diff options
| author | Christophe Simonis <chs@odoo.com> | 2018-03-07 18:46:38 +0100 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2018-03-07 18:46:38 +0100 |
| commit | 4aa5ec3fa511e42a08318fe66dde3bd57f0d1f09 (patch) | |
| tree | d3de91d0113164ce313d7eff44c2f25c7e95f743 /addons | |
| parent | 3c821d4035d0c85f691653c47986d8f4c3dfc51d (diff) | |
| parent | d67b410dfcd67d2540628d4f6be0daf8861c4a46 (diff) | |
[MERGE] forward port branch 11.0 up to d67b410dfcd
Diffstat (limited to 'addons')
64 files changed, 747 insertions, 126 deletions
diff --git a/addons/account/controllers/mail.py b/addons/account/controllers/mail.py index f30c69e21ba..564856b909c 100644 --- a/addons/account/controllers/mail.py +++ b/addons/account/controllers/mail.py @@ -11,6 +11,7 @@ from odoo.tools.misc import consteq class MailController(MailController): + @classmethod def _redirect_to_record(cls, model, res_id, access_token=None): # If the current user doesn't have access to the invoice, but provided # a valid access token, redirect him to the front-end view. diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index c477ca91d77..30934695d07 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -318,7 +318,7 @@ class AccountMoveLine(models.Model): if not cr.fetchone(): cr.execute('CREATE INDEX account_move_line_partner_id_ref_idx ON account_move_line (partner_id, ref)') - @api.depends('debit', 'credit', 'amount_currency', 'currency_id', 'matched_debit_ids', 'matched_credit_ids', 'matched_debit_ids.amount', 'matched_credit_ids.amount', 'account_id.currency_id', 'move_id.state') + @api.depends('debit', 'credit', 'amount_currency', 'currency_id', 'matched_debit_ids', 'matched_credit_ids', 'matched_debit_ids.amount', 'matched_credit_ids.amount', 'move_id.state') def _amount_residual(self): """ Computes the residual amount of a move line from a reconcilable account in the company currency and the line's currency. This amount will be 0 for fully reconciled lines or lines from a non-reconcilable account, the original line amount diff --git a/addons/account/models/company.py b/addons/account/models/company.py index d73dbf82c64..8ae4230a7b2 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -4,7 +4,7 @@ from datetime import timedelta, datetime import calendar from odoo import fields, models, api, _ -from odoo.exceptions import ValidationError +from odoo.exceptions import ValidationError, UserError from odoo.exceptions import UserError from odoo.tools.float_utils import float_round, float_is_zero @@ -130,6 +130,12 @@ Best Regards,''')) if values.get('cash_account_code_prefix'): new_cash_code = values.get('cash_account_code_prefix') or company.cash_account_code_prefix company.reflect_code_prefix_change(company.cash_account_code_prefix, new_cash_code) + + #forbid the change of currency_id if there are already some accounting entries existing + if 'currency_id' in values and values['currency_id'] != company.currency_id.id: + if self.env['account.move.line'].search([('company_id', '=', company.id)]): + raise UserError(_('You cannot change the currency of the company since some journal items already exist')) + return super(ResCompany, self).write(values) @api.model diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py index b527efa6be3..271a588dbe9 100644 --- a/addons/account/tests/test_payment.py +++ b/addons/account/tests/test_payment.py @@ -20,7 +20,9 @@ class TestPayment(AccountingTestCase): self.currency_chf_id = self.env.ref("base.CHF").id self.currency_usd_id = self.env.ref("base.USD").id self.currency_eur_id = self.env.ref("base.EUR").id - self.env.ref('base.main_company').write({'currency_id': self.currency_eur_id}) + + company = self.env.ref('base.main_company') + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", [self.currency_eur_id, company.id]) self.product = self.env.ref("product.product_product_4") self.payment_method_manual_in = self.env.ref("account.account_payment_method_manual_in") self.payment_method_manual_out = self.env.ref("account.account_payment_method_manual_out") diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index aa4fc950279..1180df172c9 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -27,7 +27,8 @@ class TestReconciliation(AccountingTestCase): self.currency_swiss_id = self.env.ref("base.CHF").id self.currency_usd_id = self.env.ref("base.USD").id self.currency_euro_id = self.env.ref("base.EUR").id - self.env.ref('base.main_company').write({'currency_id': self.currency_euro_id}) + company = self.env.ref('base.main_company') + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", [self.currency_euro_id, company.id]) self.account_rcv = partner_agrolait.property_account_receivable_id or self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_receivable').id)], limit=1) self.account_rsa = partner_agrolait.property_account_payable_id or self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_payable').id)], limit=1) self.product = self.env.ref("product.product_product_4") diff --git a/addons/barcodes/static/src/js/barcode_form_view.js b/addons/barcodes/static/src/js/barcode_form_view.js index fa5b27e2a9c..1730b42290e 100644 --- a/addons/barcodes/static/src/js/barcode_form_view.js +++ b/addons/barcodes/static/src/js/barcode_form_view.js @@ -155,7 +155,7 @@ FormController.include({ id: candidate.id, data: candidateChanges, }; - return this.model.notifyChanges(this.handle, changes); + return this.model.notifyChanges(this.handle, changes, {notifyChange: activeBarcode.notifyChange}); }, /** * @private @@ -211,6 +211,8 @@ FormController.include({ * @param {OdooEvent} event * @param {string} event.data.name: the current field name * @param {string} [event.data.fieldName] optional for x2many sub field + * @param {boolean} [event.data.notifyChange] optional for x2many sub field + * do not trigger on change server side if a candidate has been found * @param {string} [event.data.quantity] optional field to increase quantity * @param {Object} [event.data.commands] optional added methods * can use comand with specific barcode (with ReservedBarcodePrefixes) @@ -225,7 +227,9 @@ FormController.include({ handle: this.handle, target: event.target, widget: event.target.attrs && event.target.attrs.widget, + setQuantityWithKeypress: !! event.data.setQuantityWithKeypress, fieldName: event.data.fieldName, + notifyChange: (event.data.notifyChange !== undefined) ? event.data.notifyChange : true, quantity: event.data.quantity, commands: event.data.commands || {}, candidate: this.activeBarcode[name] && this.activeBarcode[name].handle === this.handle ? @@ -319,7 +323,7 @@ FormController.include({ // only catch the event if we're not focused in // another field and it's a number - if (!$(event.target).is('body') || !/[0-9]/.test(character)) { + if (!$(event.target).is('body, .modal') || !/[0-9]/.test(character)) { return; } diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 1679198caf3..fb233a50da6 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -971,6 +971,8 @@ class Lead(models.Model): 'nb_opportunities': 0, } + today = fields.Date.from_string(fields.Date.context_today(self)) + opportunities = self.search([('type', '=', 'opportunity'), ('user_id', '=', self._uid)]) for opp in opportunities: @@ -978,28 +980,28 @@ class Lead(models.Model): if opp.activity_date_deadline: if opp.date_deadline: date_deadline = fields.Date.from_string(opp.date_deadline) - if date_deadline == date.today(): + if date_deadline == today: result['closing']['today'] += 1 - if date.today() <= date_deadline <= date.today() + timedelta(days=7): + if today <= date_deadline <= today + timedelta(days=7): result['closing']['next_7_days'] += 1 - if date_deadline < date.today() and not opp.date_closed: + if date_deadline < today and not opp.date_closed: result['closing']['overdue'] += 1 # Next activities for activity in opp.activity_ids: date_deadline = fields.Date.from_string(activity.date_deadline) - if date_deadline == date.today(): + if date_deadline == today: result['activity']['today'] += 1 - if date.today() <= date_deadline <= date.today() + timedelta(days=7): + if today <= date_deadline <= today + timedelta(days=7): result['activity']['next_7_days'] += 1 - if date_deadline < date.today(): + if date_deadline < today: result['activity']['overdue'] += 1 # Won in Opportunities if opp.date_closed and opp.stage_id.probability == 100: date_closed = fields.Date.from_string(opp.date_closed) - if date.today().replace(day=1) <= date_closed <= date.today(): + if today.replace(day=1) <= date_closed <= today: if opp.planned_revenue: result['won']['this_month'] += opp.planned_revenue - elif date.today() + relativedelta(months=-1, day=1) <= date_closed < date.today().replace(day=1): + elif today + relativedelta(months=-1, day=1) <= date_closed < today.replace(day=1): if opp.planned_revenue: result['won']['last_month'] += opp.planned_revenue @@ -1024,9 +1026,9 @@ class Lead(models.Model): for activity in activites_done: if activity['date']: date_act = fields.Date.from_string(activity['date']) - if date.today().replace(day=1) <= date_act <= date.today(): + if today.replace(day=1) <= date_act <= today: result['done']['this_month'] += 1 - elif date.today() + relativedelta(months=-1, day=1) <= date_act < date.today().replace(day=1): + elif today + relativedelta(months=-1, day=1) <= date_act < today.replace(day=1): result['done']['last_month'] += 1 # Meetings @@ -1041,9 +1043,9 @@ class Lead(models.Model): for meeting in meetings: if meeting['start']: start = datetime.strptime(meeting['start'], tools.DEFAULT_SERVER_DATETIME_FORMAT).date() - if start == date.today(): + if start == today: result['meeting']['today'] += 1 - if date.today() <= start <= date.today() + timedelta(days=7): + if today <= start <= today + timedelta(days=7): result['meeting']['next_7_days'] += 1 result['done']['target'] = self.env.user.target_sales_done diff --git a/addons/delivery/models/__init__.py b/addons/delivery/models/__init__.py index 5229017a189..9e162e76592 100644 --- a/addons/delivery/models/__init__.py +++ b/addons/delivery/models/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import res_config_settings from . import delivery_carrier from . import delivery_grid from . import product_template diff --git a/addons/delivery/models/res_config_settings.py b/addons/delivery/models/res_config_settings.py new file mode 100644 index 00000000000..070f8dcdb29 --- /dev/null +++ b/addons/delivery/models/res_config_settings.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + def set_values(self): + super(ResConfigSettings, self).set_values() + rule = self.env.ref('delivery.delivery_carrier_comp_rule', False) + if rule: + rule.write({'active': not bool(self.company_share_product)}) diff --git a/addons/delivery/security/delivery_carrier_security.xml b/addons/delivery/security/delivery_carrier_security.xml index 8fad0891676..db718fbf361 100644 --- a/addons/delivery/security/delivery_carrier_security.xml +++ b/addons/delivery/security/delivery_carrier_security.xml @@ -6,6 +6,7 @@ <field name="model_id" ref="model_delivery_carrier"/> <field name="global" eval="True"/> <field name="domain_force"> ['|',('company_id','=',user.company_id.id),('company_id','=',False)]</field> + <field name="active" eval="False"/> </record> </data> </odoo> diff --git a/addons/delivery/security/delivery_security.xml b/addons/delivery/security/delivery_security.xml new file mode 100644 index 00000000000..ae588bc245c --- /dev/null +++ b/addons/delivery/security/delivery_security.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> +<data noupdate="1"> + <record id="delivery_comp_rule" model="ir.rule"> + <field name="name">Delivery multi-company</field> + <field name="model_id" ref="model_delivery_carrier"/> + <field name="global" eval="True"/> + <field name="domain_force"> ['|',('product_id.company_id','=',user.company_id.id),('product_id.company_id','=',False)]</field> + <field name="active" eval="False"/> + </record> +</data> +</odoo> diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 1cf1ee9b17f..3e0c3ea7ca1 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -88,7 +88,7 @@ class HolidaysRequest(models.Model): @api.onchange('employee_id') def _onchange_employee_id(self): self.manager_id = self.employee_id and self.employee_id.parent_id - self.department_id = self.employee_id and self.employee_id.department_id + self.department_id = self.employee_id.department_id @api.multi @api.depends('number_of_days_temp') diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index ee1d5833fb0..ddfd68e0d56 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -14,7 +14,7 @@ class AccountAnalyticLine(models.Model): result['employee_id'] = self.env['hr.employee'].search([('user_id', '=', result['user_id'])], limit=1).id return result - task_id = fields.Many2one('project.task', 'Task') + task_id = fields.Many2one('project.task', 'Task', index=True) project_id = fields.Many2one('project.project', 'Project', domain=[('allow_timesheets', '=', True)]) employee_id = fields.Many2one('hr.employee', "Employee") diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py index 11eabd18423..e228524893a 100644 --- a/addons/im_livechat/controllers/main.py +++ b/addons/im_livechat/controllers/main.py @@ -110,7 +110,8 @@ class LivechatController(http.Controller): @http.route('/im_livechat/history', type="json", auth="public") def history_pages(self, pid, channel_uuid, page_history=None): - channel = request.env['mail.channel'].search([('uuid', '=', channel_uuid)]) + partner_ids = (pid, request.env.user.partner_id.id) + channel = request.env['mail.channel'].sudo().search([('uuid', '=', channel_uuid), ('channel_partner_ids', 'in', partner_ids)]) if channel: channel._send_history_message(pid, page_history) return True diff --git a/addons/im_livechat/models/im_livechat_channel.py b/addons/im_livechat/models/im_livechat_channel.py index c2741fcef92..783fbd417ef 100644 --- a/addons/im_livechat/models/im_livechat_channel.py +++ b/addons/im_livechat/models/im_livechat_channel.py @@ -238,7 +238,7 @@ class ImLivechatChannelRule(models.Model): """ def _match(rules): for rule in rules: - if re.search(rule.regex_url, url): + if re.search(rule.regex_url or '', url): return rule return False # first, search the country specific rules (the first match is returned) diff --git a/addons/l10n_be_hr_payroll_fleet/views/res_config_settings_views.xml b/addons/l10n_be_hr_payroll_fleet/views/res_config_settings_views.xml index 4c095e2eea5..f4800282ec2 100644 --- a/addons/l10n_be_hr_payroll_fleet/views/res_config_settings_views.xml +++ b/addons/l10n_be_hr_payroll_fleet/views/res_config_settings_views.xml @@ -15,9 +15,9 @@ <div class="o_setting_left_pane"/> <div class="o_setting_right_pane"> <label string="New Car Request"/> - <div class="text-muted o_row"> + <div class="text-muted content-group mt16"> <span>An employee cannot request a new car if there are more than </span> - <field name="max_unused_cars"/> + <field name="max_unused_cars" class="text-center oe_inline" /> <span> existing vehicles availables.</span> </div> </div> diff --git a/addons/l10n_in/views/report_invoice.xml b/addons/l10n_in/views/report_invoice.xml index f5cae06634c..6bde405adc7 100644 --- a/addons/l10n_in/views/report_invoice.xml +++ b/addons/l10n_in/views/report_invoice.xml @@ -73,21 +73,21 @@ <td class="text-right" t-if="is_cgst"> <t t-foreach="tax_datas[l.id]" t-as="tax_data"> <t t-if="tag_cgst_id in tax_data['tag_ids']"> - <t t-esc="tax_data['amount']"/> + <t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> </td> <td class="text-right" t-if="is_cgst"> <t t-foreach="tax_datas[l.id]" t-as="tax_data"> <t t-if="tag_sgst_id in tax_data['tag_ids']"> - <t t-esc="tax_data['amount']"/> + <t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> </td> <td class="text-right" t-if="is_igst"> <t t-foreach="tax_datas[l.id]" t-as="tax_data"> <t t-if="tag_igst_id in tax_data['tag_ids']"> - <t t-esc="tax_data['amount']"/> + <t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> </td> @@ -95,10 +95,10 @@ <td class="text-right" t-if="is_cess"> <t t-foreach="tax_datas[l.id]" t-as="tax_data"> <t t-if="tag_cess_id in tax_data['tag_ids']"> - <t t-set="total_cess" t-value="total_cess + tax_data['amount']"/> + <t t-set="total_cess" t-value="total_cess + tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> - <t t-esc="total_cess"/> + <t t-esc="total_cess" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </td> </t> </tr> diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py index 2670d8b25ac..beceb943d21 100644 --- a/addons/mail/models/mail_activity.py +++ b/addons/mail/models/mail_activity.py @@ -348,6 +348,15 @@ class MailActivityMixin(models.AbstractModel): return [('activity_ids.summary', operator, operand)] @api.multi + def write(self, vals): + # Delete activities of archived record. + if 'active' in vals and vals['active'] is False: + self.env['mail.activity'].sudo().search( + [('res_model', '=', self._name), ('res_id', 'in', self.ids)] + ).unlink() + return super(MailActivityMixin, self).write(vals) + + @api.multi def unlink(self): """ Override unlink to delete records activities through (res_model, res_id). """ record_ids = self.ids diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index cf80ed32995..b5f8a1c7849 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -271,7 +271,7 @@ </t> <t t-name="mail.Attachment"> - <t t-set="type" t-value="attachment.mimetype.split('/').shift()"/> + <t t-set="type" t-value="attachment.mimetype and attachment.mimetype.split('/').shift()"/> <div t-attf-class="o_attachment #{attachment.upload ? 'o_attachment_uploading' : ''}" t-att-title="attachment.name"> <t t-if="type == 'image'"> <div class="o_image_box"> diff --git a/addons/note_pad/models/note.py b/addons/note_pad/models/note.py index 080e313a873..49536d79b88 100644 --- a/addons/note_pad/models/note.py +++ b/addons/note_pad/models/note.py @@ -11,4 +11,4 @@ class NotePad(models.Model): _pad_fields = ['note_pad'] - note_pad_url = fields.Char('Pad Url', pad_content_field='memo') + note_pad_url = fields.Char('Pad Url', pad_content_field='memo', copy=False) diff --git a/addons/pad/models/pad.py b/addons/pad/models/pad.py index a1e31bf485a..deb920ec69e 100644 --- a/addons/pad/models/pad.py +++ b/addons/pad/models/pad.py @@ -101,16 +101,19 @@ class PadCommon(models.AbstractModel): @api.multi def write(self, vals): - self._set_pad_value(vals) + self._set_field_to_pad(vals) + self._set_pad_to_field(vals) return super(PadCommon, self).write(vals) @api.model def create(self, vals): - self._set_pad_value(vals) + # Case of a regular creation: we receive the pad url, so we need to update the + # corresponding field + self._set_pad_to_field(vals) pad = super(PadCommon, self).create(vals) - # In case the pad is created programmatically, the content is not filled in yet since it is - # normally initialized by the JS layer + # Case of a programmatical creation (e.g. copy): we receive the field content, so we need + # to create the corresponding pad if self.env.context.get('pad_no_create', False): return pad for k, field in self._fields.items(): @@ -124,9 +127,7 @@ class PadCommon(models.AbstractModel): pad[k] = pad_info.get('url') return pad - # Set the pad content in vals - def _set_pad_value(self, vals): - + def _set_field_to_pad(self, vals): # Update the pad if the `pad_content_field` is modified for k, field in self._fields.items(): if hasattr(field, 'pad_content_field') and vals.get(field.pad_content_field) and self[k]: @@ -135,22 +136,9 @@ class PadCommon(models.AbstractModel): path = self[k].split('/p/')[1] myPad.setHtmlFallbackText(path, vals[field.pad_content_field]) + def _set_pad_to_field(self, vals): # Update the `pad_content_field` if the pad is modified for k, v in list(vals.items()): field = self._fields.get(k) if hasattr(field, 'pad_content_field'): vals[field.pad_content_field] = self.pad_get_content(v) - - @api.multi - def copy(self, default=None): - self.ensure_one() - if not default: - default = {} - for k, field in self._fields.items(): - if hasattr(field, 'pad_content_field'): - if self.env.context.get('pad_no_create', False): - default[k] = '' - else: - pad = self.pad_generate_url() - default[k] = pad.get('url') - return super(PadCommon, self).copy(default) diff --git a/addons/pad_project/models/project.py b/addons/pad_project/models/project.py index eae27a4f6a7..f994b3a5ea7 100644 --- a/addons/pad_project/models/project.py +++ b/addons/pad_project/models/project.py @@ -8,7 +8,7 @@ class ProjectTask(models.Model): _name = "project.task" _inherit = ["project.task", 'pad.common'] - description_pad = fields.Char('Pad URL', pad_content_field='description') + description_pad = fields.Char('Pad URL', pad_content_field='description', copy=False) use_pad = fields.Boolean(related="project_id.use_pads", string="Use collaborative pad") @api.model @@ -19,12 +19,6 @@ class ProjectTask(models.Model): self = self.with_context(pad_no_create=True) return super(ProjectTask, self).create(vals) - @api.multi - def copy(self, default=None): - if not self.use_pad: - self = self.with_context(pad_no_create=True) - return super(ProjectTask, self).copy(default) - class ProjectProject(models.Model): _inherit = "project.project" diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index 444d936429b..7e023e01c7a 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -73,7 +73,8 @@ class WebsitePayment(http.Controller): return tx - @http.route(['/website_payment/transaction/<string:reference>/<string:amount>/<string:currency_id>',], type='json', auth='public') + @http.route(['/website_payment/transaction/<string:reference>/<string:amount>/<string:currency_id>', + '/website_payment/transaction/v2/<string:amount>/<string:currency_id>/<path:reference>',], type='json', auth='public') def transaction(self, acquirer_id, reference, amount, currency_id, **kwargs): partner_id = request.env.user.partner_id.id if not request.env.user._is_public() else False acquirer = request.env['payment.acquirer'].browse(acquirer_id) @@ -100,7 +101,8 @@ class WebsitePayment(http.Controller): return acquirer.sudo().render(reference, float(amount), int(currency_id), values=render_values) - @http.route(['/website_payment/token/<string:reference>/<float:amount>/<int:currency_id>'], type='http', auth='public', website=True) + @http.route(['/website_payment/token/<string:reference>/<string:amount>/<string:currency_id>', + '/website_payment/token/v2/<string:amount>/<string:currency_id>/<path:reference>'], type='http', auth='public', website=True) def payment_token(self, pm_id, reference, amount, currency_id, return_url=None, **kwargs): token = request.env['payment.token'].browse(int(pm_id)) diff --git a/addons/payment/security/ir.model.access.csv b/addons/payment/security/ir.model.access.csv index 2982f1ab0c0..76d11991b88 100644 --- a/addons/payment/security/ir.model.access.csv +++ b/addons/payment/security/ir.model.access.csv @@ -1,6 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink payment_acquirer_all,payment.acquirer.all,model_payment_acquirer,,1,0,0,0 -payment_acquirer_user,payment.acquirer.user,model_payment_acquirer,base.group_user,1,1,1,0 payment_acquirer_system,payment.acquirer.system,model_payment_acquirer,base.group_system,1,1,1,1 payment_transaction_all,payment.transaction.all,model_payment_transaction,,1,0,0,0 payment_transaction_user,payment.transaction.user,model_payment_transaction,base.group_user,1,1,1,0 diff --git a/addons/payment/views/payment_portal_templates.xml b/addons/payment/views/payment_portal_templates.xml index 166cd429718..2224eeec179 100644 --- a/addons/payment/views/payment_portal_templates.xml +++ b/addons/payment/views/payment_portal_templates.xml @@ -55,8 +55,8 @@ <p><b>Amount:</b> <t t-esc="amount" t-options="{'widget': 'monetary', 'display_currency': currency}"/></p> <t t-call="payment.payment_tokens_list" t-if="reference and amount and currency"> <t t-set="mode" t-value="'payment'"/> - <t t-set="prepare_tx_url" t-value="'/website_payment/transaction/' + reference + '/' + str(amount) + '/' + str(currency.id)"/> - <t t-set="form_action" t-value="'/website_payment/token/' + reference + '/' + str(amount) + '/' + str(currency.id)"/> + <t t-set="prepare_tx_url" t-value="'/website_payment/transaction/v2/' + str(amount) + '/' + str(currency.id) + '/' + reference"/> + <t t-set="form_action" t-value="'/website_payment/token/v2/' + str(amount) + '/' + str(currency.id) + '/' + reference"/> </t> <div t-if="not (s2s_acquirers or form_acquirers)" class="alert alert-danger"> <p>No payment acquirer found.</p> diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js index 7ecf25284f6..e4474fe72ce 100644 --- a/addons/payment_stripe/static/src/js/stripe.js +++ b/addons/payment_stripe/static/src/js/stripe.js @@ -109,8 +109,8 @@ odoo.define('payment_stripe.stripe', function(require) { if ($('.o_website_payment').length !== 0) { var invoice_num = $("input[name='invoice_num']").val(); - var url = _.str.sprintf("/website_payment/transaction/%s/%f/%s", - invoice_num, amount, currency_id); + var url = _.str.sprintf("/website_payment/transaction/v2/%f/%s/%s", + amount, currency_id, invoice_num); var create_tx = ajax.jsonRpc(url, 'call', { acquirer_id: acquirer_id diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 704c9084e37..19d0b32d58f 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -160,13 +160,14 @@ class PosOrder(models.Model): """ Prepare the dict of values to create the new invoice for a pos order. """ + invoice_type = 'out_invoice' if self.amount_total >= 0 else 'out_refund' return { 'name': self.name, 'origin': self.name, 'account_id': self.partner_id.property_account_receivable_id.id, 'journal_id': self.session_id.config_id.invoice_journal_id.id, 'company_id': self.company_id.id, - 'type': 'out_invoice', + 'type': invoice_type, 'reference': self.name, 'partner_id': self.partner_id.id, 'comment': self.note or '', @@ -208,7 +209,7 @@ class PosOrder(models.Model): inv_line = { 'invoice_id': invoice_id, 'product_id': line.product_id.id, - 'quantity': line.qty, + 'quantity': line.qty if self.amount_total >= 0 else -line.qty, 'account_analytic_id': self._prepare_analytic_account(line), 'name': inv_name, } diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 9f940a8bfeb..054f5ad4b09 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -526,7 +526,7 @@ class Task(models.Model): legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True, related_sudo=False) legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True, related_sudo=False) legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True, related_sudo=False) - parent_id = fields.Many2one('project.task', string='Parent Task') + parent_id = fields.Many2one('project.task', string='Parent Task', index=True) child_ids = fields.One2many('project.task', 'parent_id', string="Sub-tasks") subtask_count = fields.Integer("Sub-task count", compute='_compute_subtask_count') email_from = fields.Char(string='Email', help="These people will receive email.", index=True) diff --git a/addons/purchase/models/stock.py b/addons/purchase/models/stock.py index 4f5288486bf..fa38fa44bae 100644 --- a/addons/purchase/models/stock.py +++ b/addons/purchase/models/stock.py @@ -34,14 +34,14 @@ class StockMove(models.Model): @api.model def _prepare_merge_moves_distinct_fields(self): distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields() - distinct_fields.append('purchase_line_id') + distinct_fields += ['purchase_line_id', 'created_purchase_line_id'] return distinct_fields @api.model def _prepare_merge_move_sort_method(self, move): move.ensure_one() keys_sorted = super(StockMove, self)._prepare_merge_move_sort_method(move) - keys_sorted.append(move.purchase_line_id.id) + keys_sorted += [move.purchase_line_id.id, move.created_purchase_line_id.id] return keys_sorted @api.multi diff --git a/addons/purchase/tests/test_create_picking.py b/addons/purchase/tests/test_create_picking.py index 9cdfe0fb4fd..a056a11a651 100644 --- a/addons/purchase/tests/test_create_picking.py +++ b/addons/purchase/tests/test_create_picking.py @@ -224,3 +224,92 @@ class TestCreatePicking(common.TestProductCommon): # check the delivered quantity self.assertEqual(po.order_line.qty_received, 3.0) + def test_04_mto_multiple_po(self): + """ Simulate a mto chain with 2 purchase order. + Create a move with qty 1, confirm the RFQ then create a new + move that will not be merged in the first one(simulate an increase + order quantity on a SO). It should generate a new RFQ, validate + and receipt the picking then try to reserve the delivery + picking. + """ + stock_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_stock') + customer_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_customers') + picking_type_out = self.env['ir.model.data'].xmlid_to_object('stock.picking_type_out') + # route buy should be there by default + partner = self.env['res.partner'].create({ + 'name': 'Jhon' + }) + + seller = self.env['product.supplierinfo'].create({ + 'name': partner.id, + 'price': 12.0, + }) + + product = self.env['product.product'].create({ + 'name': 'product', + 'type': 'product', + 'route_ids': [(4, self.ref('stock.route_warehouse0_mto')), (4, self.ref('purchase.route_warehouse0_buy'))], + 'seller_ids': [(6, 0, [seller.id])], + 'categ_id': self.env.ref('product.product_category_all').id, + }) + + # A picking is require since only moves inside the same picking are merged. + customer_picking = self.env['stock.picking'].create({ + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'partner_id': partner.id, + 'picking_type_id': picking_type_out.id, + }) + + customer_move = self.env['stock.move'].create({ + 'name': 'move out', + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 80.0, + 'procure_method': 'make_to_order', + 'picking_id': customer_picking.id, + }) + + customer_move._action_confirm() + + purchase_order = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + self.assertTrue(purchase_order, 'No purchase order created.') + + # Check purchase order line data. + purchase_order_line = purchase_order.order_line + self.assertEqual(purchase_order_line.product_id, product, 'The product on the purchase order line is not correct.') + self.assertEqual(purchase_order_line.price_unit, seller.price, 'The purchase order line price should be the same than the seller.') + self.assertEqual(purchase_order_line.product_qty, customer_move.product_uom_qty, 'The purchase order line qty should be the same than the move.') + + purchase_order.button_confirm() + + customer_move_2 = self.env['stock.move'].create({ + 'name': 'move out', + 'location_id': stock_location.id, + 'location_dest_id': customer_location.id, + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 20.0, + 'procure_method': 'make_to_order', + 'picking_id': customer_picking.id, + }) + + customer_move_2._action_confirm() + + self.assertTrue(customer_move_2.exists(), 'The second customer move should not be merged in the first.') + self.assertEqual(sum(customer_picking.move_lines.mapped('product_uom_qty')), 100.0) + + purchase_order_2 = self.env['purchase.order'].search([('partner_id', '=', partner.id), ('state', '=', 'draft')]) + self.assertTrue(purchase_order_2, 'No purchase order created.') + + purchase_order_2.button_confirm() + + purchase_order.picking_ids.move_lines.quantity_done = 80.0 + purchase_order.picking_ids.button_validate() + + purchase_order_2.picking_ids.move_lines.quantity_done = 20.0 + purchase_order_2.picking_ids.button_validate() + + self.assertEqual(sum(customer_picking.move_lines.mapped('reserved_availability')), 100.0, 'The total quantity for the customer move should be available and reserved.') diff --git a/addons/sale/__init__.py b/addons/sale/__init__.py index 0b45acc7630..17753815848 100644 --- a/addons/sale/__init__.py +++ b/addons/sale/__init__.py @@ -16,7 +16,8 @@ def uninstall_hook(cr, registry): db_registry = odoo.modules.registry.Registry.new(dbname) with api.Environment.manage(), db_registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) - env['account.invoice.report'].init() + if 'account.invoice.report' in env: + env['account.invoice.report'].init() if 'crm.team' in env: recs = env['crm.team'].search([]) for rec in recs: diff --git a/addons/sale/controllers/mail.py b/addons/sale/controllers/mail.py index cd0701e21db..d257bfa2736 100644 --- a/addons/sale/controllers/mail.py +++ b/addons/sale/controllers/mail.py @@ -11,6 +11,7 @@ from odoo.tools.misc import consteq class MailController(MailController): + @classmethod def _redirect_to_record(cls, model, res_id, access_token=None): # If the current user doesn't have access to the sales order, but provided # a valid access token, redirect him to the front-end view. diff --git a/addons/sale_crm/models/crm_lead.py b/addons/sale_crm/models/crm_lead.py index 7409fdccaa5..b4eeb43f565 100644 --- a/addons/sale_crm/models/crm_lead.py +++ b/addons/sale_crm/models/crm_lead.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import date from dateutil.relativedelta import relativedelta from odoo import api, fields, models @@ -31,7 +30,7 @@ class CrmLead(models.Model): @api.model def retrieve_sales_dashboard(self): res = super(CrmLead, self).retrieve_sales_dashboard() - date_today = date.today() + date_today = fields.Date.from_string(fields.Date.context_today(self)) res['invoiced'] = { 'this_month': 0, diff --git a/addons/sale_stock/models/account_invoice.py b/addons/sale_stock/models/account_invoice.py index 4b9212beb8f..3b4c60cc413 100644 --- a/addons/sale_stock/models/account_invoice.py +++ b/addons/sale_stock/models/account_invoice.py @@ -51,7 +51,9 @@ class AccountInvoiceLine(models.Model): qty_to_consider = invoiced_qty - qty_done qty_to_consider = min(qty_to_consider, quantity - qty_delivered) qty_delivered += qty_to_consider - average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + (-1 * move.price_unit) * qty_to_consider) / qty_delivered + # `move.price_unit` is negative if the move is out and positive if the move is + # dropshipped. Use its absolute value to compute the average price unit. + average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + abs(move.price_unit) * qty_to_consider) / qty_delivered if qty_delivered == quantity: break return average_price_unit diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 335a6b657fe..83a0c1b82d2 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -137,7 +137,7 @@ class SaleOrderLine(models.Model): _inherit = "sale.order.line" qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')]) - task_id = fields.Many2one('project.task', 'Task', help="Task generated by the sales order item") + task_id = fields.Many2one('project.task', 'Task', index=True, help="Task generated by the sales order item") is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.") analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense) diff --git a/addons/stock/data/stock_demo2.xml b/addons/stock/data/stock_demo2.xml index 8b86d919a8e..155d18507c0 100644 --- a/addons/stock/data/stock_demo2.xml +++ b/addons/stock/data/stock_demo2.xml @@ -419,13 +419,6 @@ obj().env.ref('stock.incomming_chicago_warehouse3').id]"/> </function> - <function model="stock.picking" name="force_assign"> - <value model="stock.picking" eval="[ - obj().env.ref('stock.outgoing_shipment_main_warehouse').id, - obj().env.ref('stock.outgoing_chicago_warehouse3').id, - obj().env.ref('stock.outgoing_shipment_main_warehouse2').id]"/> - </function> - <function model="stock.picking" name="do_transfer"> <value model="stock.picking" eval="[ obj().env.ref('stock.outgoing_shipment_main_warehouse1').id, diff --git a/addons/stock/models/procurement.py b/addons/stock/models/procurement.py index bdd554ca8cf..ba047badef8 100644 --- a/addons/stock/models/procurement.py +++ b/addons/stock/models/procurement.py @@ -236,7 +236,7 @@ class ProcurementGroup(models.Model): self.sudo()._procure_orderpoint_confirm(use_new_cursor=use_new_cursor, company_id=company_id) # Search all confirmed stock_moves and try to assign them - confirmed_moves = self.env['stock.move'].search([('state', '=', 'confirmed')], limit=None, order='priority desc, date_expected asc') + confirmed_moves = self.env['stock.move'].search([('state', '=', 'confirmed'), ('product_uom_qty', '!=', 0.0)], limit=None, order='priority desc, date_expected asc') for moves_chunk in split_every(100, confirmed_moves.ids): self.env['stock.move'].browse(moves_chunk)._action_assign() if use_new_cursor: diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 17e7a6c8b93..3dbea80703b 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -159,6 +159,7 @@ class StockMove(models.Model): is_initial_demand_editable = fields.Boolean('Is initial demand editable', compute='_compute_is_initial_demand_editable') is_quantity_done_editable = fields.Boolean('Is quantity done editable', compute='_compute_is_quantity_done_editable') reference = fields.Char(compute='_compute_reference', string="Reference", store=True) + has_move_lines = fields.Boolean(compute='_compute_has_move_lines') @api.depends('picking_id.is_locked') def _compute_is_locked(self): @@ -218,6 +219,8 @@ class StockMove(models.Model): move.is_quantity_done_editable = False elif move.show_details_visible: move.is_quantity_done_editable = False + elif move.show_operations: + move.is_quantity_done_editable = False else: move.is_quantity_done_editable = True @@ -226,6 +229,11 @@ class StockMove(models.Model): for move in self: move.reference = move.picking_id.name if move.picking_id else move.name + @api.depends('move_line_ids') + def _compute_has_move_lines(self): + for move in self: + move.has_move_lines = bool(move.move_line_ids) + @api.one @api.depends('product_id', 'product_uom', 'product_uom_qty') def _compute_product_qty(self): diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 21d929f772e..d485a08131f 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -167,7 +167,7 @@ class StockMoveLine(models.Model): # If the move line is directly create on the picking view. # If this picking is already done we should generate an # associated done move. - if 'picking_id' in vals and 'move_id' not in vals: + if 'picking_id' in vals and not vals.get('move_id'): picking = self.env['stock.picking'].browse(vals['picking_id']) if picking.state == 'done': product = self.env['product.product'].browse(vals['product_id']) diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index d00b2575c61..1147c9e6a96 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -514,7 +514,18 @@ class Picking(models.Model): if after_vals: self.mapped('move_lines').filtered(lambda move: not move.scrapped).write(after_vals) if vals.get('move_lines'): - self._autoconfirm_picking() + # Do not run autoconfirm if any of the moves has an initial demand. If an initial demand + # is present in any of the moves, it means the picking was created through the "planned + # transfer" mechanism. + pickings_to_not_autoconfirm = self.env['stock.picking'] + for picking in self: + if picking.state != 'draft': + continue + for move in picking.move_lines: + if not float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding): + pickings_to_not_autoconfirm |= picking + break + (self - pickings_to_not_autoconfirm)._autoconfirm_picking() return res @api.multi diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py index 35da952faa9..d7bbc745703 100644 --- a/addons/stock/tests/test_move2.py +++ b/addons/stock/tests/test_move2.py @@ -185,6 +185,51 @@ class TestPickShip(TestStockCommon): # the client picking should not be assigned anymore, as we returned partially what we took self.assertEqual(picking_client.state, 'confirmed') + def test_mto_resupply_cancel_ship(self): + """ This test simulates a pick pack ship with a resupply route + set. Pick and pack are validated, ship is cancelled. This test + ensure that new picking are not created from the cancelled + ship after the scheduler task. The supply route is only set in + order to make the scheduler run without mistakes (no next + activity). + """ + picking_pick, picking_pack, picking_ship = self.create_pick_pack_ship() + stock_location = self.env['stock.location'].browse(self.stock_location) + warehouse_1 = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1) + warehouse_1.write({'delivery_steps': 'pick_pack_ship'}) + warehouse_2 = self.env['stock.warehouse'].create({ + 'name': 'Small Warehouse', + 'code': 'SWH' + }) + warehouse_1.write({ + 'default_resupply_wh_id': warehouse_2.id, + 'resupply_wh_ids': [(6, 0, [warehouse_2.id])] + }) + resupply_route = self.env['stock.location.route'].search([('supplier_wh_id', '=', warehouse_2.id), ('supplied_wh_id', '=', warehouse_1.id)]) + self.assertTrue(resupply_route) + self.productA.write({'route_ids': [(4, resupply_route.id), (4, self.env.ref('stock.route_warehouse0_mto').id)]}) + + self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 10.0) + + picking_pick.action_assign() + picking_pick.move_lines[0].move_line_ids[0].qty_done = 10.0 + picking_pick.action_done() + + picking_pack.action_assign() + picking_pack.move_lines[0].move_line_ids[0].qty_done = 10.0 + picking_pack.action_done() + + picking_ship.action_cancel() + picking_ship.move_lines.write({'procure_method': 'make_to_order'}) + + self.env['procurement.group'].run_scheduler() + next_activity = self.env['mail.activity'].search([('res_model', '=', 'product.template'), ('res_id', '=', self.productA.product_tmpl_id.id)]) + self.assertEqual(picking_ship.state, 'cancel') + self.assertFalse(next_activity, 'If a next activity has been created if means that scheduler failed\ + and the end of this test do not have sense.') + self.assertEqual(len(picking_ship.move_lines.mapped('move_orig_ids')), 0, + 'Scheduler should not create picking pack and pick since ship has been manually cancelled.') + def test_no_backorder_1(self): """ Check the behavior of doing less than asked in the picking pick and chosing not to create a backorder. In this behavior, the second picking should obviously only be able to diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index f9c5d607995..bda4b4decc8 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -257,7 +257,8 @@ <page string="Detailed Operations" attrs="{'invisible': [('show_operations', '=', False)]}"> <field name="move_line_ids" attrs="{'readonly': [('state', '=', 'done'), ('is_locked', '=', True)]}" context="{'default_picking_id': id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}"> <tree editable="bottom" decoration-muted="(state == 'done' and is_locked == True)" decoration-danger="qty_done>product_uom_qty and state!='done'" decoration-success="qty_done==product_uom_qty and state!='done' and not result_package_id"> - <field name="product_id" required="1"/> + <field name="product_id" required="1" attrs="{'readonly': ['|', ('state', '=', 'done'), ('move_id', '!=', False)]}"/> + <field name="move_id" invisible="1"/> <field name="in_entire_package" invisible="1"/> <field name="product_uom_id" force_save="1" attrs="{'readonly': [('state', '!=', 'draft')]}" groups="product.group_uom"/> <field name="package_id" attrs="{'readonly': [('in_entire_package', '=', True)]}" groups="stock.group_tracking_lot" domain="[('location_id', '=', location_id)]"/> @@ -270,7 +271,7 @@ <field name="lot_id" groups="stock.group_production_lot" attrs="{'column_invisible': [('parent.show_lots_text', '=', True)], 'invisible': [('lots_visible', '=', False)], 'readonly': [('in_entire_package', '=', True)]}" domain="[('product_id', '=', product_id)]" context="{'default_product_id': product_id}"/> <field name="lot_name" groups="stock.group_production_lot" attrs="{'column_invisible': [('parent.show_lots_text', '=', False)], 'invisible': [('lots_visible', '=', False)], 'readonly': [('in_entire_package', '=', True)]}" context="{'default_product_id': product_id}"/> <field name="is_initial_demand_editable" invisible="1"/> - <field name="product_uom_qty" attrs="{'readonly': ['|', ('is_initial_demand_editable', '=', False), ('in_entire_package', '=', True)], 'required': [('product_id', '!=', False)]}"/> + <field name="product_uom_qty" readonly="1"/> <field name="is_locked" invisible="1"/> <field name="qty_done" attrs="{'readonly': ['|', ('state', 'in', ('done', 'cancel')), ('is_locked', '=', True), ('in_entire_package', '=', True)]}" force_save="1"/> </tree> @@ -280,7 +281,7 @@ </page> <page string="Operations"> <field name="id" invisible="1"/> - <field name="move_lines" attrs="{'readonly': ['|', ('show_operations', '=', True), '&', ('state', '=', 'done'), ('is_locked', '=', True)]}" context="{'picking_type_code': picking_type_code, 'default_picking_id': id, 'form_view_ref':'stock.view_move_picking_form', 'address_in_id': partner_id, 'default_picking_type_id': picking_type_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}"> + <field name="move_lines" attrs="{'readonly': ['|', '&', ('show_operations', '=', True), '|', ('is_locked', '=', True), ('state', '=', 'done'), '&', ('state', '=', 'done'), ('is_locked', '=', True)]}" context="{'picking_type_code': picking_type_code, 'default_picking_id': id, 'form_view_ref':'stock.view_move_picking_form', 'address_in_id': partner_id, 'default_picking_type_id': picking_type_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}"> <tree decoration-danger="state != 'done' and quantity_done > reserved_availability and show_reserved_availability" decoration-muted="scrapped == True or state == 'cancel' or (state == 'done' and is_locked == True)" string="Stock Moves" editable="bottom"> <field name="name" invisible="1"/> <field name="date_expected" invisible="1"/> @@ -295,8 +296,9 @@ <field name="show_reserved_availability" invisible="1"/> <field name="show_operations" invisible="1" readonly="1"/> <field name="additional" invisible="1"/> + <field name="has_move_lines" invisible="1"/> <field name="is_locked" invisible="1"/> - <field name="product_id" required="1" attrs="{'readonly': ['&', ('state', '!=', 'draft'), ('additional', '=', False)]}"/> + <field name="product_id" required="1" attrs="{'readonly': ['|', '&', ('state', '!=', 'draft'), ('additional', '=', False), ('has_move_lines', '=', True)]}"/> <field name="is_initial_demand_editable" invisible="1"/> <field name="is_quantity_done_editable" invisible="1"/> <field name="product_uom_qty" string="Initial Demand" attrs="{'readonly': [('is_initial_demand_editable', '=', False)]}"/> diff --git a/addons/stock_account/__init__.py b/addons/stock_account/__init__.py index 17a52b35e20..e6ecd5c5e46 100644 --- a/addons/stock_account/__init__.py +++ b/addons/stock_account/__init__.py @@ -66,13 +66,14 @@ def _configure_journals(cr, registry): 'company_id': company_id.id, 'fields_id': field_id, 'value': value, - 'res_id': 'product.category,'+str(env.ref('product.product_category_all').id), } properties = env['ir.property'].search([ ('name', '=', record), ('company_id', '=', company_id.id), - ('value_reference', '!=', False)]) - if not properties: + ]) + if properties: + properties.write(vals) + else: # create the property env['ir.property'].create(vals) diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index b1e5f2b931d..a680bd4d8c1 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -200,6 +200,14 @@ class StockMove(models.Model): return True return False + def _is_dropshipped(self): + """ Check if the move should be considered as a dropshipping move so that the cost method + will be able to apply the correct logic. + + :return: True if the move is a dropshipping one else False + """ + return self.location_id.usage == 'supplier' and self.location_dest_id.usage == 'customer' + @api.model def _run_fifo(self, move, quantity=None): move.ensure_one() @@ -298,6 +306,21 @@ class StockMove(models.Model): 'value': value if quantity is None else self.value + value, 'price_unit': value / valued_quantity, }) + elif self._is_dropshipped(): + curr_rounding = self.company_id.currency_id.rounding + if self.product_id.cost_method in ['fifo']: + price_unit = self._get_price_unit() + # see test_dropship_fifo_perpetual_anglosaxon_ordered + self.product_id.standard_price = price_unit + else: + price_unit = self.product_id.standard_price + value = float_round(self.product_qty * price_unit, precision_rounding=curr_rounding) + # In move have a positive value, out move have a negative value, let's arbitrary say + # dropship are positive. + self.write({ + 'value': value, + 'price_unit': price_unit, + }) def _action_done(self): self.product_price_update_before_done() @@ -319,7 +342,7 @@ class StockMove(models.Model): if company_src and company_dst and company_src.id != company_dst.id: raise UserError(_("The move lines are not in a consistent states: they are doing an intercompany in a single step while they should go through the intercompany transit location.")) move._run_valuation() - for move in res.filtered(lambda m: m.product_id.valuation == 'real_time' and (m._is_in() or m._is_out())): + for move in res.filtered(lambda m: m.product_id.valuation == 'real_time' and (m._is_in() or m._is_out() or m._is_dropshipped())): move._account_entry_move() return res @@ -589,7 +612,7 @@ class StockMove(models.Model): else: self.with_context(force_company=company_from.id)._create_account_move_line(acc_valuation, acc_dest, journal_id) - if self.company_id.anglo_saxon_accounting and self.location_id.usage == 'supplier' and self.location_dest_id.usage == 'customer': + if self.company_id.anglo_saxon_accounting and self._is_dropshipped(): # Creates an account entry from stock_input to stock_output on a dropship move. https://github.com/odoo/odoo/issues/12687 journal_id, acc_src, acc_dest, acc_valuation = self._get_accounting_data_for_valuation() self.with_context(force_company=self.company_id.id)._create_account_move_line(acc_src, acc_dest, journal_id) diff --git a/addons/stock_dropshipping/tests/__init__.py b/addons/stock_dropshipping/tests/__init__.py index f4159ef6e7f..c060ad7247b 100644 --- a/addons/stock_dropshipping/tests/__init__.py +++ b/addons/stock_dropshipping/tests/__init__.py @@ -5,3 +5,5 @@ from . import test_crossdock from . import test_dropship from . import test_lifo_price from . import test_procurement_exception +from . import test_stockvaluation + diff --git a/addons/stock_dropshipping/tests/test_stockvaluation.py b/addons/stock_dropshipping/tests/test_stockvaluation.py new file mode 100644 index 00000000000..2eb1ced2756 --- /dev/null +++ b/addons/stock_dropshipping/tests/test_stockvaluation.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.account.tests.account_test_classes import AccountingTestCase + + +class TestStockValuation(AccountingTestCase): + def setUp(self): + super(TestStockValuation, self).setUp() + self.supplier_location = self.env.ref('stock.stock_location_suppliers') + self.stock_location = self.env.ref('stock.stock_location_stock') + self.partner_id = self.env.ref('base.res_partner_1') + self.product1 = self.env.ref('product.product_product_8') + + self.acc_payable = self.env['account.account'].search([('name', '=', 'Account Payable')]).id + self.acc_expense = self.env['account.account'].search([('name', '=', 'Expenses')]).id + self.acc_receivable = self.env['account.account'].search([('name', '=', 'Account Receivable')]).id + self.acc_sale = self.env['account.account'].search([('name', '=', 'Product Sales')]).id + self.acc_stock_in = self.env['account.account'].search([('name', '=', 'Stock Interim Account (Received)')]).id + self.acc_stock_out = self.env['account.account'].search([('name', '=', 'Stock Interim Account (Delivered)')]).id + + def _dropship_product1(self): + # enable the dropship and MTO route on the product + dropshipping_route = self.env.ref('stock_dropshipping.route_drop_shipping') + mto_route = self.env.ref('stock.route_warehouse0_mto') + self.product1.write({'route_ids': [(6, 0, [dropshipping_route.id, mto_route.id])]}) + + # add a vendor + vendor1 = self.env['res.partner'].create({'name': 'vendor1'}) + seller1 = self.env['product.supplierinfo'].create({ + 'name': vendor1.id, + 'price': 8, + }) + self.product1.write({'seller_ids': [(6, 0, [seller1.id])]}) + + # sell one unit of this product + customer1 = self.env['res.partner'].create({'name': 'customer1'}) + sale_order1 = self.env['sale.order'].create({ + 'partner_id': customer1.id, + 'partner_invoice_id': customer1.id, + 'partner_shipping_id': customer1.id, + 'order_line': [(0, 0, { + 'name': self.product1.name, + 'product_id': self.product1.id, + 'product_uom_qty': 1, + 'product_uom': self.product1.uom_id.id, + 'price_unit': 12, + })], + 'pricelist_id': self.env.ref('product.list0').id, + 'picking_policy': 'direct', + }) + sale_order1.action_confirm() + + # confirm the purchase order + purchase_order1 = self.env['purchase.order'].search([('group_id', '=', sale_order1.procurement_group_id.id)]) + purchase_order1.button_confirm() + + # validate the dropshipping picking + self.assertEqual(len(sale_order1.picking_ids), 1) + self.assertEqual(sale_order1.picking_ids.move_lines._is_dropshipped(), True) + wizard = sale_order1.picking_ids.button_validate() + immediate_transfer = self.env[wizard['res_model']].browse(wizard['res_id']) + immediate_transfer.process() + self.assertEqual(sale_order1.picking_ids.state, 'done') + + # create the vendor bill + vendor_bill1 = self.env['account.invoice'].create({ + 'partner_id': vendor1.id, + 'purchase_id': purchase_order1.id, + 'account_id': vendor1.property_account_payable_id.id, + 'type': 'in_invoice', + }) + vendor_bill1.purchase_order_change() + vendor_bill1.action_invoice_open() + + # create the customer invoice + customer_invoice1_id = sale_order1.action_invoice_create() + customer_invoice1 = self.env['account.invoice'].browse(customer_invoice1_id) + customer_invoice1.action_invoice_open() + + all_amls = vendor_bill1.move_id.line_ids + customer_invoice1.move_id.line_ids + if sale_order1.picking_ids.move_lines.account_move_ids: + all_amls |= sale_order1.picking_ids.move_lines.account_move_ids.line_ids + return all_amls + + def _check_results(self, expected_aml, expected_aml_count, all_amls): + # Construct a dict similar to `expected_aml` with `all_amls` in order to + # compare them. + result_aml = {} + for aml in all_amls: + account_id = aml.account_id.id + if result_aml.get(account_id): + debit = result_aml[account_id][0] + credit = result_aml[account_id][1] + result_aml[account_id] = (debit + aml.debit, credit + aml.credit) + else: + result_aml[account_id] = (aml.debit, aml.credit) + + self.assertEqual(len(all_amls), expected_aml_count) + + for k, v in expected_aml.items(): + self.assertEqual(result_aml[k], v) + + # ------------------------------------------------------------------------- + # Continental + # ------------------------------------------------------------------------- + def test_dropship_standard_perpetual_continental_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = False + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + def test_dropship_standard_perpetual_continental_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = False + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + def test_dropship_fifo_perpetual_continental_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = False + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + def test_dropship_fifo_perpetual_continental_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = False + + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + } + + self._check_results(expected_aml, 4, all_amls) + + # ------------------------------------------------------------------------- + # Anglosaxon + # ------------------------------------------------------------------------- + def test_dropship_standard_perpetual_anglosaxon_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (10.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 10.0), + self.acc_stock_out: (10.0, 10.0), + } + # Interim IN is not balanced because because there's a difference between the po line + # price unit and the standard price. We could set a price difference account on the + # category to compensate. + + self._check_results(expected_aml, 8, all_amls) + + def test_dropship_standard_perpetual_anglosaxon_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'standard' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (10.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 10.0), + self.acc_stock_out: (10.0, 10.0), + } + # Interim IN is not balanced because because there's a difference between the po line + # price unit and the standard price. We could set a price difference account on the + # category to compensate. + + self._check_results(expected_aml, 8, all_amls) + + def test_dropship_fifo_perpetual_anglosaxon_ordered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'order' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 8.0), + self.acc_stock_out: (8.0, 8.0), + } + + self._check_results(expected_aml, 8, all_amls) + + def test_dropship_fifo_perpetual_anglosaxon_delivered(self): + self.env.user.company_id.anglo_saxon_accounting = True + self.product1.product_tmpl_id.cost_method = 'fifo' + self.product1.product_tmpl_id.standard_price = 10 + self.product1.product_tmpl_id.valuation = 'real_time' + self.product1.product_tmpl_id.invoice_policy = 'delivery' + + all_amls = self._dropship_product1() + + expected_aml = { + self.acc_payable: (0.0, 8.0), + self.acc_expense: (8.0, 0.0), + self.acc_receivable: (12.0, 0.0), + self.acc_sale: (0.0, 12.0), + self.acc_stock_in: (8.0, 8.0), + self.acc_stock_out: (8.0, 8.0), + } + + self._check_results(expected_aml, 8, all_amls) + diff --git a/addons/stock_landed_costs/views/stock_landed_cost_views.xml b/addons/stock_landed_costs/views/stock_landed_cost_views.xml index 6ec34e5db11..c2b5b580658 100644 --- a/addons/stock_landed_costs/views/stock_landed_cost_views.xml +++ b/addons/stock_landed_costs/views/stock_landed_cost_views.xml @@ -22,7 +22,7 @@ <group> <group> <field name="date"/> - <field name="picking_ids" widget="many2many_tags" options="{'no_create_edit': True}" domain="[('state', '=', 'done')]"/> + <field name="picking_ids" widget="many2many_tags" options="{'no_create_edit': True}" domain="[('state', '=', 'done'), ('picking_type_code', '!=', 'internal')]"/> </group> <group> <field name="account_journal_id"/> diff --git a/addons/survey/models/survey.py b/addons/survey/models/survey.py index 78ea2b17810..60c387fd395 100644 --- a/addons/survey/models/survey.py +++ b/addons/survey/models/survey.py @@ -680,6 +680,7 @@ class SurveyLabel(models.Model): value = fields.Char('Suggested value', translate=True, required=True) quizz_mark = fields.Float('Score for this choice', help="A positive score indicates a correct choice; a negative or null score indicates a wrong answer") + @api.one @api.constrains('question_id', 'question_id_2') def _check_question_not_empty(self): """Ensure that field question_id XOR field question_id_2 is not null""" diff --git a/addons/web/static/lib/qweb/qweb2.js b/addons/web/static/lib/qweb/qweb2.js index c4bb7128e9b..94a0afaa32a 100644 --- a/addons/web/static/lib/qweb/qweb2.js +++ b/addons/web/static/lib/qweb/qweb2.js @@ -358,7 +358,12 @@ QWeb2.Engine = (function() { req.open('GET', s, async); if (async) { req.addEventListener("load", function() { - if (req.status == 200) { + // 0, not being a valid HTTP status code, is used by browsers + // to indicate success for a non-http xhr response + // (for example, using the file:// protocol) + // https://developer.mozilla.org/fr/docs/Web/API/XMLHttpRequest + // https://bugzilla.mozilla.org/show_bug.cgi?id=331610 + if (req.status == 200 || req.status == 0) { callback(null, self._parse_from_request(req)); } else { callback(new Error("Can't load template " + s + ", http status " + req.status)); diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js index 731183e7efe..5f31c7103c7 100644 --- a/addons/web/static/src/js/views/pivot/pivot_controller.js +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -126,6 +126,7 @@ var PivotController = AbstractController.extend({ var table = this.model.exportData(); if(table.measure_row.length + 1 > 256) { crash_manager.show_message(_t("For Excel compatibility, data cannot be exported if there are more than 256 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures.")); + framework.unblockUI(); return; } framework.blockUI(); diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 99a5e4ee334..1c76c41ab71 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -135,7 +135,7 @@ var PivotModel = AbstractModel.extend({ exportData: function () { var measureNbr = this.data.measures.length; var headers = this._computeHeaders(); - var measureRow = measureNbr > 1 ? _.last(headers) : []; + var measureRow = measureNbr >= 1 ? _.last(headers) : []; var rows = this._computeRows(); var i, j, value; headers[0].splice(0,1); diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js index 4b9f6a29a47..dd0096f269b 100644 --- a/addons/web/static/src/js/widgets/data_export.js +++ b/addons/web/static/src/js/widgets/data_export.js @@ -455,7 +455,7 @@ var DataExport = Dialog.extend({ Dialog.alert(this, _t("Please select fields to export...")); return; } - exported_fields.unshift({name: 'id', label: 'External ID'}); + exported_fields.unshift({name: 'id', label: _t('External ID')}); var export_format = this.$export_format_inputs.filter(':checked').val(); diff --git a/addons/web/static/src/less/form_view.less b/addons/web/static/src/less/form_view.less index 505f62ac42f..60a3671caa2 100644 --- a/addons/web/static/src/less/form_view.less +++ b/addons/web/static/src/less/form_view.less @@ -165,7 +165,9 @@ .o-flex-flow(row, wrap); > .btn { - margin-right: 3px; + @o-statusbar-buttons-vmargin: 4px; + min-height: @odoo-statusbar-height - 2 * @o-statusbar-buttons-vmargin; + margin: @o-statusbar-buttons-vmargin 3px @o-statusbar-buttons-vmargin 0; padding-top: 2px; padding-bottom: 2px; } diff --git a/addons/website_form/controllers/main.py b/addons/website_form/controllers/main.py index f4fd9716aa2..b37ca97ea79 100644 --- a/addons/website_form/controllers/main.py +++ b/addons/website_form/controllers/main.py @@ -171,7 +171,8 @@ class WebsiteForm(http.Controller): return data def insert_record(self, request, model, values, custom, meta=None): - record = request.env[model.model].sudo().with_context(mail_create_nosubscribe=True).create(values) + model_name = model.sudo().model + record = request.env[model_name].sudo().with_context(mail_create_nosubscribe=True).create(values) if custom or meta: default_field = model.website_form_default_field_id @@ -183,13 +184,13 @@ class WebsiteForm(http.Controller): # If there is a default field configured for this model, use it. # If there isn't, put the custom data in a message instead if default_field.name: - if default_field.ttype == 'html' or model.model == 'mail.mail': + if default_field.ttype == 'html' or model_name == 'mail.mail': custom_content = nl2br(custom_content) record.update({default_field.name: custom_content}) else: values = { 'body': nl2br(custom_content), - 'model': model.model, + 'model': model_name, 'message_type': 'comment', 'no_auto_thread': False, 'res_id': record.id, @@ -201,7 +202,8 @@ class WebsiteForm(http.Controller): # Link all files attached on the form def insert_attachment(self, model, id_record, files): orphan_attachment_ids = [] - record = model.env[model.model].browse(id_record) + model_name = model.sudo().model + record = model.env[model_name].browse(id_record) authorized_fields = model.sudo()._get_form_writable_fields() for file in files: custom_field = file.field_name not in authorized_fields @@ -209,7 +211,7 @@ class WebsiteForm(http.Controller): 'name': file.filename, 'datas': base64.encodestring(file.read()), 'datas_fname': file.filename, - 'res_model': model.model, + 'res_model': model_name, 'res_id': record.id, } attachment_id = request.env['ir.attachment'].sudo().create(attachment_value) @@ -221,10 +223,10 @@ class WebsiteForm(http.Controller): # If some attachments didn't match a field on the model, # we create a mail.message to link them to the record if orphan_attachment_ids: - if model.model != 'mail.mail': + if model_name != 'mail.mail': values = { 'body': _('<p>Attached files : </p>'), - 'model': model.model, + 'model': model_name, 'message_type': 'comment', 'no_auto_thread': False, 'res_id': id_record, diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index d3a44363fab..3cf961ada09 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -151,7 +151,7 @@ class WebsiteSale(http.Controller): else: price = variant.website_public_price / quantity visible_attribute_ids = [v.id for v in variant.attribute_value_ids if v.attribute_id.id in visible_attrs_ids] - attribute_value_ids.append([variant.id, visible_attribute_ids, variant.website_price, price]) + attribute_value_ids.append([variant.id, visible_attribute_ids, variant.website_price / quantity, price]) return attribute_value_ids def _get_search_order(self, post): diff --git a/addons/website_sale/data/demo.xml b/addons/website_sale/data/demo.xml index e7a9dfafb94..7d6b4ee7524 100644 --- a/addons/website_sale/data/demo.xml +++ b/addons/website_sale/data/demo.xml @@ -548,6 +548,16 @@ Weight: 31 grams</field> <field name="compute_price">formula</field> </record> + <record id="item2" model="product.pricelist.item"> + <field name="base">list_price</field> + <field name="applied_on">1_product</field> + <field name="pricelist_id" ref="product.list0"/> + <field name="product_tmpl_id" ref="product.product_product_4_product_template"/> + <field name="price_discount">40</field> + <field name="min_quantity">2</field> + <field name="compute_price">formula</field> + </record> + <!-- product.public.category --> <record id="Components" model="product.public.category"> diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index 874b9f698b5..b1fe1d710ce 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -88,18 +88,34 @@ odoo.define('website_sale.website_sale', function (require) { $(oe_website_sale).on("change", 'input[name="add_qty"]', function (event) { var product_ids = []; - var product_dom = $(".js_product .js_add_cart_variants[data-attribute_value_ids]"); + var product_dom = $(event.target).closest(".js_product").find("ul.js_add_cart_variants"); var qty = $(event.target).closest('form').find('input[name="add_qty"]').val(); if (!product_dom.length) { + // if variants in list view, update variant price based on quantity + var $list_products = $(event.target).closest('.js_product').find('.js_product_change'); + if ($list_products.length) { + ajax.jsonRpc("/shop/get_unit_price", 'call', { + 'product_ids': _.map($list_products, function (variant) { + return parseInt(variant.value); + }), + 'add_qty': parseInt(qty) + }).then(function (data) { + _.each(data, function (value, key) { + $list_products.filter('[value="'+key+'"]').data('price', value); + }); + $list_products.filter(':checked').change(); + }); + } return; } var attribute_value_ids = product_dom.data("attribute_value_ids"); if(_.isString(attribute_value_ids)) { attribute_value_ids = JSON.parse(attribute_value_ids.replace(/'/g, '"')); + // set permanent reference so modifications are kept + product_dom.data('attribute_value_ids', attribute_value_ids); } _.each(attribute_value_ids, function(entry) { product_ids.push(entry[0]);}); - var qty = $(event.target).closest('form').find('input[name="add_qty"]').val(); if ($("#product_detail").length) { // display the reduction from the pricelist in function of the quantity @@ -109,7 +125,7 @@ odoo.define('website_sale.website_sale', function (require) { for(var j=0; j < current.length; j++){ current[j][2] = data[current[j][0]]; } - product_dom.attr("data-attribute_value_ids", JSON.stringify(current)).trigger("change"); + product_dom.trigger("change"); }); } }); @@ -210,8 +226,7 @@ odoo.define('website_sale.website_sale', function (require) { $('input[name="'+$input.attr("name")+'"]').add($input).filter(function () { var $prod = $(this).closest('*:has(input[name="product_id"])'); return !$prod.length || +$prod.find('input[name="product_id"]').val() === product_id; - }).val(new_qty); - $input.change(); + }).val(new_qty).change(); return false; }); @@ -380,7 +395,7 @@ odoo.define('website_sale.website_sale', function (require) { }); $('div.js_product', oe_website_sale).each(function () { - $('input.js_product_change', this).first().trigger('change'); + $('input.js_product_change', this).first().prop('checked', 'checked').trigger('change'); }); $('.js_add_cart_variants', oe_website_sale).each(function () { diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 3595a5fd219..662d2212838 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -458,8 +458,7 @@ <div class="col-sm-5 col-lg-4 col-lg-offset-1" id="product_details"> <h1 itemprop="name" t-field="product.name">Product Name</h1> <span itemprop="url" style="display:none;" t-esc="'%sshop/product/%s' % (request.httprequest.url_root, slug(product))"/> - <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product)"/> - <form action="/shop/cart/update" class="js_add_cart_variants" t-att-data-attribute_value_ids="attribute_value_ids" method="POST"> + <form action="/shop/cart/update" class="js_add_cart_variants" method="POST"> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" /> <div class="js_product" t-if="product.product_variant_ids"> <t t-placeholder="select"> @@ -603,20 +602,24 @@ <input type="hidden" t-if="len(product.product_variant_ids) == 1" class="product_id" name="product_id" t-att-value="product.product_variant_id.id"/> <t t-if="len(product.product_variant_ids) > 1"> <label label-default="label-default" class="radio" t-foreach="product.product_variant_ids" t-as="variant_id"> - <input type="radio" name="product_id" class="js_product_change" t-att-checked="'checked' if variant_id_index == 0 else ''" t-att-value="variant_id.id" t-att-data-lst_price="compute_currency(variant_id.lst_price)" t-att-data-price="variant_id.price"/> + <input type="radio" name="product_id" class="js_product_change" t-att-checked="'checked' if variant_id_index == 0 else None" t-att-value="variant_id.id" t-att-data-lst_price="compute_currency(variant_id.lst_price)" t-att-data-price="variant_id.price"/> <span t-esc="variant_id.name_get()[0][1]"/> <t t-set="diff_price" t-value="variant_id.price - variant_id.product_tmpl_id.price"/> <span class="badge" t-if="diff_price != 0"> - <t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(diff_price)" t-esc-options='{"widget": "monetary", "display_currency": "website.currency_id"}'/> + <t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(diff_price)" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/> </span> </label> </t> + <t t-else=""> + <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product)"/> + <ul class="hidden js_add_cart_variants" t-att-data-attribute_value_ids="json.dumps(attribute_value_ids)"/> + </t> </xpath> </template> <template id="variants"> <t t-set="attribute_value_ids" t-value="get_attribute_value_ids(product)"/> - <ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_value_ids="attribute_value_ids"> + <ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_value_ids="json.dumps(attribute_value_ids)"> <t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id"> <li t-if="len(variant_id.value_ids) > 1"> diff --git a/addons/website_sale_delivery/models/res_country.py b/addons/website_sale_delivery/models/res_country.py index 8b4648a7105..601ac731787 100644 --- a/addons/website_sale_delivery/models/res_country.py +++ b/addons/website_sale_delivery/models/res_country.py @@ -35,5 +35,7 @@ class ResCountry(models.Model): states = res break states |= carrier.state_ids + if not states: + states = states.search([('country_id', '=', self.id)]) res = res & states return res diff --git a/addons/website_sale_options/static/src/js/website_sale.test.js b/addons/website_sale_options/static/src/js/website_sale.test.js index 2f60f2f0902..b94f4493a2a 100644 --- a/addons/website_sale_options/static/src/js/website_sale.test.js +++ b/addons/website_sale_options/static/src/js/website_sale.test.js @@ -46,10 +46,103 @@ tour.register('shop_customize', { trigger: '.oe_product_cart a:contains("iPad")', }, { + content: "open customize menu", + trigger: '#customize-menu > a', + extra_trigger: "#product_detail", + }, + { + content: "click on 'List View of Variants'", + trigger: "#customize-menu a:contains(List View of Variants)", + }, + { + content: "check page loaded after list of variant customization enabled", + trigger: '.js_product_change', + run: function () {}, // it's a check + }, + { + content: "check price is 600 and set quantity to 2", + trigger: ".js_product:first input.quantity:propValue(1)", + extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)", + run: "text 2", + }, + { + content: "verify pricelist based on quantity has effect", + trigger: ".product_price .oe_price .oe_currency_value:containsExact(450.00)", + run: function () {}, // it's a check + }, + { + content: "check pricelit has been applied and switch to 32 GB variant", + trigger: ".js_product label:contains('32 GB')", + }, + { + content: "verify that price has changed when changing variant", + trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(450.00))", + run: function () {}, // it's a check + }, + { + content: "open customize menu", + trigger: '#customize-menu > a', + }, + { + content: "remove 'List View of Variants'", + trigger: "#customize-menu a:contains(List View of Variants):has(input:checked)", + }, + { + content: "check page loaded after list of variant customization disabled", + trigger: ".js_product:not(:has(.js_product_change))", + run: function () {}, // it's a check + }, + { + content: "check price is 600 and set quantity to 2", + trigger: ".js_product:first input.quantity:propValue(1)", + extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)", + run: "text 2", + }, + { + content: "verify pricelist based on quantity has effect", + trigger: ".product_price .oe_price .oe_currency_value:containsExact(450.00)", + run: function () {}, // it's a check + }, + { + content: "switch to 32 GB variant", + trigger: ".js_product label:contains('32 GB')", + }, + { + content: "verify that price has changed when changing variant", + trigger: ".product_price .oe_price .oe_currency_value:not(:containsExact(450.00))", + run: function () {}, // it's a check + }, + { + content: "switch back to 16 GB variant", + trigger: ".js_product label:contains('16 GB')", + }, + { content: "click on 'Add to Cart' button", trigger: "a:contains(Add to Cart)", }, { + content: "price is lowered by pricelist and not multiplied by quantity", + trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(450.00)", + extra_trigger: "#product_confirmation input.quantity:propValue(2)", + run: function () {}, // it's a check + }, + { + content: "set quantity to 1", + trigger: "#product_confirmation .js_add_cart_json .fa-minus", + }, + { + content: "check that product page has been updated", + trigger: ".js_product:first input.quantity:propValue(1)", + extra_trigger: ".product_price .oe_price .oe_currency_value:containsExact(600.00)", + run: function () {}, // it's a check + }, + { + content: "check that add to cart modal has been updated", + trigger: "#product_confirmation .oe_price .oe_currency_value:containsExact(600.00)", + extra_trigger: "#product_confirmation input.quantity:propValue(1)", + run: function () {}, // it's a check + }, + { content: "add an optional Warranty", trigger: ".js_product:contains(Warranty) a:contains(Add to Cart)", }, diff --git a/addons/website_sale_options/views/website_sale_options_templates.xml b/addons/website_sale_options/views/website_sale_options_templates.xml index fdbd0efc349..6fb938231f1 100644 --- a/addons/website_sale_options/views/website_sale_options_templates.xml +++ b/addons/website_sale_options/views/website_sale_options_templates.xml @@ -67,7 +67,7 @@ </div> </td> <td class="text-center td-price" name="price"> - <ul class="hidden js_add_cart_variants" t-att-data-attribute_value_ids="main_product_attr_ids"></ul> + <ul class="hidden js_add_cart_variants" t-att-data-attribute_value_ids="json.dumps(main_product_attr_ids)"></ul> <div class="hidden oe_unchanged_value_ids" t-att-data-unchanged_value_ids="main_product_attr_ids[0][1]" ></div> <span t-attf-class="text-danger oe_default_price oe_striked_price {{ (compute_currency(product.lst_price) - product.website_price) < 0.01 and 'hidden' }}" t-field="product.lst_price" diff --git a/addons/website_sale_stock/static/src/js/website_sale_stock.js b/addons/website_sale_stock/static/src/js/website_sale_stock.js index 2a3a7f8cc6f..40b4490bdbd 100644 --- a/addons/website_sale_stock/static/src/js/website_sale_stock.js +++ b/addons/website_sale_stock/static/src/js/website_sale_stock.js @@ -39,7 +39,10 @@ $('.oe_website_sale').each(function() { $(oe_website_sale).on('change', 'ul[data-attribute_value_ids]', function(event) { var $ul = $(event.target).closest('.js_add_cart_variants'); var $parent = $ul.closest('.js_product'); - var variant_ids = JSON.parse($ul.data("attribute_value_ids").replace(/'/g, '"')); + var variant_ids = $ul.data("attribute_value_ids"); + if(_.isString(variant_ids)) { + variant_ids = JSON.parse(variant_ids.replace(/'/g, '"')); + } var values = []; $parent.find('input.js_variant_change:checked, select.js_variant_change').each(function() { values.push(+$(this).val()); @@ -47,7 +50,8 @@ $('.oe_website_sale').each(function() { var qty = $parent.find('input[name="add_qty"]').val(); for (var k in variant_ids) { if (_.isEmpty(_.difference(variant_ids[k][1], values))) { - var info = variant_ids[k][4]; + // clone so permanent object is not modified + var info = _.clone(variant_ids[k][4]); if(_.contains(['always', 'threshold'], info['inventory_availability'])) { info['virtual_available'] -= parseInt(info['cart_qty']); if (info['virtual_available'] < 0) { diff --git a/addons/website_slides/models/slides.py b/addons/website_slides/models/slides.py index b9d4aa2d757..764d8acfa6a 100644 --- a/addons/website_slides/models/slides.py +++ b/addons/website_slides/models/slides.py @@ -348,7 +348,7 @@ class Slide(models.Model): base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for record in self: if record.datas and (not record.document_id or record.slide_type in ['document', 'presentation']): - record.embed_code = '<iframe src="%s/slides/embed/%s?page=1" allowFullScreen="true" height="%s" width="%s" frameborder="0"></iframe>' % (base_url, record.id, 315, 420) + record.embed_code = '<iframe src="%s/slides/embed/%s?page=1" class="o_wslides_iframe_viewer" allowFullScreen="true" height="%s" width="%s" frameborder="0"></iframe>' % (base_url, record.id, 315, 420) elif record.slide_type == 'video' and record.document_id: if not record.mime_type: # embed youtube video diff --git a/addons/website_slides/static/src/js/slides.js b/addons/website_slides/static/src/js/slides.js index 7d945e9eb28..b331ebf366c 100644 --- a/addons/website_slides/static/src/js/slides.js +++ b/addons/website_slides/static/src/js/slides.js @@ -109,11 +109,12 @@ var page_widgets = {}; }, }); - $('iframe').ready(function() { + $('iframe.o_wslides_iframe_viewer').ready(function() { // TODO : make it work. For now, once the iframe is loaded, the value of #page_count is // still now set (the pdf is still loading) - var max_page = $('iframe').contents().find('#page_count').val(); - new SlideSocialEmbed($(this), max_page).setElement($('.oe_slide_js_embed_code_widget')); + var $iframe = $(this); + var max_page = $iframe.contents().find('#page_count').val(); + new SlideSocialEmbed($iframe, max_page).setElement($('.oe_slide_js_embed_code_widget')); }); |
