diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 451a02e1bd..fd8018a077 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -1107,7 +1107,12 @@ class AccountMove(models.Model): else: tax_type = line.tax_ids[0].type_tax_use is_refund = (tax_type == 'sale' and line.debit) or (tax_type == 'purchase' and line.credit) - taxes = line.tax_ids.flatten_taxes_hierarchy() + taxes = line.tax_ids._origin.flatten_taxes_hierarchy().filtered( + lambda tax: ( + tax.amount_type == 'fixed' and not invoice.company_id.currency_id.is_zero(tax.amount) + or not float_is_zero(tax.amount, precision_digits=4) + ) + ) if is_refund: tax_rep_lines = taxes.refund_repartition_line_ids._origin.filtered(lambda x: x.repartition_type == "tax") else: diff --git a/addons/account/tests/test_invoice_taxes.py b/addons/account/tests/test_invoice_taxes.py index eeac01b81b..817cbf1b0e 100644 --- a/addons/account/tests/test_invoice_taxes.py +++ b/addons/account/tests/test_invoice_taxes.py @@ -711,3 +711,113 @@ class TestInvoiceTaxes(AccountTestInvoicingCommon): self.assertRecordValues(invoice.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable'), [{ 'balance': 686.54 }]) + + def test_change_tax_line_account_when_tax_zero_percent(self): + """ + This test checks the following flow: + - Invoice with three invoice lines: + • One with tax > 0% + • One with tax == 0% + • One with tax == 0 fixed + - On line_ids, change the account of the tax line + The tax line should still be there and the account should be effectively changed + """ + tax_0_percent, tax_0_fixed = self.env['account.tax'].create([ + { + 'name': '0%', + 'amount_type': 'percent', + 'amount': 0, + 'sequence': 10, + }, + { + 'name': '0 fixed', + 'amount_type': 'fixed', + 'amount': 0, + 'sequence': 10, + } + ]) + + + new_account_revenue = self.company_data['default_account_revenue'].copy() + + invoice = self._create_invoice([(500, self.percent_tax_1), (300, tax_0_percent), (100, tax_0_fixed)], inv_type='out_invoice') + + self.assertRecordValues(invoice.line_ids, [ + { + 'account_id': self.company_data['default_account_revenue'].id, + 'name': 'xxxx', + 'tax_ids': self.percent_tax_1.ids, + 'debit': 0.0, + 'credit': 500.0, + }, + { + 'account_id': self.company_data['default_account_revenue'].id, + 'name': 'xxxx', + 'tax_ids': tax_0_percent.ids, + 'debit': 0.0, + 'credit': 300.0, + }, + { + 'account_id': self.company_data['default_account_revenue'].id, + 'name': 'xxxx', + 'tax_ids': tax_0_fixed.ids, + 'debit': 0.0, + 'credit': 100.0, + }, + { + 'account_id': self.company_data['default_account_revenue'].id, + 'name': '21%', + 'tax_ids': [], + 'debit': 0.0, + 'credit': 105.0, + }, + { + 'account_id': self.company_data['default_account_receivable'].id, + 'name': '', + 'tax_ids': [], + 'debit': 1005.0, + 'credit': 0.0, + } + ]) + + with Form(invoice) as invoice_form: + with invoice_form.line_ids.edit(3) as line: + line.account_id = new_account_revenue + + self.assertRecordValues(invoice.line_ids, [ + { + 'account_id': self.company_data['default_account_revenue'].id, + 'name': 'xxxx', + 'tax_ids': self.percent_tax_1.ids, + 'debit': 0.0, + 'credit': 500.0, + }, + { + 'account_id': self.company_data['default_account_revenue'].id, + 'name': 'xxxx', + 'tax_ids': tax_0_percent.ids, + 'debit': 0.0, + 'credit': 300.0, + }, + { + 'account_id': self.company_data['default_account_revenue'].id, + 'name': 'xxxx', + 'tax_ids': tax_0_fixed.ids, + 'debit': 0.0, + 'credit': 100.0, + }, + { + 'account_id': new_account_revenue.id, + 'name': '21%', + 'tax_ids': [], + 'debit': 0.0, + 'credit': 105.0, + }, + { + 'account_id': self.company_data['default_account_receivable'].id, + 'name': '', + 'tax_ids': [], + 'debit': 1005.0, + 'credit': 0.0, + } + ]) diff --git a/addons/account_edi_facturx/data/facturx_templates.xml b/addons/account_edi_facturx/data/facturx_templates.xml index ba0ba3ab12..3b994496ad 100644 --- a/addons/account_edi_facturx/data/facturx_templates.xml +++ b/addons/account_edi_facturx/data/facturx_templates.xml @@ -87,6 +87,16 @@ + + + + + + + + + + @@ -111,7 +121,7 @@ --> - urn:cen.eu:en16931:2017 + urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended diff --git a/addons/account_edi_facturx/tests/test_facturx.py b/addons/account_edi_facturx/tests/test_facturx.py index bfc0b80e1e..b41b5bc510 100644 --- a/addons/account_edi_facturx/tests/test_facturx.py +++ b/addons/account_edi_facturx/tests/test_facturx.py @@ -68,7 +68,7 @@ class TestAccountEdiFacturx(AccountEdiTestCommon): - urn:cen.eu:en16931:2017 + urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended @@ -117,10 +117,16 @@ class TestAccountEdiFacturx(AccountEdiTestCommon): company_1_data + + company_1_data + partner_b + + partner_b + @@ -130,6 +136,9 @@ class TestAccountEdiFacturx(AccountEdiTestCommon): partner_b + + partner_b + diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index 2f6d2be20f..e8e4a98119 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. +from collections import defaultdict from lxml import etree import re @@ -17,7 +18,7 @@ class AccountAnalyticLine(models.Model): if 'encoding_uom_id' in field_list: result['encoding_uom_id'] = self.env.company.timesheet_encode_uom_id.id if not self.env.context.get('default_employee_id') and 'employee_id' in field_list and result.get('user_id'): - result['employee_id'] = self.env['hr.employee'].search([('user_id', '=', result['user_id'])], limit=1).id + result['employee_id'] = self.env['hr.employee'].search([('user_id', '=', result['user_id']), ('company_id', '=', result.get('company_id', self.env.company.id))], limit=1).id return result def _domain_project_id(self): @@ -45,7 +46,7 @@ class AccountAnalyticLine(models.Model): 'project.project', 'Project', compute='_compute_project_id', store=True, readonly=False, domain=_domain_project_id) user_id = fields.Many2one(compute='_compute_user_id', store=True, readonly=False) - employee_id = fields.Many2one('hr.employee', "Employee", domain=_domain_employee_id) + employee_id = fields.Many2one('hr.employee', "Employee", domain=_domain_employee_id, context={'active_test': False}) department_id = fields.Many2one('hr.department', "Department", compute='_compute_department_id', store=True, compute_sudo=True) encoding_uom_id = fields.Many2one('uom.uom', compute='_compute_encoding_uom_id') partner_id = fields.Many2one(compute='_compute_partner_id', store=True, readonly=False) @@ -92,18 +93,33 @@ class AccountAnalyticLine(models.Model): def create(self, vals_list): default_user_id = self._default_user() user_ids = list(map(lambda x: x.get('user_id', default_user_id), filter(lambda x: not x.get('employee_id') and x.get('project_id'), vals_list))) - employees = self.env['hr.employee'].search([('user_id', 'in', user_ids)]) - user_map = {employee.user_id.id: employee.id for employee in employees} for vals in vals_list: # when the name is not provide by the 'Add a line', we set a default one if vals.get('project_id') and not vals.get('name'): vals['name'] = '/' - # compute employee only for timesheet lines, makes no sense for other lines - if not vals.get('employee_id') and vals.get('project_id'): - vals['employee_id'] = user_map.get(vals.get('user_id') or default_user_id) vals.update(self._timesheet_preprocess(vals)) + # Although this make a second loop on the vals, we need to wait the preprocess as it could change the company_id in the vals + # TODO To be refactored in master + employees = self.env['hr.employee'].sudo().search([('user_id', 'in', user_ids)]) + employee_for_user_company = defaultdict(dict) + for employee in employees: + employee_for_user_company[employee.user_id.id][employee.company_id.id] = employee.id + + employee_ids = set() + for vals in vals_list: + # compute employee only for timesheet lines, makes no sense for other lines + if not vals.get('employee_id') and vals.get('project_id'): + employee_for_company = employee_for_user_company.get(vals.get('user_id', default_user_id), False) + if not employee_for_company: + continue + company_id = list(employee_for_company)[0] if len(employee_for_company) == 1 else vals.get('company_id', self.env.company.id) + vals['employee_id'] = employee_for_company.get(company_id, False) + elif vals.get('employee_id'): + employee_ids.add(vals['employee_id']) + if any(not emp.active for emp in self.env['hr.employee'].browse(list(employee_ids))): + raise UserError(_('Timesheets must be created with an active employee.')) lines = super(AccountAnalyticLine, self).create(vals_list) for line, values in zip(lines, vals_list): if line.project_id: # applied only for timesheet @@ -116,6 +132,10 @@ class AccountAnalyticLine(models.Model): raise AccessError(_("You cannot access timesheets that are not yours.")) values = self._timesheet_preprocess(values) + if values.get('employee_id'): + employee = self.env['hr.employee'].browse(values['employee_id']) + if not employee.active: + raise UserError(_('You cannot set an archived employee to the existing timesheets.')) if 'name' in values and not values.get('name'): values['name'] = '/' result = super(AccountAnalyticLine, self).write(values) diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index 77789e2a39..2a105ae194 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -128,8 +128,14 @@ class Task(models.Model): @api.depends('timesheet_ids.unit_amount') def _compute_effective_hours(self): + if not any(self._ids): + for task in self: + task.effective_hours = round(sum(task.timesheet_ids.mapped('unit_amount')), 2) + return + timesheet_read_group = self.env['account.analytic.line'].read_group([('task_id', 'in', self.ids)], ['unit_amount', 'task_id'], ['task_id']) + timesheets_per_task = {res['task_id'][0]: res['unit_amount'] for res in timesheet_read_group} for task in self: - task.effective_hours = round(sum(task.timesheet_ids.mapped('unit_amount')), 2) + task.effective_hours = round(timesheets_per_task.get(task.id, 0.0), 2) @api.depends('effective_hours', 'subtask_effective_hours', 'planned_hours') def _compute_progress_hours(self): diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py index 1ce30afee8..319c6a0e04 100644 --- a/addons/hr_timesheet/tests/test_timesheet.py +++ b/addons/hr_timesheet/tests/test_timesheet.py @@ -393,3 +393,68 @@ class TestTimesheet(TestCommonTimesheet): timesheet1.unit_amount + timesheet2.unit_amount, 'The total timesheet time of this project should be equal to 4.' ) + + def test_create_timesheet_employee_not_in_company(self): + ''' ts.employee_id only if the user has an employee in the company or one employee for all companies. + ''' + company_2 = self.env['res.company'].create({'name': 'Company 2'}) + company_3 = self.env['res.company'].create({'name': 'Company 3'}) + + analytic_account = self.env['account.analytic.account'].create({ + 'name': 'Aa Aa', + 'company_id': company_3.id, + }) + project = self.env['project.project'].create({ + 'name': 'Aa Project', + 'company_id': company_3.id, + 'analytic_account_id': analytic_account.id, + }) + task = self.env['project.task'].create({ + 'name': 'Aa Task', + 'project_id': project.id, + }) + + Timesheet = self.env['account.analytic.line'].with_context(allowed_company_ids=[company_3.id, company_2.id, self.env.company.id]) + timesheet = Timesheet.create({ + 'name': 'Timesheet', + 'project_id': project.id, + 'task_id': task.id, + 'unit_amount': 2, + 'user_id': self.user_manager.id, + 'company_id': company_3.id, + }) + self.assertEqual(timesheet.employee_id, self.user_manager.employee_id, 'As there is a unique employee for this user, it must be found') + + self.env['hr.employee'].with_company(company_2).create({ + 'name': 'Employee 2', + 'user_id': self.user_manager.id, + }) + timesheet = Timesheet.create({ + 'name': 'Timesheet', + 'project_id': project.id, + 'task_id': task.id, + 'unit_amount': 2, + 'user_id': self.user_manager.id, + 'company_id': company_3.id, + }) + self.assertFalse(timesheet.employee_id, 'As there are several employees for this user, but none of them in this company, none must be found') + + def test_create_timesheet_with_archived_employee(self): + ''' the timesheet can be created or edited only with an active employee + ''' + self.empl_employee2.active = False + batch_vals = { + 'project_id': self.project_customer.id, + 'task_id': self.task1.id, + 'name': 'archived employee timesheet', + 'unit_amount': 3, + 'employee_id': self.empl_employee2.id + } + + self.assertRaises(UserError, self.env['account.analytic.line'].create, batch_vals) + + batch_vals["employee_id"] = self.empl_employee.id + timesheet = self.env['account.analytic.line'].create(batch_vals) + + with self.assertRaises(UserError): + timesheet.employee_id = self.empl_employee2 diff --git a/addons/hr_timesheet/views/hr_timesheet_views.xml b/addons/hr_timesheet/views/hr_timesheet_views.xml index c12d1039d7..d2f2192274 100644 --- a/addons/hr_timesheet/views/hr_timesheet_views.xml +++ b/addons/hr_timesheet/views/hr_timesheet_views.xml @@ -45,6 +45,7 @@ 0 1 many2one_avatar_employee + {'active_test': True} @@ -110,7 +111,7 @@ 10 - + diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index 8cec4d7613..d2ec42b7b0 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -107,7 +107,7 @@ - + @@ -117,7 +117,7 @@ - + @@ -152,7 +152,7 @@ - + diff --git a/addons/iap/models/iap_account.py b/addons/iap/models/iap_account.py index 8656b0ac97..95a5fce408 100644 --- a/addons/iap/models/iap_account.py +++ b/addons/iap/models/iap_account.py @@ -31,6 +31,17 @@ class IapAccount(models.Model): ('company_ids', '=', False) ] accounts = self.search(domain, order='id desc') + accounts_without_token = accounts.filtered(lambda acc: not acc.account_token) + if accounts_without_token: + with self.pool.cursor() as cr: + # In case of a further error that will rollback the database, we should + # use a different SQL cursor to avoid undo the accounts deletion. + + # Flush the pending operations to avoid a deadlock. + self.flush() + IapAccount = self.with_env(self.env(cr=cr)) + IapAccount.search(domain + [('account_token', '=', False)]).unlink() + accounts = accounts - accounts_without_token if not accounts: with self.pool.cursor() as cr: # Since the account did not exist yet, we will encounter a NoCreditError, diff --git a/addons/l10n_ar_website_sale/static/tests/tours/website_sale_buy.js b/addons/l10n_ar_website_sale/static/tests/tours/website_sale_buy.js new file mode 100644 index 0000000000..bd212326ff --- /dev/null +++ b/addons/l10n_ar_website_sale/static/tests/tours/website_sale_buy.js @@ -0,0 +1,47 @@ +flectra.define('l10n_ar_website_sale.shop_buy_product', function (require) { +"use strict"; + + let tour = require('web_tour.tour'); + + var Session = require('web.session'); + + let session = Session; + + console.log(session); + console.log(session.user_context); + console.log(session.user_context.allowed_company_ids); + + let shop_buy_product = tour.tours.shop_buy_product; + // Remove the step related to other localisations + shop_buy_product.steps = _.filter(shop_buy_product.steps, step => !step.l10n || step.l10n === "ar"); + + let checkout_step_idx = _.findIndex(shop_buy_product.steps, step => step.trigger === 'a[href*="/shop/checkout"]'); + + shop_buy_product.steps.splice(checkout_step_idx + 1, 0, { + trigger: "#wizard-step20.active, #wizard-step40.active", + auto: true, + l10n: 'ar', + run: function (actions) { + let $wizard_step = $("div[id=wizard-step20]"); + if ($wizard_step.length && $wizard_step.hasClass("active")) { + let $el = $('select[name="l10n_ar_afip_responsibility_type_id"]'); + if ($el.length) { + $("select[name='l10n_ar_afip_responsibility_type_id'] option[value='5']").prop('selected', true); + $("select[name='l10n_latam_identification_type_id'] option[value='1']").prop('selected', true); + $("input[name='vat']").val('123456789'); + $("a[role='button'] span:contains('Next')").click(); + } + } + }, + }, { + trigger: "span:contains('Confirm'), #wizard-step40.active", + auto: true, + l10n: 'ar', + run: function (actions) { + let $wizard_step = $("div[id=wizard-step20]"); + if ($wizard_step.length && $wizard_step.hasClass("active")) { + $("a[role='button'] span:contains('Confirm')").click(); + } + }, + }); +}); diff --git a/addons/l10n_ar_website_sale/static/tests/tours/website_sale_complete_flow.js b/addons/l10n_ar_website_sale/static/tests/tours/website_sale_complete_flow.js new file mode 100644 index 0000000000..5e241ceff1 --- /dev/null +++ b/addons/l10n_ar_website_sale/static/tests/tours/website_sale_complete_flow.js @@ -0,0 +1,19 @@ +flectra.define('l10n_ar_website_sale.website_sale_tour', function (require) { +"use strict"; + + let tour = require('web_tour.tour'); + + let website_sale_tour = tour.tours.website_sale_tour; + // Remove the step related to other localisations + website_sale_tour.steps = _.filter(website_sale_tour.steps, step => !step.l10n || step.l10n === "ar"); + + let address_step = _.findIndex(website_sale_tour.steps, step => step.content === 'Fulfill billing address form'); + let step_run = website_sale_tour.steps[address_step].run; + + website_sale_tour.steps[address_step].run = function () { + step_run(); + $("select[name='l10n_ar_afip_responsibility_type_id'] option[value='5']").prop('selected', true); + $("select[name='l10n_latam_identification_type_id'] option[value='1']").prop('selected', true); + $("input[name='vat']").val('123456789'); + }; +}); diff --git a/addons/l10n_in_pos/models/pos_order.py b/addons/l10n_in_pos/models/pos_order.py index 2a2b453dbf..cee5958028 100644 --- a/addons/l10n_in_pos/models/pos_order.py +++ b/addons/l10n_in_pos/models/pos_order.py @@ -6,7 +6,7 @@ from flectra import api, fields, models class PosOrder(models.Model): _inherit = 'pos.order' - + #TODO: remove in master @api.model def _get_account_move_line_group_data_type_key(self, data_type, values, options={}): res = super(PosOrder, self)._get_account_move_line_group_data_type_key(data_type, values, options) @@ -14,7 +14,7 @@ class PosOrder(models.Model): if self.env['account.tax'].browse(values['tax_line_id']).company_id.country_id.code == 'IN': return res + (values['product_uom_id'], values['product_id']) return res - + #TODO: remove in master def _prepare_account_move_line(self, line, partner_id, current_company, currency_id, rounding_method): res = super(PosOrder, self)._prepare_account_move_line(line, partner_id, current_company, currency_id, rounding_method) for line_values in res: @@ -24,3 +24,15 @@ class PosOrder(models.Model): 'product_uom_id': line.product_id.uom_id.id }) return res + + def _prepare_invoice_vals(self): + vals = super()._prepare_invoice_vals() + if self.session_id.company_id.country_id.code == 'IN': + partner = self.partner_id + l10n_in_gst_treatment = partner.l10n_in_gst_treatment + if not l10n_in_gst_treatment and partner.country_id and partner.country_id.code != 'IN': + l10n_in_gst_treatment = 'overseas' + if not l10n_in_gst_treatment: + l10n_in_gst_treatment = partner.vat and 'regular' or 'consumer' + vals['l10n_in_gst_treatment'] = l10n_in_gst_treatment + return vals diff --git a/addons/mail/static/src/models/partner/partner.js b/addons/mail/static/src/models/partner/partner.js index 061747c555..f3b284a58f 100644 --- a/addons/mail/static/src/models/partner/partner.js +++ b/addons/mail/static/src/models/partner/partner.js @@ -189,8 +189,11 @@ function factory(dependencies) { // partners (livechat guests), public partners (technical) continue; } + if (!partner.name) { + continue; + } if ( - (partner.nameOrDisplayName && cleanSearchTerm(partner.nameOrDisplayName).includes(cleanedSearchTerm)) || + (cleanSearchTerm(partner.name).includes(cleanedSearchTerm)) || (partner.email && cleanSearchTerm(partner.email).includes(cleanedSearchTerm)) ) { if (partner.user) { @@ -303,8 +306,8 @@ function factory(dependencies) { return 1; } } - const cleanedAName = cleanSearchTerm(a.nameOrDisplayName || ''); - const cleanedBName = cleanSearchTerm(b.nameOrDisplayName || ''); + const cleanedAName = cleanSearchTerm(a.name || ''); + const cleanedBName = cleanSearchTerm(b.name || ''); if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) { return -1; } diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index e8a9dc971b..b02cb9bd7d 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -1524,7 +1524,7 @@ exports.PosModel = Backbone.Model.extend({ return taxes; }, - get_taxes_after_fp: function(taxes_ids){ + get_taxes_after_fp: function(taxes_ids, order = false){ var self = this; var taxes = this.taxes; var product_taxes = []; @@ -1532,7 +1532,7 @@ exports.PosModel = Backbone.Model.extend({ var tax = _.detect(taxes, function(t){ return t.id === el; }); - product_taxes.push.apply(product_taxes, self._map_tax_fiscal_position(tax)); + product_taxes.push.apply(product_taxes, self._map_tax_fiscal_position(tax, order)); }); product_taxes = _.uniq(product_taxes, function(tax) { return tax.id; }); return product_taxes; @@ -2245,7 +2245,7 @@ exports.Orderline = Backbone.Model.extend({ return round_pr(this.get_unit_price() * this.get_quantity() * (1 - this.get_discount()/100), rounding); }, get_taxes_after_fp: function(taxes_ids){ - return this.pos.get_taxes_after_fp(taxes_ids); + return this.pos.get_taxes_after_fp(taxes_ids, this.order); }, get_display_price_one: function(){ var rounding = this.pos.currency.rounding; diff --git a/addons/pos_sale/report/sale_report.py b/addons/pos_sale/report/sale_report.py index 0d3b4a771f..c2fd37c476 100644 --- a/addons/pos_sale/report/sale_report.py +++ b/addons/pos_sale/report/sale_report.py @@ -53,8 +53,8 @@ class SaleReport(models.Model): partner.country_id AS country_id, partner.industry_id AS industry_id, partner.commercial_partner_id AS commercial_partner_id, - (sum(t.weight) * l.qty / u.factor) AS weight, - (sum(t.volume) * l.qty / u.factor) AS volume, + (sum(p.weight) * l.qty / u.factor) AS weight, + (sum(p.volume) * l.qty / u.factor) AS volume, l.discount as discount, sum((l.price_unit * l.discount * l.qty / 100.0 / CASE COALESCE(pos.currency_rate, 0) WHEN 0 THEN 1.0 ELSE pos.currency_rate END)) as discount_amount, NULL as order_id diff --git a/addons/pos_sale/tests/test_pos_sale_report.py b/addons/pos_sale/tests/test_pos_sale_report.py index 751fc03b65..0fac7bc80d 100644 --- a/addons/pos_sale/tests/test_pos_sale_report.py +++ b/addons/pos_sale/tests/test_pos_sale_report.py @@ -37,3 +37,46 @@ class TestPoSSaleReport(TestPoSCommon): self.assertEqual(reports[0].volume, 4) self.assertEqual(reports[1].weight, 18) self.assertEqual(reports[1].volume, 24) + + def test_weight_and_volume_product_variant(self): + colors = ['red', 'blue'] + prod_attr = self.env['product.attribute'].create({'name': 'Color', 'create_variant': 'dynamic'}) + prod_attr_values = self.env['product.attribute.value'].create([{'name': color, 'attribute_id': prod_attr.id, 'sequence': 1} for color in colors]) + + uom_unit = self.env.ref('uom.product_uom_unit') + product_template = self.env['product.template'].create({ + 'name': 'Sofa', + 'uom_id': uom_unit.id, + 'uom_po_id': uom_unit.id, + 'attribute_line_ids': [(0, 0, { + 'attribute_id': prod_attr.id, + 'value_ids': [(6, 0, prod_attr_values.ids)] + })] + }) + prod_tmpl_attrs = self.env['product.template.attribute.value'].search([ + ('attribute_line_id', '=', product_template.attribute_line_ids.id), + ('product_attribute_value_id', 'in', prod_attr_values.ids) + ]) + + product_1 = product_template._create_product_variant(prod_tmpl_attrs[0]) + product_1.weight = 1 + product_1.volume = 1 + + product_2 = product_template._create_product_variant(prod_tmpl_attrs[1]) + product_2.weight = 2 + product_2.volume = 2 + + self.open_new_session() + session = self.pos_session + + order = self.create_ui_order_data([(product_1, 3), (product_2, 3)]) + self.env['pos.order'].create_from_ui([order]) + + session.action_pos_session_closing_control() + + report = self.env['sale.report'].sudo().search([('product_id', '=', product_1.id)], order='id', limit=1) + self.assertEqual(report.weight, 3) + self.assertEqual(report.weight, 3) + report = self.env['sale.report'].sudo().search([('product_id', '=', product_2.id)], order='id', limit=1) + self.assertEqual(report.weight, 6) + self.assertEqual(report.weight, 6) diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 1d980f76d9..400164a0b1 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -1082,7 +1082,7 @@ class PurchaseOrderLine(models.Model): @api.onchange('product_qty', 'product_uom') def _onchange_quantity(self): - if not self.product_id or self.state in ('purchase', 'done'): + if not self.product_id or self.invoice_lines: return params = {'order_id': self.order_id} seller = self.product_id._select_seller( diff --git a/addons/purchase/tests/test_purchase_invoice.py b/addons/purchase/tests/test_purchase_invoice.py index 581b5c785c..7613a37b83 100644 --- a/addons/purchase/tests/test_purchase_invoice.py +++ b/addons/purchase/tests/test_purchase_invoice.py @@ -467,3 +467,61 @@ class TestPurchaseToInvoice(AccountTestInvoicingCommon): ] for line in invoice.invoice_line_ids.sorted('sequence'): self.assertEqual(line.purchase_order_id, expected_purchase.pop(0)) + + def test_on_change_quantity_price_unit(self): + """ When a user changes the quantity of a product in a purchase order it + should only update the unit price if PO line has no invoice line. """ + + supplierinfo_vals = { + 'name': self.partner_a.id, + 'price': 10.0, + 'min_qty': 1, + "product_id": self.product_order.id, + "product_tmpl_id": self.product_order.product_tmpl_id.id, + } + + supplierinfo = self.env["product.supplierinfo"].create(supplierinfo_vals) + po_form = Form(self.env['purchase.order']) + po_form.partner_id = self.partner_a + with po_form.order_line.new() as po_line_form: + po_line_form.product_id = self.product_order + po_line_form.product_qty = 1 + po = po_form.save() + po_line = po.order_line[0] + + self.assertEqual(10.0, po_line.price_unit, "Unit price should be set to 10.0 for 1 quantity") + + # Ensure price unit is updated when changing quantity on a un-confirmed PO + supplierinfo.write({'min_qty': 2, 'price': 20.0}) + po_line.write({'product_qty': 2}) + po_line._onchange_quantity() + self.assertEqual(20.0, po_line.price_unit, "Unit price should be set to 20.0 for 2 quantity") + + po.button_confirm() + + # Ensure price unit is updated when changing quantity on a confirmed PO + supplierinfo.write({'min_qty': 3, 'price': 30.0}) + po_line.write({'product_qty': 3}) + po_line._onchange_quantity() + self.assertEqual(30.0, po_line.price_unit, "Unit price should be set to 30.0 for 3 quantity") + + po.action_create_invoice() + + # Ensure price unit is NOT updated when changing quantity on PO confirmed and line linked to an invoice line + supplierinfo.write({'min_qty': 4, 'price': 40.0}) + po_line.write({'product_qty': 4}) + po_line._onchange_quantity() + self.assertEqual(30.0, po_line.price_unit, "Unit price should be set to 30.0 for 3 quantity") + + with po_form.order_line.new() as po_line_form: + po_line_form.product_id = self.product_order + po_line_form.product_qty = 1 + po = po_form.save() + po_line = po.order_line[1] + + self.assertEqual(235.0, po_line.price_unit, "Unit price should be reset to 235.0 since the supplier supplies minimum of 4 quantities") + + # Ensure price unit is updated when changing quantity on PO confirmed and line NOT linked to an invoice line + po_line.write({'product_qty': 4}) + po_line._onchange_quantity() + self.assertEqual(40.0, po_line.price_unit, "Unit price should be set to 40.0 for 4 quantity") diff --git a/addons/sale_stock/models/account_move.py b/addons/sale_stock/models/account_move.py index a7792cf96b..42225f8adc 100644 --- a/addons/sale_stock/models/account_move.py +++ b/addons/sale_stock/models/account_move.py @@ -68,18 +68,19 @@ class AccountMove(models.Model): lot_values = [] for lot, qty in qties_per_lot.items(): - if float_is_zero(invoiced_qties[lot.product_id], precision_rounding=lot.product_uom_id.rounding) \ - or float_compare(qty, 0, precision_rounding=lot.product_uom_id.rounding) <= 0: + lot_sudo = lot.sudo() + if float_is_zero(invoiced_qties[lot_sudo.product_id], precision_rounding=lot_sudo.product_uom_id.rounding) \ + or float_compare(qty, 0, precision_rounding=lot_sudo.product_uom_id.rounding) <= 0: continue - invoiced_lot_qty = min(qty, invoiced_qties[lot.product_id]) - invoiced_qties[lot.product_id] -= invoiced_lot_qty + invoiced_lot_qty = min(qty, invoiced_qties[lot_sudo.product_id]) + invoiced_qties[lot_sudo.product_id] -= invoiced_lot_qty lot_values.append({ - 'product_name': lot.product_id.display_name, + 'product_name': lot_sudo.product_id.display_name, 'quantity': formatLang(self.env, invoiced_lot_qty, dp='Product Unit of Measure'), - 'uom_name': lot.product_uom_id.name, - 'lot_name': lot.name, + 'uom_name': lot_sudo.product_uom_id.name, + 'lot_name': lot_sudo.name, # The lot id is needed by localizations to inherit the method and add custom fields on the invoice's report. - 'lot_id': lot.id, + 'lot_id': lot_sudo.id, }) return lot_values diff --git a/addons/sale_stock_margin/models/sale_order_line.py b/addons/sale_stock_margin/models/sale_order_line.py index 78166f0a11..987679f790 100644 --- a/addons/sale_stock_margin/models/sale_order_line.py +++ b/addons/sale_stock_margin/models/sale_order_line.py @@ -7,7 +7,7 @@ from flectra import api, fields, models class SaleOrderLine(models.Model): _inherit = 'sale.order.line' - @api.depends('move_ids', 'move_ids.stock_valuation_layer_ids', 'order_id.picking_ids.state') + @api.depends('move_ids', 'move_ids.stock_valuation_layer_ids', 'move_ids.picking_id.state') def _compute_purchase_price(self): lines_without_moves = self.browse() for line in self: diff --git a/addons/sale_stock_margin/tests/test_sale_stock_margin.py b/addons/sale_stock_margin/tests/test_sale_stock_margin.py index a1e5a7787d..e8f028cc85 100644 --- a/addons/sale_stock_margin/tests/test_sale_stock_margin.py +++ b/addons/sale_stock_margin/tests/test_sale_stock_margin.py @@ -166,6 +166,36 @@ class TestSaleStockMargin(TestStockValuationCommon): self.assertAlmostEqual(order_line_2.margin, 30) # (20 - 12.5) * 4 self.assertAlmostEqual(sale_order.margin, 64) + def test_sale_stock_margin_6(self): + """ Test that the purchase price doesn't change when there is a service product in the SO""" + service = self.env['product.product'].create({ + 'name': 'Service', + 'type': 'service', + 'list_price': 100.0, + 'standard_price': 50.0}) + self.product1.list_price = 80.0 + self.product1.standard_price = 40.0 + sale_order = self._create_sale_order() + order_line_1 = self._create_sale_order_line(sale_order, service, 1, 100) + order_line_2 = self._create_sale_order_line(sale_order, self.product1, 1, 80) + + self.assertEqual(order_line_1.purchase_price, 50, "Sales order line cost should be 50.00") + self.assertEqual(order_line_2.purchase_price, 40, "Sales order line cost should be 40.00") + + self.assertEqual(order_line_1.margin, 50, "Sales order line profit should be 50.00") + self.assertEqual(order_line_2.margin, 40, "Sales order line profit should be 40.00") + self.assertEqual(sale_order.margin, 90, "Sales order profit should be 90.00") + + # Change the purchase price of the service product. + order_line_1.purchase_price = 100.0 + self.assertEqual(order_line_1.purchase_price, 100, "Sales order line cost should be 100.00") + + # Confirm the sales order. + sale_order.action_confirm() + + self.assertEqual(order_line_1.purchase_price, 100, "Sales order line cost should be 100.00") + self.assertEqual(order_line_2.purchase_price, 40, "Sales order line cost should be 40.00") + def test_so_and_multicurrency(self): ResCurrencyRate = self.env['res.currency.rate'] company_currency = self.env.company.currency_id diff --git a/addons/stock/views/stock_quant_views.xml b/addons/stock/views/stock_quant_views.xml index 33256a95f8..8cb1f43d58 100644 --- a/addons/stock/views/stock_quant_views.xml +++ b/addons/stock/views/stock_quant_views.xml @@ -208,9 +208,10 @@ - Stock On Hand - {'search_default_internal_loc': 1, 'search_default_productgroup':1, 'search_default_locationgroup':1} + Update Quantity + {'search_default_internal_loc': 1, 'search_default_productgroup':1, 'search_default_locationgroup':1, 'inventory_mode':True} stock.quant + {'search_default_productgroup': 1} diff --git a/addons/web/tests/test_serving_base.py b/addons/web/tests/test_serving_base.py index 2d82ed7eb6..65e369d2a2 100644 --- a/addons/web/tests/test_serving_base.py +++ b/addons/web/tests/test_serving_base.py @@ -999,4 +999,4 @@ class TestStaticInheritancePerformance(TestStaticInheritanceCommon): time_ratio = delta25000.total_seconds() / delta2500.total_seconds() _logger.runbot('Static Templates Inheritance: 25000 templates treated in %s seconds' % delta25000.total_seconds()) _logger.runbot('Static Templates Inheritance: Computed linearity ratio: %s' % time_ratio) - self.assertLessEqual(time_ratio, 12) + self.assertLessEqual(time_ratio, 14) diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 3a66b44866..9a53100538 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -631,8 +631,10 @@
+

Product Name

-