From 3e576b368d72da6826a537c8e99c09c70ff9a819 Mon Sep 17 00:00:00 2001 From: Parthiv Patel Date: Sun, 20 Jun 2021 11:03:19 +0000 Subject: [PATCH] [PATCH] Upstream patch --- addons/account/models/account_move.py | 68 ++-- .../models/account_partial_reconcile.py | 47 +-- .../account/models/account_reconcile_model.py | 3 +- addons/account/models/account_tax_report.py | 23 +- .../account/report/account_invoice_report.py | 6 +- addons/account/tests/common.py | 1 + .../tests/test_account_invoice_report.py | 4 +- .../tests/test_account_move_reconcile.py | 304 ++++++++++++++++++ addons/account/tests/test_tax_report.py | 28 ++ .../views/account_account_tag_views.xml | 6 +- addons/account/views/account_move_views.xml | 6 +- addons/account/views/account_payment_view.xml | 6 +- .../wizard/account_payment_register.py | 2 +- .../account_edi/models/account_edi_format.py | 3 +- .../models/account_edi_format.py | 24 +- addons/base_vat/models/res_partner.py | 9 +- addons/crm/models/crm_lead.py | 75 ++--- addons/crm/tests/test_crm_lead.py | 56 ++++ addons/crm/views/crm_lead_views.xml | 6 +- addons/digest/data/digest_data.xml | 10 +- addons/event/views/event_views.xml | 12 +- .../fleet/views/fleet_vehicle_cost_views.xml | 6 +- addons/fleet/views/fleet_vehicle_views.xml | 6 +- addons/hr/models/hr_employee_base.py | 2 +- addons/hr/views/hr_employee_views.xml | 6 +- .../hr_contract/views/hr_contract_views.xml | 6 +- addons/hr_expense/models/hr_expense.py | 21 +- addons/hr_expense/views/hr_expense_views.xml | 12 +- .../wizard/account_payment_register.py | 9 + .../views/hr_leave_allocation_views.xml | 6 +- addons/hr_holidays/views/hr_leave_views.xml | 6 +- addons/hr_recruitment/data/digest_data.xml | 2 +- .../data/hr_recruitment_data.xml | 4 +- .../views/hr_recruitment_views.xml | 6 +- addons/hw_drivers/tools/helpers.py | 4 +- .../l10n_it_edi/data/account_invoice_demo.xml | 10 +- .../l10n_it_edi/data/invoice_it_template.xml | 4 +- .../l10n_it_edi/models/account_edi_format.py | 166 ++++++++-- addons/l10n_it_edi/models/account_invoice.py | 90 +----- addons/l10n_it_edi/models/ir_mail_server.py | 22 +- .../l10n_it_edi/tests/test_ir_mail_server.py | 19 +- addons/link_tracker/models/link_tracker.py | 7 +- addons/mail/models/mail_activity.py | 22 ++ .../src/components/message/message_tests.js | 71 ++++ addons/mail/views/res_partner_views.xml | 6 +- .../maintenance/views/maintenance_views.xml | 12 +- .../data/mailing_data_templates.xml | 2 +- addons/mrp/models/mrp_production.py | 8 +- addons/mrp/models/stock_move.py | 4 +- addons/mrp/views/mrp_bom_views.xml | 6 +- addons/mrp/views/mrp_production_views.xml | 6 +- addons/mrp/views/mrp_unbuild_views.xml | 6 +- addons/mrp_account/models/product.py | 2 +- addons/mrp_account/models/stock_move.py | 5 + addons/note/views/note_views.xml | 6 +- addons/payment_ingenico/controllers/main.py | 2 +- addons/point_of_sale/controllers/main.py | 4 +- addons/point_of_sale/models/pos_order.py | 2 +- addons/point_of_sale/models/pos_session.py | 22 +- addons/point_of_sale/models/stock_picking.py | 2 +- addons/point_of_sale/static/src/css/pos.css | 9 +- .../static/src/css/pos_receipts.css | 2 +- addons/point_of_sale/static/src/js/Chrome.js | 2 +- .../src/js/Misc/AbstractReceiptScreen.js | 6 +- .../ClientListScreen/ClientDetailsEdit.js | 6 +- .../ControlButtons/ReprintReceiptButton.js | 10 +- .../OrderManagementScreen.js | 7 +- .../ReprintReceiptScreen.js | 19 +- .../js/Screens/PaymentScreen/PaymentScreen.js | 7 +- .../js/Screens/ReceiptScreen/ReceiptScreen.js | 5 +- .../static/src/js/custom_hooks.js | 8 + addons/point_of_sale/static/src/js/models.js | 2 +- .../OrderManagementScreen.xml | 5 +- .../xml/Screens/ProductScreen/Orderline.xml | 2 +- .../Screens/ReceiptScreen/OrderReceipt.xml | 4 +- .../tests/test_point_of_sale_flow.py | 68 ++++ .../controllers/portal_rating.py | 2 +- .../static/src/js/Screens/BillScreen.js | 3 + .../static/src/js/Screens/TipScreen.js | 3 +- addons/product/views/product_views.xml | 6 +- addons/project/views/project_views.xml | 6 +- addons/purchase/models/purchase.py | 10 +- addons/purchase/tests/test_purchase.py | 33 ++ addons/purchase/views/purchase_views.xml | 6 +- .../views/purchase_requisition_views.xml | 6 +- addons/repair/views/repair_views.xml | 6 +- addons/sale/models/account_move.py | 13 +- addons/sale/models/sale.py | 19 +- addons/sale/security/sale_security.xml | 12 +- addons/sale/tests/test_sale_to_invoice.py | 28 ++ addons/sale/views/sale_views.xml | 6 +- addons/sale_coupon/models/coupon_program.py | 5 +- addons/sale_mrp/models/account_move.py | 4 +- addons/sale_stock/models/stock.py | 15 +- addons/sale_timesheet/models/project.py | 4 +- .../sale_timesheet/models/project_overview.py | 13 +- addons/stock/models/stock_move.py | 17 +- addons/stock/models/stock_picking.py | 2 +- addons/stock/static/img/replenishment.svg | 2 +- addons/stock/views/stock_move_views.xml | 6 + addons/stock/views/stock_picking_views.xml | 6 +- addons/stock_account/models/product.py | 2 +- addons/stock_account/models/stock_move.py | 3 + .../stock_valuation_layer_revaluation.py | 6 +- ...tock_valuation_layer_revaluation_views.xml | 2 +- addons/stock_landed_costs/models/product.py | 11 + .../views/stock_landed_cost_views.xml | 6 +- .../views/stock_picking_batch_views.xml | 6 +- addons/survey/controllers/main.py | 2 +- addons/survey/tests/__init__.py | 1 + addons/survey/tests/common.py | 12 + .../tests/test_survey_flow_with_conditions.py | 126 ++++++++ addons/survey/views/survey_survey_views.xml | 6 +- addons/test_mail/tests/test_mail_activity.py | 28 ++ addons/uom/views/uom_uom_views.xml | 8 +- addons/web/models/ir_http.py | 6 + addons/web/static/lib/owl/owl.js | 53 ++- addons/web/static/src/img/logo.png | Bin 14890 -> 10503 bytes addons/web/static/src/js/core/time.js | 4 +- .../web/static/src/js/fields/basic_fields.js | 4 +- .../web/static/src/js/fields/field_utils.js | 2 +- .../src/js/views/calendar/calendar_model.js | 4 +- .../src/js/views/calendar/calendar_popover.js | 5 +- .../src/js/views/graph/graph_renderer.js | 2 +- .../src/js/views/kanban/kanban_record.js | 2 +- .../static/src/js/views/pivot/pivot_model.js | 11 +- addons/web/static/src/xml/base.xml | 2 +- addons/web/static/tests/core/time_tests.js | 15 + .../web/static/tests/views/calendar_tests.js | 51 +++ .../static/shapes/Blocks/01_001.svg | 26 ++ .../static/shapes/Blocks/02_001.svg | 25 ++ .../static/src/scss/web_editor.frontend.scss | 2 +- .../static/src/scss/web_editor.variables.scss | 2 + addons/web_editor/views/snippets.xml | 4 +- addons/web_tour/static/src/js/tour_service.js | 2 +- addons/website/models/website.py | 10 +- .../src/js/backend/res_config_settings.js | 4 +- addons/website/static/src/js/content/menu.js | 2 +- addons/website/static/src/js/menu/edit.js | 9 +- .../src/snippets/s_image_gallery/options.js | 2 +- .../static/src/snippets/s_timeline/000.scss | 1 + addons/website/views/snippets/s_popup.xml | 1 + .../static/src/scss/website_blog.scss | 8 +- .../views/event_track_views.xml | 6 +- .../static/src/scss/website_forum.scss | 14 +- addons/website_membership/controllers/main.py | 5 +- .../static/src/js/website_sale_backend.js | 2 +- addons/website_slides/controllers/mail.py | 2 +- addons/website_slides/controllers/main.py | 2 +- addons/website_slides/data/mail_data.xml | 4 +- addons/website_slides/models/slide_slide.py | 2 +- .../static/src/img/banner_default.svg | 239 +------------- .../static/src/img/banner_default_all.svg | 113 +------ .../views/slide_slide_views.xml | 7 +- .../views/website_slides_templates_course.xml | 4 +- 155 files changed, 1673 insertions(+), 901 deletions(-) create mode 100644 addons/survey/tests/test_survey_flow_with_conditions.py create mode 100644 addons/web_editor/static/shapes/Blocks/01_001.svg create mode 100644 addons/web_editor/static/shapes/Blocks/02_001.svg diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 9f6c87c2ca..e0f2c0f5b3 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -367,9 +367,12 @@ class AccountMove(models.Model): if self.invoice_date: if not self.invoice_payment_term_id and (not self.invoice_date_due or self.invoice_date_due < self.invoice_date): self.invoice_date_due = self.invoice_date - if self.date != self.invoice_date: # Don't flag date as dirty if not needed + if ( + self.is_sale_document() and self.date != self.invoice_date + or self.is_purchase_document() and not self.date + ): self.date = self.invoice_date - self._onchange_currency() + self._onchange_currency() @api.onchange('journal_id') def _onchange_journal(self): @@ -795,7 +798,7 @@ class AccountMove(models.Model): 'amount_currency': diff_amount_currency, 'partner_id': self.partner_id.id, 'move_id': self.id, - 'currency_id': self.currency_id, + 'currency_id': self.currency_id.id, 'company_id': self.company_id.id, 'company_currency_id': self.company_id.currency_id.id, 'is_rounding_line': True, @@ -2338,13 +2341,17 @@ class AccountMove(models.Model): if cancel: reverse_moves.with_context(move_reverse_cancel=cancel)._post(soft=False) for move, reverse_move in zip(self, reverse_moves): - accounts = move.mapped('line_ids.account_id') \ - .filtered(lambda account: account.reconcile or account.internal_type == 'liquidity') - for account in accounts: - (move.line_ids + reverse_move.line_ids)\ - .filtered(lambda line: line.account_id == account and not line.reconciled)\ - .with_context(move_reverse_cancel=cancel)\ - .reconcile() + lines = move.line_ids.filtered( + lambda x: (x.account_id.reconcile or x.account_id.internal_type == 'liquidity') + and not x.reconciled + ) + for line in lines: + counterpart_lines = reverse_move.line_ids.filtered( + lambda x: x.account_id == line.account_id + and x.currency_id == line.currency_id + and not x.reconciled + ) + (line + counterpart_lines).with_context(move_reverse_cancel=cancel).reconcile() return reverse_moves @@ -3540,6 +3547,8 @@ class AccountMoveLine(models.Model): @api.onchange('product_uom_id') def _onchange_uom_id(self): ''' Recompute the 'price_unit' depending of the unit of measure. ''' + if self.display_type in ('line_section', 'line_note'): + return taxes = self._get_computed_taxes() if taxes and self.move_id.fiscal_position_id: taxes = self.move_id.fiscal_position_id.map_tax(taxes, partner=self.partner_id) @@ -4235,12 +4244,16 @@ class AccountMoveLine(models.Model): if debit_line_currency == credit_line_currency: # Reconcile on the same currency. - # The debit line is now fully reconciled. + # The debit line is now fully reconciled because: + # - either amount_residual & amount_residual_currency are at 0. + # - either the credit_line is not an exchange difference one. if not has_debit_residual_curr_left and (has_credit_residual_curr_left or not has_debit_residual_left): debit_line = None continue - # The credit line is now fully reconciled. + # The credit line is now fully reconciled because: + # - either amount_residual & amount_residual_currency are at 0. + # - either the debit is not an exchange difference one. if not has_credit_residual_curr_left and (has_debit_residual_curr_left or not has_credit_residual_left): credit_line = None continue @@ -4252,13 +4265,13 @@ class AccountMoveLine(models.Model): else: # Reconcile on the company's currency. - # The debit line is now fully reconciled. - if not has_debit_residual_left and (has_credit_residual_left or not has_debit_residual_curr_left): + # The debit line is now fully reconciled since amount_residual is 0. + if not has_debit_residual_left: debit_line = None continue - # The credit line is now fully reconciled. - if not has_credit_residual_left and (has_debit_residual_left or not has_credit_residual_curr_left): + # The credit line is now fully reconciled since amount_residual is 0. + if not has_credit_residual_left: credit_line = None continue @@ -4418,12 +4431,23 @@ class AccountMoveLine(models.Model): if line.tax_repartition_line_id: # Tax line. grouping_key = self.env['account.partial.reconcile']._get_cash_basis_tax_line_grouping_key_from_record(line) - account_vals_to_fix[grouping_key] = { - **vals, - 'account_id': line.account_id.id, - 'tax_base_amount': line.tax_base_amount, - 'tax_repartition_line_id': line.tax_repartition_line_id.id, - } + if grouping_key in account_vals_to_fix: + debit = account_vals_to_fix[grouping_key]['debit'] + vals['debit'] + credit = account_vals_to_fix[grouping_key]['credit'] + vals['credit'] + balance = debit - credit + + account_vals_to_fix[grouping_key].update({ + 'debit': balance if balance > 0 else 0, + 'credit': -balance if balance < 0 else 0, + 'tax_base_amount': account_vals_to_fix[grouping_key]['tax_base_amount'] + line.tax_base_amount, + }) + else: + account_vals_to_fix[grouping_key] = { + **vals, + 'account_id': line.account_id.id, + 'tax_base_amount': line.tax_base_amount, + 'tax_repartition_line_id': line.tax_repartition_line_id.id, + } elif line.tax_ids: # Base line. account_to_fix = line.company_id.account_cash_basis_base_account_id diff --git a/addons/account/models/account_partial_reconcile.py b/addons/account/models/account_partial_reconcile.py index 087d3d7509..c5659cd081 100644 --- a/addons/account/models/account_partial_reconcile.py +++ b/addons/account/models/account_partial_reconcile.py @@ -522,33 +522,40 @@ class AccountPartialReconcile(models.Model): if line.tax_repartition_line_id: # Tax line. - cb_tax_line_vals = self._prepare_cash_basis_tax_line_vals(line, balance, amount_currency) - grouping_key = self._get_cash_basis_tax_line_grouping_key_from_vals(cb_tax_line_vals) - partial_lines_to_create[grouping_key] = { - 'tax_line': line, - 'vals': cb_tax_line_vals, - } - + cb_line_vals = self._prepare_cash_basis_tax_line_vals(line, balance, amount_currency) + grouping_key = self._get_cash_basis_tax_line_grouping_key_from_vals(cb_line_vals) elif line.tax_ids: # Base line. - cb_base_line_vals = self._prepare_cash_basis_base_line_vals(line, balance, amount_currency) - grouping_key = self._get_cash_basis_base_line_grouping_key_from_vals(cb_base_line_vals) + cb_line_vals = self._prepare_cash_basis_base_line_vals(line, balance, amount_currency) + grouping_key = self._get_cash_basis_base_line_grouping_key_from_vals(cb_line_vals) - if grouping_key in partial_lines_to_create: - aggregated_vals = partial_lines_to_create[grouping_key]['vals'] - balance = aggregated_vals['debit'] - aggregated_vals['credit'] - balance += cb_base_line_vals['debit'] - cb_base_line_vals['credit'] + if grouping_key in partial_lines_to_create: + aggregated_vals = partial_lines_to_create[grouping_key]['vals'] + debit = aggregated_vals['debit'] + cb_line_vals['debit'] + credit = aggregated_vals['credit'] + cb_line_vals['credit'] + balance = debit - credit + + aggregated_vals.update({ + 'debit': balance if balance > 0 else 0, + 'credit': -balance if balance < 0 else 0, + 'amount_currency': aggregated_vals['amount_currency'] + cb_line_vals['amount_currency'], + }) + + if line.tax_repartition_line_id: aggregated_vals.update({ - 'debit': balance if balance > 0.0 else 0.0, - 'credit': -balance if balance < 0.0 else 0.0, + 'tax_base_amount': aggregated_vals['tax_base_amount'] + cb_line_vals['tax_base_amount'], + }) + partial_lines_to_create[grouping_key]['tax_line'] += line + else: + partial_lines_to_create[grouping_key] = { + 'vals': cb_line_vals, + } + if line.tax_repartition_line_id: + partial_lines_to_create[grouping_key].update({ + 'tax_line': line, }) - aggregated_vals['amount_currency'] += cb_base_line_vals['amount_currency'] - else: - partial_lines_to_create[grouping_key] = { - 'vals': cb_base_line_vals, - } # ========================================================================== # Create the counterpart journal items. diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index 6d5069e95c..dad9a631e2 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -884,7 +884,8 @@ class AccountReconcileModel(models.Model): # Statement line amount is equal to the total residual. if line_currency.is_zero(line_residual_after_reconciliation): return True - reconciled_percentage = 100 - abs(line_residual_after_reconciliation) / abs(line_residual - line_residual_after_reconciliation) * 100 + residual_difference = line_residual - line_residual_after_reconciliation + reconciled_percentage = 100 - abs(line_residual_after_reconciliation) / abs(residual_difference) * 100 if (residual_difference != 0) else 0 return reconciled_percentage >= self.match_total_amount_param def _filter_candidates(self, candidates, aml_ids_to_exclude, reconciled_amls_ids): diff --git a/addons/account/models/account_tax_report.py b/addons/account/models/account_tax_report.py index a8ffef1601..fb4f565c2b 100644 --- a/addons/account/models/account_tax_report.py +++ b/addons/account/models/account_tax_report.py @@ -31,12 +31,11 @@ class AccountTaxReport(models.Model): new_tags = tags_cache[cache_key] if new_tags: - tags_to_unlink = line.tag_ids.filtered(lambda x: record == x.mapped('tax_report_line_ids.report_id')) - # == instead of in, as we only want tags_to_unlink to contain the tags that are not linked to any other report than the one we're considering + line._remove_tags_used_only_by_self() line.write({'tag_ids': [(6, 0, new_tags.ids)]}) - self.env['account.tax.report.line']._delete_tags_from_taxes(tags_to_unlink.ids) elif line.mapped('tag_ids.tax_report_line_ids.report_id').filtered(lambda x: x not in self): + line._remove_tags_used_only_by_self() line.write({'tag_ids': [(5, 0, 0)] + line._get_tags_create_vals(line.tag_name, vals['country_id'])}) tags_cache[cache_key] = line.tag_ids @@ -203,7 +202,7 @@ class AccountTaxReportLine(models.Model): # All the lines sharing their tags must always be synchronized, tags_to_remove += records_to_link.mapped('tag_ids') records_to_link = tags_to_remove.mapped('tax_report_line_ids') - self._delete_tags_from_taxes(tags_to_remove.ids) + tags_to_remove.mapped('tax_report_line_ids')._remove_tags_used_only_by_self() records_to_link.write({'tag_name': tag_name_postponed, 'tag_ids': [(2, tag.id) for tag in tags_to_remove] + [(6, 0, existing_tags.ids)]}) else: @@ -220,17 +219,27 @@ class AccountTaxReportLine(models.Model): return rslt def unlink(self): - self._delete_tags_from_taxes(self.mapped('tag_ids.id')) + self._remove_tags_used_only_by_self() children = self.mapped('children_line_ids') if children: children.unlink() return super(AccountTaxReportLine, self).unlink() + def _remove_tags_used_only_by_self(self): + """ Deletes and removes from taxes and move lines all the + tags from the provided tax report lines that are not linked + to any other tax report lines. + """ + all_tags = self.mapped('tag_ids') + tags_to_unlink = all_tags.filtered(lambda x: not (x.tax_report_line_ids - self)) + self.write({'tag_ids': [(3, tag.id, 0) for tag in tags_to_unlink]}) + self._delete_tags_from_taxes(tags_to_unlink.ids) + @api.model def _delete_tags_from_taxes(self, tag_ids_to_delete): """ Based on a list of tag ids, removes them first from the repartition lines they are linked to, then deletes them - from the account move lines. + from the account move lines, and finally unlink them. """ if not tag_ids_to_delete: # Nothing to do, then! @@ -247,6 +256,8 @@ class AccountTaxReportLine(models.Model): self.env['account.move.line'].invalidate_cache(fnames=['tax_tag_ids']) self.env['account.tax.repartition.line'].invalidate_cache(fnames=['tag_ids']) + self.env['account.account.tag'].browse(tag_ids_to_delete).unlink() + @api.constrains('formula', 'tag_name') def _validate_formula(self): for record in self: diff --git a/addons/account/report/account_invoice_report.py b/addons/account/report/account_invoice_report.py index 0515d3d894..2ed4dade3b 100644 --- a/addons/account/report/account_invoice_report.py +++ b/addons/account/report/account_invoice_report.py @@ -98,7 +98,11 @@ class AccountInvoiceReport(models.Model): line.quantity / NULLIF(COALESCE(uom_line.factor, 1) / COALESCE(uom_template.factor, 1), 0.0) * (CASE WHEN move.move_type IN ('in_invoice','out_refund','in_receipt') THEN -1 ELSE 1 END) AS quantity, -line.balance * currency_table.rate AS price_subtotal, - -line.balance / NULLIF(COALESCE(uom_line.factor, 1) / COALESCE(uom_template.factor, 1), 0.0) * currency_table.rate + -COALESCE(line.balance + / NULLIF(line.quantity, 0.0) + / NULLIF(COALESCE(uom_line.factor, 1), 0.0) + / NULLIF(COALESCE(uom_template.factor, 1), 0.0), + 0.0) * currency_table.rate AS price_average, COALESCE(partner.country_id, commercial_partner.country_id) AS country_id ''' diff --git a/addons/account/tests/common.py b/addons/account/tests/common.py index 202173b65a..8e197996e6 100644 --- a/addons/account/tests/common.py +++ b/addons/account/tests/common.py @@ -364,6 +364,7 @@ class AccountTestInvoicingCommon(SavepointCase): def init_invoice(cls, move_type, partner=None, invoice_date=None, post=False, products=[], amounts=[], taxes=None): move_form = Form(cls.env['account.move'].with_context(default_move_type=move_type, account_predictive_bills_disable_prediction=True)) move_form.invoice_date = invoice_date or fields.Date.from_string('2019-01-01') + move_form.date = move_form.invoice_date move_form.partner_id = partner or cls.partner_a for product in products: diff --git a/addons/account/tests/test_account_invoice_report.py b/addons/account/tests/test_account_invoice_report.py index ba6e5a637b..f341db0208 100644 --- a/addons/account/tests/test_account_invoice_report.py +++ b/addons/account/tests/test_account_invoice_report.py @@ -21,7 +21,7 @@ class TestAccountInvoiceReport(AccountTestInvoicingCommon): (0, None, { 'product_id': cls.product_a.id, 'quantity': 3, - 'price_unit': 1000, + 'price_unit': 750, }), (0, None, { 'product_id': cls.product_a.id, @@ -110,7 +110,7 @@ class TestAccountInvoiceReport(AccountTestInvoicingCommon): #price_average price_subtotal quantity [2000, 2000, 1], [1000, 1000, 1], - [1000, 1000, 3], + [250, 750, 3], [6, 6, 1], [-20, -20, -1], [-20, -20, -1], diff --git a/addons/account/tests/test_account_move_reconcile.py b/addons/account/tests/test_account_move_reconcile.py index 5ad0c9a172..b2c82d01ee 100644 --- a/addons/account/tests/test_account_move_reconcile.py +++ b/addons/account/tests/test_account_move_reconcile.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from flectra.addons.account.tests.common import AccountTestInvoicingCommon from flectra.tests import tagged +from flectra.tests.common import Form +from flectra import fields @tagged('post_install', '-at_install') @@ -677,6 +679,232 @@ class TestAccountMoveReconcile(AccountTestInvoicingCommon): self.assertFullReconcile(res['full_reconcile'], line_1 + line_2 + line_3 + line_4 + line_5) + def test_reconcile_asymetric_rate_change(self): + account_id = self.company_data['default_account_receivable'].id + + # Rate is 3.0 in 2016, 2.0 in 2017. + currency1_id = self.currency_data['currency'].id + # Rate is 6.0 in 2016, 4.0 in 2017. + currency2_id = self.currency_data_2['currency'].id + + # Create rate changes for 2018: currency1 rate increases while currency2 rate decreases. + self.env['res.currency.rate'].create({ + 'name': '2018-01-01', + 'rate': 8.0, + 'currency_id': currency1_id, + 'company_id': self.company_data['company'].id, + }) + + self.env['res.currency.rate'].create({ + 'name': '2018-01-01', + 'rate': 2.0, + 'currency_id': currency2_id, + 'company_id': self.company_data['company'].id, + }) + + moves = self.env['account.move'].create([ + { + 'move_type': 'entry', + 'date': '2018-01-01', + 'line_ids': [ + (0, 0, { + 'debit': 1200.0, + 'credit': 0.0, + 'amount_currency': 9600.0, + 'account_id': account_id, + 'currency_id': currency1_id, + }), + (0, 0, { + 'debit': 960.0, + 'credit': 0.0, + 'amount_currency': 1920.0, + 'account_id': account_id, + 'currency_id': currency2_id, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 2160.0, + 'account_id': account_id, + }), + ] + }, + { + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, { + 'debit': 0.0, + 'credit': 1200.0, + 'amount_currency': -4800.0, + 'account_id': account_id, + 'currency_id': currency2_id, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 960.0, + 'amount_currency': -1920.0, + 'account_id': account_id, + 'currency_id': currency1_id, + }), + (0, 0, { + 'debit': 2160.0, + 'credit': 0.0, + 'account_id': account_id, + }), + ] + } + ]) + + moves.action_post() + + line_1 = moves.line_ids.filtered(lambda line: line.debit == 1200.0) + line_2 = moves.line_ids.filtered(lambda line: line.debit == 960.0) + line_3 = moves.line_ids.filtered(lambda line: line.credit == 1200.0) + line_4 = moves.line_ids.filtered(lambda line: line.credit == 960.0) + + self.assertRecordValues(line_1 + line_2 + line_3 + line_4, [ + {'amount_residual': 1200.0, 'amount_residual_currency': 9600.0, 'reconciled': False}, + {'amount_residual': 960.0, 'amount_residual_currency': 1920.0, 'reconciled': False}, + {'amount_residual': -1200.0, 'amount_residual_currency': -4800.0, 'reconciled': False}, + {'amount_residual': -960.0, 'amount_residual_currency': -1920.0, 'reconciled': False}, + ]) + + # Reconcile with debit_line currency rate increased and credit_line currency rate decreased between + # credit_line.date and debit_line.date. + + res = (line_1 + line_3).reconcile() + + exchange_diff = res['full_reconcile'].exchange_move_id + exchange_diff_lines = exchange_diff.line_ids.sorted(lambda line: (line.currency_id, abs(line.amount_currency), -line.amount_currency)) + + self.assertRecordValues(exchange_diff_lines, [ + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 2400.0, + 'currency_id': currency2_id, + 'account_id': account_id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -2400.0, + 'currency_id': currency2_id, + 'account_id': exchange_diff.journal_id.company_id.income_currency_exchange_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 7200.0, + 'currency_id': currency1_id, + 'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -7200.0, + 'currency_id': currency1_id, + 'account_id': account_id, + } + ]) + + self.assertPartialReconcile(res['partials'], [ + { + 'amount': 0.0, + 'debit_amount_currency': 2400.0, + 'credit_amount_currency': 2400.0, + 'debit_move_id': exchange_diff_lines[0].id, + 'credit_move_id': line_3.id, + }, + { + 'amount': 0.0, + 'debit_amount_currency': 7200.0, + 'credit_amount_currency': 7200.0, + 'debit_move_id': line_1.id, + 'credit_move_id': exchange_diff_lines[3].id, + }, + { + 'amount': 1200.0, + 'debit_amount_currency': 2400.0, + 'credit_amount_currency': 2400.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_3.id, + }, + ]) + + self.assertRecordValues(line_1 + line_3, [ + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + ]) + + # Reconcile with debit_line currency rate decreased and credit_line currency rate increased between + # credit_line.date and debit_line.date. + + res = (line_2 + line_4).reconcile() + + exchange_diff = res['full_reconcile'].exchange_move_id + exchange_diff_lines = exchange_diff.line_ids.sorted(lambda line: (line.currency_id, abs(line.amount_currency), -line.amount_currency)) + + self.assertRecordValues(exchange_diff_lines, [ + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 5760.0, + 'currency_id': currency1_id, + 'account_id': exchange_diff.journal_id.company_id.expense_currency_exchange_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -5760.0, + 'currency_id': currency1_id, + 'account_id': account_id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 1920.0, + 'currency_id': currency2_id, + 'account_id': account_id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -1920.0, + 'currency_id': currency2_id, + 'account_id': exchange_diff.journal_id.company_id.income_currency_exchange_account_id.id, + } + ]) + + self.assertPartialReconcile(res['partials'], [ + { + 'amount': 0.0, + 'debit_amount_currency': 1920.0, + 'credit_amount_currency': 1920.0, + 'debit_move_id': exchange_diff_lines[2].id, + 'credit_move_id': line_2.id, + }, + { + 'amount': 0.0, + 'debit_amount_currency': 5760.0, + 'credit_amount_currency': 5760.0, + 'debit_move_id': line_4.id, + 'credit_move_id': exchange_diff_lines[1].id, + }, + { + 'amount': 960.0, + 'debit_amount_currency': 3840.0, + 'credit_amount_currency': 7680.0, + 'debit_move_id': line_2.id, + 'credit_move_id': line_4.id, + }, + ]) + + self.assertRecordValues(line_2 + line_4, [ + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + ]) + def test_reverse_exchange_difference_same_foreign_currency(self): move_2016 = self.env['account.move'].create({ 'move_type': 'entry', @@ -2076,6 +2304,82 @@ class TestAccountMoveReconcile(AccountTestInvoicingCommon): # Check full reconciliation self.assertTrue(all(line.full_reconcile_id for line in lines_to_reconcile), "All tax lines should be fully reconciled") + def test_caba_double_tax(self): + """ Test the CABA entries generated from an invoice with almost + equal lines, different only on analytic accounting + """ + # Make the tax account reconcilable + self.tax_account_1.reconcile = True + + # Create an invoice with a CABA tax using 'Include in analytic cost' + move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice', account_predictive_bills_disable_prediction=True)) + move_form.invoice_date = fields.Date.from_string('2019-01-01') + move_form.partner_id = self.partner_a + self.cash_basis_tax_a_third_amount.analytic = True + test_analytic_account = self.env['account.analytic.account'].create({'name': 'test_analytic_account'}) + + tax = self.cash_basis_tax_a_third_amount + + # line with analytic account, will generate 2 lines in CABA move + with move_form.invoice_line_ids.new() as line_form: + line_form.name = "test line with analytic account" + line_form.product_id = self.product_a + line_form.tax_ids.clear() + line_form.tax_ids.add(tax) + line_form.analytic_account_id = test_analytic_account + line_form.price_unit = 100 + + # line with analytic account, will generate other 2 lines in CABA move + # even if the tax is the same + with move_form.invoice_line_ids.new() as line_form: + line_form.name = "test line" + line_form.product_id = self.product_a + line_form.tax_ids.clear() + line_form.tax_ids.add(tax) + line_form.price_unit = 100 + + rslt = move_form.save() + rslt.action_post() + + pmt_wizard = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=rslt.ids).create({ + 'amount': rslt.amount_total, + 'payment_date': rslt.date, + 'journal_id': self.company_data['default_journal_bank'].id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + }) + pmt_wizard._create_payments() + + partial_rec = rslt.mapped('line_ids.matched_debit_ids') + caba_move = self.env['account.move'].search([('tax_cash_basis_rec_id', '=', partial_rec.id)]) + self.assertEqual(len(caba_move.line_ids), 4, "All lines should be there") + self.assertEqual(caba_move.line_ids.filtered(lambda x: x.tax_line_id).balance, 66.66, "Tax amount should take into account both lines") + + def test_caba_double_tax_negative_line(self): + """ Tests making a cash basis invoice with 2 lines using the same tax: a positive and a negative one. + """ + invoice = self.init_invoice('in_invoice', amounts=[300, -60], post=True, taxes=self.cash_basis_tax_a_third_amount) + + pmt_wizard = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=invoice.ids).create({ + 'amount': 320, + 'payment_date': invoice.date, + 'journal_id': self.company_data['default_journal_bank'].id, + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + }) + + pmt_wizard._create_payments() + + partial_rec = invoice.mapped('line_ids.matched_debit_ids') + caba_move = self.env['account.move'].search([('tax_cash_basis_rec_id', '=', partial_rec.id)]) + + self.assertRecordValues(caba_move.line_ids.sorted(lambda line: (-abs(line.balance), -line.debit, line.account_id)), [ + # Base amount: + {'debit': 240.0, 'credit': 0.0, 'tax_tag_ids': self.tax_tags[0].ids, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 240.0, 'tax_tag_ids': [], 'account_id': self.cash_basis_base_account.id}, + # tax: + {'debit': 80.0, 'credit': 0.0, 'tax_tag_ids': self.tax_tags[1].ids, 'account_id': self.tax_account_1.id}, + {'debit': 0.0, 'credit': 80.0, 'tax_tag_ids': [], 'account_id': self.cash_basis_transfer_account.id}, + ]) + def test_caba_dest_acc_reconciliation_partial_pmt(self): """ Test the reconciliation of tax lines (when using a reconcilable tax account) for partially paid invoices with cash basis taxes. diff --git a/addons/account/tests/test_tax_report.py b/addons/account/tests/test_tax_report.py index dd696aedf8..0fb9011ab9 100644 --- a/addons/account/tests/test_tax_report.py +++ b/addons/account/tests/test_tax_report.py @@ -68,6 +68,13 @@ class TaxReportTest(AccountTestInvoicingCommon): 'sequence': 7, }) + cls.tax_report_line_1_6 = cls.env['account.tax.report.line'].create({ + 'name': "[100] Line 100", + 'tag_name': '100', + 'report_id': cls.tax_report_1.id, + 'sequence': 8, + }) + cls.tax_report_2 = cls.env['account.tax.report'].create({ 'name': "Tax report 2", 'country_id': cls.test_country_1.id, @@ -93,6 +100,13 @@ class TaxReportTest(AccountTestInvoicingCommon): 'sequence': 3, }) + cls.tax_report_line_2_6 = cls.env['account.tax.report.line'].create({ + 'name': "[100] Line 100", + 'tag_name': '100', + 'report_id': cls.tax_report_2.id, + 'sequence': 4, + }) + def _get_tax_tags(self, tag_name=None): domain = [('country_id', '=', self.test_country_1.id), ('applicability', '=', 'taxes')] if tag_name: @@ -255,3 +269,17 @@ class TaxReportTest(AccountTestInvoicingCommon): self.assertEqual(line.tag_ids.ids, original_report_2_tags[line.id], "The tax report lines not sharing their tags with any other report should keep the same tags when the country of their report is changed") elif line.tag_ids or original_report_2_tags[line.id]: self.assertNotEqual(line.tag_ids.ids, original_report_2_tags[line.id], "The tax report lines sharing their tags with other report should receive new tags when the country of their report is changed") + + def test_unlink_report_line_tags(self): + """ Under certain circumstances, unlinking a tax report line should also unlink + the tags that are linked to it. We test those cases here. + """ + def check_tags_unlink(tag_name, report_lines, unlinked, error_message): + report_lines.unlink() + surviving_tags = self._get_tax_tags(tag_name) + required_len = 0 if unlinked else 2 # 2 for + and - tag + self.assertEqual(len(surviving_tags), required_len, error_message) + + check_tags_unlink('42', self.tax_report_line_2_42, True, "Unlinking one line not sharing its tags should also unlink them") + check_tags_unlink('01', self.tax_report_line_1_1, False, "Unlinking one line sharing its tags with others should keep the tags") + check_tags_unlink('100', self.tax_report_line_1_6 + self.tax_report_line_2_6, True, "Unlinkink all the lines sharing the same tags should also unlink them") diff --git a/addons/account/views/account_account_tag_views.xml b/addons/account/views/account_account_tag_views.xml index d1bc04041a..a34f99e134 100644 --- a/addons/account/views/account_account_tag_views.xml +++ b/addons/account/views/account_account_tag_views.xml @@ -13,9 +13,9 @@ - - - + + + diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index 7c55018a94..65a7176ee1 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -1186,12 +1186,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/account/views/account_payment_view.xml b/addons/account/views/account_payment_view.xml index d5e688f8f3..3ae67ec727 100644 --- a/addons/account/views/account_payment_view.xml +++ b/addons/account/views/account_payment_view.xml @@ -118,12 +118,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/account/wizard/account_payment_register.py b/addons/account/wizard/account_payment_register.py index dd1290b3aa..08f815d01a 100644 --- a/addons/account/wizard/account_payment_register.py +++ b/addons/account/wizard/account_payment_register.py @@ -128,7 +128,7 @@ class AccountPaymentRegister(models.TransientModel): 'partner_id': line.partner_id.id, 'account_id': line.account_id.id, 'currency_id': (line.currency_id or line.company_currency_id).id, - 'partner_bank_id': line.move_id.partner_bank_id.id, + 'partner_bank_id': (line.move_id.partner_bank_id or line.partner_id.commercial_partner_id.bank_ids[:1]).id, 'partner_type': 'customer' if line.account_internal_type == 'receivable' else 'supplier', 'payment_type': 'inbound' if line.balance > 0.0 else 'outbound', } diff --git a/addons/account_edi/models/account_edi_format.py b/addons/account_edi/models/account_edi_format.py index f03c17aecb..223aabb0fc 100644 --- a/addons/account_edi/models/account_edi_format.py +++ b/addons/account_edi/models/account_edi_format.py @@ -5,6 +5,7 @@ from flectra import models, fields, api from flectra.exceptions import UserError from flectra.tools.pdf import FlectraPdfFileReader, FlectraPdfFileWriter from flectra.osv import expression +from flectra.tools import html_escape from lxml import etree import base64 @@ -499,5 +500,5 @@ class AccountEdiFormat(models.Model): @api.model def _format_error_message(self, error_title, errors): - bullet_list_msg = ''.join('
  • %s
  • ' % msg for msg in errors) + bullet_list_msg = ''.join('
  • %s
  • ' % html_escape(msg) for msg in errors) return '%s
      %s
    ' % (error_title, bullet_list_msg) diff --git a/addons/account_edi_facturx/models/account_edi_format.py b/addons/account_edi_facturx/models/account_edi_format.py index 4693a2dc02..741861bfea 100644 --- a/addons/account_edi_facturx/models/account_edi_format.py +++ b/addons/account_edi_facturx/models/account_edi_format.py @@ -192,17 +192,19 @@ class AccountEdiFormat(models.Model): account_predictive_bills_disable_prediction=True)) as invoice_form: # Partner (first step to avoid warning 'Warning! You must first select a partner.'). partner_type = invoice_form.journal_id.type == 'purchase' and 'SellerTradeParty' or 'BuyerTradeParty' - elements = tree.xpath('//ram:' + partner_type + '/ram:SpecifiedTaxRegistration/ram:ID', namespaces=tree.nsmap) - partner = elements and self.env['res.partner'].search([('vat', '=', elements[0].text)], limit=1) - if not partner: - elements = tree.xpath('//ram:' + partner_type + '/ram:Name', namespaces=tree.nsmap) - partner_name = elements and elements[0].text - partner = elements and self.env['res.partner'].search([('name', 'ilike', partner_name)], limit=1) - if not partner: - elements = tree.xpath('//ram:' + partner_type + '//ram:URIID[@schemeID=\'SMTP\']', namespaces=tree.nsmap) - partner = elements and self.env['res.partner'].search([('email', '=', elements[0].text)], limit=1) - if partner: - invoice_form.partner_id = partner + invoice_form.partner_id = self._retrieve_partner( + name=self._find_value('//ram:' + partner_type + '/ram:Name', tree, namespaces=tree.nsmap), + mail=self._find_value('//ram:' + partner_type + '//ram:URIID[@schemeID=\'SMTP\']', tree, namespaces=tree.nsmap), + vat=self._find_value('//ram:' + partner_type + '/ram:SpecifiedTaxRegistration/ram:ID', tree, namespaces=tree.nsmap), + ) + + # Delivery partner + if 'partner_shipping_id' in invoice._fields: + invoice_form.partner_shipping_id = self._retrieve_partner( + name=self._find_value('//ram:ShipToTradeParty/ram:Name', tree, namespaces=tree.nsmap), + mail=self._find_value('//ram:ShipToTradeParty//ram:URIID[@schemeID=\'SMTP\']', tree, namespaces=tree.nsmap), + vat=self._find_value('//ram:ShipToTradeParty/ram:SpecifiedTaxRegistration/ram:ID', tree, namespaces=tree.nsmap), + ) # Reference. elements = tree.xpath('//rsm:ExchangedDocument/ram:ID', namespaces=tree.nsmap) diff --git a/addons/base_vat/models/res_partner.py b/addons/base_vat/models/res_partner.py index debca5773c..981cb01ca3 100644 --- a/addons/base_vat/models/res_partner.py +++ b/addons/base_vat/models/res_partner.py @@ -312,7 +312,11 @@ class ResPartner(models.Model): vat = clean(vat, ' -.').upper().strip() - if not (len(vat) == 14): + # Remove the prefix + if vat.startswith("NL"): + vat = vat[2:] + + if not len(vat) == 12: return False # Check the format @@ -325,9 +329,6 @@ class ResPartner(models.Model): char_to_int['+'] = '36' char_to_int['*'] = '37' - # Remove the prefix - vat = vat[2:] - # 2 possible checks: # - For natural persons # - For non-natural persons and combinations of natural persons (company) diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 8cce315d31..86637a2c93 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -348,40 +348,24 @@ class Lead(models.Model): @api.depends('partner_id.email') def _compute_email_from(self): for lead in self: - if lead.partner_id.email and lead.partner_id.email != lead.email_from: + if lead.partner_id.email and lead._get_partner_email_update(): lead.email_from = lead.partner_id.email def _inverse_email_from(self): for lead in self: - if lead.partner_id and lead.email_from != lead.partner_id.email: - # force reset - if not lead.email_from or not lead.partner_id.email: - lead.partner_id.email = lead.email_from - # compare formatted values as we may have formatting differences between equivalent email - else: - lead_email_normalized = tools.email_normalize(lead.email_from) - partner_email_normalized = tools.email_normalize(lead.partner_id.email) - if lead_email_normalized != partner_email_normalized: - lead.partner_id.email = lead.email_from + if lead._get_partner_email_update(): + lead.partner_id.email = lead.email_from @api.depends('partner_id.phone') def _compute_phone(self): for lead in self: - if lead.partner_id.phone and lead.phone != lead.partner_id.phone: + if lead.partner_id.phone and lead._get_partner_phone_update(): lead.phone = lead.partner_id.phone def _inverse_phone(self): for lead in self: - if lead.partner_id and lead.phone != lead.partner_id.phone: - # force reset - if not lead.phone or not lead.partner_id.phone: - lead.partner_id.phone = lead.phone - # compare formatted values as we may have encoding differences between equivalent numbers - else: - lead_phone_formatted = lead.phone_format(lead.phone) - partner_phone_formatted = lead.phone_format(lead.partner_id.phone) - if lead_phone_formatted != partner_phone_formatted: - lead.partner_id.phone = lead.phone + if lead._get_partner_phone_update(): + lead.partner_id.phone = lead.phone @api.depends('phone', 'country_id.code') def _compute_phone_state(self): @@ -454,21 +438,8 @@ class Lead(models.Model): @api.depends('email_from', 'phone', 'partner_id') def _compute_ribbon_message(self): for lead in self: - # beware: void user input gives '' which is different from False - lead_email_normalized = tools.email_normalize(lead.email_from) or (lead.email_from if lead.email_from else False) - partner_email_normalized = tools.email_normalize(lead.partner_id.email) or lead.partner_id.email - will_write_email = lead_email_normalized != partner_email_normalized if lead.partner_id else False - will_write_phone = False - if lead.partner_id and lead.phone != lead.partner_id.phone: - # if reset -> obviously new value will be propagated - if not lead.phone or not lead.partner_id.phone: - will_write_phone = True - # otherwise compare formatted values as we may have encoding differences - else: - lead_phone_formatted = lead.phone_format(lead.phone) - partner_phone_formatted = lead.phone_format(lead.partner_id.phone) - if lead_phone_formatted != partner_phone_formatted: - will_write_phone = True + will_write_email = lead._get_partner_email_update() + will_write_phone = lead._get_partner_phone_update() if will_write_email and will_write_phone: lead.ribbon_message = _('By saving this change, the customer email and phone number will also be updated.') @@ -550,6 +521,36 @@ class Lead(models.Model): partner_name = partner.name return {'partner_name': partner_name or self.partner_name} + def _get_partner_email_update(self): + """Calculate if we should write the email on the related partner. When + the email of the lead / partner is an empty string, we force it to False + to not propagate a False on an empty string. + + Done in a separate method so it can be used in both ribbon and inverse + and compute of email update methods. + """ + self.ensure_one() + if self.partner_id and self.email_from != self.partner_id.email: + lead_email_normalized = tools.email_normalize(self.email_from) or self.email_from or False + partner_email_normalized = tools.email_normalize(self.partner_id.email) or self.partner_id.email or False + return lead_email_normalized != partner_email_normalized + return False + + def _get_partner_phone_update(self): + """Calculate if we should write the phone on the related partner. When + the phone of the lead / partner is an empty string, we force it to False + to not propagate a False on an empty string. + + Done in a separate method so it can be used in both ribbon and inverse + and compute of phone update methods. + """ + self.ensure_one() + if self.partner_id and self.phone != self.partner_id.phone: + lead_phone_formatted = self.phone_format(self.phone) if self.phone else False or self.phone or False + partner_phone_formatted = self.phone_format(self.partner_id.phone) if self.partner_id.phone else False or self.partner_id.phone or False + return lead_phone_formatted != partner_phone_formatted + return False + # ------------------------------------------------------------ # ORM # ------------------------------------------------------------ diff --git a/addons/crm/tests/test_crm_lead.py b/addons/crm/tests/test_crm_lead.py index eb5a3de83f..1e8050a20f 100644 --- a/addons/crm/tests/test_crm_lead.py +++ b/addons/crm/tests/test_crm_lead.py @@ -302,6 +302,62 @@ class TestCRMLead(TestCrmCommon): self.assertEqual(partner.phone_sanitized, partner_mobile_sanitized, 'Partner sanitized should be computed on mobile') + @users('user_sales_manager') + def test_crm_lead_partner_sync_email_phone_corner_cases(self): + """ Test corner cases of email and phone sync (False versus '', formatting + differences, wrong input, ...) """ + test_email = 'amy.wong@test.example.com' + lead = self.lead_1.with_user(self.env.user) + contact = self.env['res.partner'].create({ + 'name': 'NoContact Partner', + 'phone': '', + 'email': '', + 'mobile': '', + }) + + lead_form = Form(lead) + self.assertEqual(lead_form.email_from, test_email) + self.assertFalse(lead_form.ribbon_message) + + # email: False versus empty string + lead_form.partner_id = contact + self.assertIn('the customer email', lead_form.ribbon_message) + lead_form.email_from = '' + self.assertFalse(lead_form.ribbon_message) + lead_form.email_from = False + self.assertFalse(lead_form.ribbon_message) + + # phone: False versus empty string + lead_form.phone = '+1 202-555-0888' + self.assertIn('the customer phone', lead_form.ribbon_message) + lead_form.phone = '' + self.assertFalse(lead_form.ribbon_message) + lead_form.phone = False + self.assertFalse(lead_form.ribbon_message) + + # email/phone: formatting should not trigger ribbon + lead.write({ + 'email_from': '"My Name" <%s>' % test_email, + 'phone': '+1 202-555-0888', + }) + contact.write({ + 'email': '"My Name" <%s>' % test_email, + 'phone': '+1 202-555-0888', + }) + + lead_form = Form(lead) + self.assertFalse(lead_form.ribbon_message) + lead_form.partner_id = contact + self.assertFalse(lead_form.ribbon_message) + lead_form.email_from = '"Another Name" <%s>' % test_email # same email normalized + self.assertFalse(lead_form.ribbon_message, 'Formatting-only change should not trigger write') + lead_form.phone = '2025550888' # same number but another format + self.assertFalse(lead_form.ribbon_message, 'Formatting-only change should not trigger write') + + # wrong value are also propagated + lead_form.phone = '666 789456789456789456' + self.assertIn('the customer phone', lead_form.ribbon_message) + @users('user_sales_manager') def test_crm_lead_stages(self): lead = self.lead_1.with_user(self.env.user) diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index c415ff4220..c17c37d765 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -734,16 +734,16 @@ to 0. but this approach is not working. the work around is temporary --> + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> @@ -705,12 +705,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/fleet/views/fleet_vehicle_cost_views.xml b/addons/fleet/views/fleet_vehicle_cost_views.xml index 00698dd232..4bc78aa9b2 100644 --- a/addons/fleet/views/fleet_vehicle_cost_views.xml +++ b/addons/fleet/views/fleet_vehicle_cost_views.xml @@ -145,12 +145,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/fleet/views/fleet_vehicle_views.xml b/addons/fleet/views/fleet_vehicle_views.xml index 2a1668fac1..08581d0ddf 100644 --- a/addons/fleet/views/fleet_vehicle_views.xml +++ b/addons/fleet/views/fleet_vehicle_views.xml @@ -176,12 +176,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/hr/models/hr_employee_base.py b/addons/hr/models/hr_employee_base.py index e4a901807a..1bbadaaff6 100644 --- a/addons/hr/models/hr_employee_base.py +++ b/addons/hr/models/hr_employee_base.py @@ -158,7 +158,7 @@ class HrEmployeeBase(models.AbstractModel): def _get_employee_working_now(self): working_now = [] # We loop over all the employee tz and the resource calendar_id to detect working hours in batch. - all_employee_tz = self.mapped('tz') + all_employee_tz = set(self.mapped('tz')) for tz in all_employee_tz: employee_ids = self.filtered(lambda e: e.tz == tz) resource_calendar_ids = employee_ids.mapped('resource_calendar_id') diff --git a/addons/hr/views/hr_employee_views.xml b/addons/hr/views/hr_employee_views.xml index 1781ca807e..4df9e1359a 100644 --- a/addons/hr/views/hr_employee_views.xml +++ b/addons/hr/views/hr_employee_views.xml @@ -14,11 +14,11 @@ + domain="[('my_activity_date_deadline', '<', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/hr_contract/views/hr_contract_views.xml b/addons/hr_contract/views/hr_contract_views.xml index 8658e94436..343069752b 100644 --- a/addons/hr_contract/views/hr_contract_views.xml +++ b/addons/hr_contract/views/hr_contract_views.xml @@ -115,12 +115,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index fac37df204..58d5fa8621 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -401,16 +401,10 @@ Or send your receipts at + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> @@ -842,12 +842,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/hr_expense/wizard/account_payment_register.py b/addons/hr_expense/wizard/account_payment_register.py index b8e687e47a..15027041c8 100644 --- a/addons/hr_expense/wizard/account_payment_register.py +++ b/addons/hr_expense/wizard/account_payment_register.py @@ -9,6 +9,15 @@ class AccountPaymentRegister(models.TransientModel): # ------------------------------------------------------------------------- # BUSINESS METHODS # ------------------------------------------------------------------------- + + @api.model + def _get_line_batch_key(self, line): + # OVERRIDE to set the bank account defined on the employee + res = super()._get_line_batch_key(line) + expense_sheet = self.env['hr.expense.sheet'].search([('payment_mode', '=', 'own_account'), ('account_move_id', 'in', line.move_id.ids)]) + if expense_sheet.employee_id.bank_account_id and not line.move_id.partner_bank_id: + res['partner_bank_id'] = expense_sheet.employee_id.bank_account_id.id + return res def _create_payments(self): # OVERRIDE to set the 'done' state on expense sheets. diff --git a/addons/hr_holidays/views/hr_leave_allocation_views.xml b/addons/hr_holidays/views/hr_leave_allocation_views.xml index 3643e36693..33e4ac0766 100644 --- a/addons/hr_holidays/views/hr_leave_allocation_views.xml +++ b/addons/hr_holidays/views/hr_leave_allocation_views.xml @@ -29,12 +29,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/hr_holidays/views/hr_leave_views.xml b/addons/hr_holidays/views/hr_leave_views.xml index f62d9d898c..19d9bd7f16 100644 --- a/addons/hr_holidays/views/hr_leave_views.xml +++ b/addons/hr_holidays/views/hr_leave_views.xml @@ -70,12 +70,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/hr_recruitment/data/digest_data.xml b/addons/hr_recruitment/data/digest_data.xml index 2eff95bf35..0a5a800b4d 100644 --- a/addons/hr_recruitment/data/digest_data.xml +++ b/addons/hr_recruitment/data/digest_data.xml @@ -18,7 +18,7 @@ By setting an alias to a job position, emails sent to this address create applications automatically. You can even use multiple trackers to get statistics according to the source of the application: LinkedIn, Monster, Indeed, etc. % set record = object.env['hr.job'].search([('alias_name', '!=', False)], limit=1) % if record and record.alias_domain - Try sending an email + Try sending an email % endif

    diff --git a/addons/hr_recruitment/data/hr_recruitment_data.xml b/addons/hr_recruitment/data/hr_recruitment_data.xml index 031ab5802f..42016fd10a 100644 --- a/addons/hr_recruitment/data/hr_recruitment_data.xml +++ b/addons/hr_recruitment/data/hr_recruitment_data.xml @@ -85,7 +85,7 @@ % if 'website_url' in object.job_id and object.job_id.website_url:
    Job Description + style="background-color: #875a7b; text-decoration: none; color: #fff; padding: 8px 16px 8px 16px; border-radius: 5px;">Job Description
    % endif @@ -185,7 +185,7 @@ % if 'website_url' in object.job_id and object.job_id.website_url:
    Job Description + style="background-color: #875a7b; text-decoration: none; color: #fff; padding: 8px 16px 8px 16px; border-radius: 5px;">Job Description
    % endif diff --git a/addons/hr_recruitment/views/hr_recruitment_views.xml b/addons/hr_recruitment/views/hr_recruitment_views.xml index 2f5ceca089..aa2a7a6389 100644 --- a/addons/hr_recruitment/views/hr_recruitment_views.xml +++ b/addons/hr_recruitment/views/hr_recruitment_views.xml @@ -212,12 +212,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/hw_drivers/tools/helpers.py b/addons/hw_drivers/tools/helpers.py index ccf6fbde6d..17012e0575 100644 --- a/addons/hw_drivers/tools/helpers.py +++ b/addons/hw_drivers/tools/helpers.py @@ -146,7 +146,7 @@ def get_mac_address(): return netifaces.ifaddresses('wlan0')[netifaces.AF_LINK][0]['addr'] def get_ssid(): - ap = subprocess.call(['systemctl', 'is-active', 'hostapd']) # if service is active return 0 else inactive + ap = subprocess.call(['systemctl', 'is-active', '--quiet', 'hostapd']) # if service is active return 0 else inactive if not ap: return subprocess.check_output(['grep', '-oP', '(?<=ssid=).*', '/etc/hostapd/hostapd.conf']).decode('utf-8').rstrip() process_iwconfig = subprocess.Popen(['iwconfig'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) @@ -154,7 +154,7 @@ def get_ssid(): return subprocess.check_output(['sed', 's/.*"\\(.*\\)"/\\1/'], stdin=process_grep.stdout).decode('utf-8').rstrip() def get_flectra_server_url(): - ap = subprocess.call(['systemctl', 'is-active', 'hostapd']) # if service is active return 0 else inactive + ap = subprocess.call(['systemctl', 'is-active', '--quiet', 'hostapd']) # if service is active return 0 else inactive if not ap: return False return read_file_first_line('flectra-remote-server.conf') diff --git a/addons/l10n_it_edi/data/account_invoice_demo.xml b/addons/l10n_it_edi/data/account_invoice_demo.xml index a17c95a7b6..d083923584 100644 --- a/addons/l10n_it_edi/data/account_invoice_demo.xml +++ b/addons/l10n_it_edi/data/account_invoice_demo.xml @@ -3,14 +3,14 @@ - 0123456789987654 + 09814700101 RF01 12345 Palazzo dell'Arte - IT12345670124 + IT00000010215 Piazza Marconi 5 Cremona @@ -21,10 +21,12 @@ - IT12345670231 + IT00079760328 + XS00001 - IT12345670348 + IT00140390501 + XS00001 diff --git a/addons/l10n_it_edi/data/invoice_it_template.xml b/addons/l10n_it_edi/data/invoice_it_template.xml index 05ade3cc70..c1867fafa4 100644 --- a/addons/l10n_it_edi/data/invoice_it_template.xml +++ b/addons/l10n_it_edi/data/invoice_it_template.xml @@ -52,7 +52,7 @@ - + @@ -72,7 +72,7 @@ - + diff --git a/addons/l10n_it_edi/models/account_edi_format.py b/addons/l10n_it_edi/models/account_edi_format.py index fc6515bc79..64906e9c1c 100644 --- a/addons/l10n_it_edi/models/account_edi_format.py +++ b/addons/l10n_it_edi/models/account_edi_format.py @@ -20,6 +20,96 @@ DEFAULT_FACTUR_ITALIAN_DATE_FORMAT = '%Y-%m-%d' class AccountEdiFormat(models.Model): _inherit = 'account.edi.format' + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + @api.model + def _l10n_it_edi_generate_electronic_invoice_filename(self, invoice): + '''Returns a name conform to the Fattura pa Specifications: + See ES documentation 2.2 + ''' + a = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + n = invoice.id + progressive_number = "" + while n: + (n, m) = divmod(n, len(a)) + progressive_number = a[m] + progressive_number + + return '%(country_code)s%(codice)s_%(progressive_number)s.xml' % { + 'country_code': invoice.company_id.country_id.code, + 'codice': invoice.company_id.l10n_it_codice_fiscale.replace(' ', ''), + 'progressive_number': progressive_number.zfill(5), + } + + def _l10n_it_edi_check_invoice_configuration(self, invoice): + errors = [] + seller = invoice.company_id + buyer = invoice.commercial_partner_id + + # <1.1.1.1> + if not seller.country_id: + errors.append(_("%s must have a country", seller.display_name)) + + # <1.1.1.2> + if not seller.vat: + errors.append(_("%s must have a VAT number", seller.display_name)) + elif len(seller.vat) > 30: + errors.append(_("The maximum length for VAT number is 30. %s have a VAT number too long: %s.", seller.display_name, seller.vat)) + + # <1.2.1.2> + if not seller.l10n_it_codice_fiscale: + errors.append(_("%s must have a codice fiscale number", seller.display_name)) + + # <1.2.1.8> + if not seller.l10n_it_tax_system: + errors.append(_("The seller's company must have a tax system.")) + + # <1.2.2> + if not seller.street and not seller.street2: + errors.append(_("%s must have a street.", seller.display_name)) + if not seller.zip: + errors.append(_("%s must have a post code.", seller.display_name)) + elif len(seller.zip) != 5 and seller.country_id.code == 'IT': + errors.append(_("%s must have a post code of length 5.", seller.display_name)) + if not seller.city: + errors.append(_("%s must have a city.", seller.display_name)) + if not seller.country_id: + errors.append(_("%s must have a country.", seller.display_name)) + + if seller.l10n_it_has_tax_representative and not seller.l10n_it_tax_representative_partner_id.vat: + errors.append(_("Tax representative partner %s of %s must have a tax number.", seller.l10n_it_tax_representative_partner_id.display_name, seller.display_name)) + + # <1.4.1> + if not buyer.vat and not buyer.l10n_it_codice_fiscale and buyer.country_id.code == 'IT': + errors.append(_("The buyer, %s, or his company must have either a VAT number either a tax code (Codice Fiscale).", buyer.display_name)) + + # <1.4.2> + if not buyer.street and not buyer.street2: + errors.append(_("%s must have a street.", buyer.display_name)) + if not buyer.zip: + errors.append(_("%s must have a post code.", buyer.display_name)) + if len(buyer.zip) != 5 and buyer.country_id.code == 'IT': + errors.append(_("%s must have a post code of length 5.", buyer.display_name)) + if not buyer.city: + errors.append(_("%s must have a city.", buyer.display_name)) + if not buyer.country_id: + errors.append(_("%s must have a country.", buyer.display_name)) + + # <2.2.1> + for invoice_line in invoice.invoice_line_ids: + if not invoice_line.display_type and len(invoice_line.tax_ids) != 1: + raise UserError(_("You must select one and only one tax by line.")) + + for tax_line in invoice.line_ids.filtered(lambda line: line.tax_line_id): + if not tax_line.tax_line_id.l10n_it_kind_exoneration and tax_line.tax_line_id.amount == 0: + errors.append(_("%s has an amount of 0.0, you must indicate the kind of exoneration.", tax_line.name)) + + if not invoice.partner_bank_id: + errors.append(_("The seller must have a bank account.")) + + return errors + # ------------------------------------------------------------------------- # Export # ------------------------------------------------------------------------- @@ -36,25 +126,30 @@ class AccountEdiFormat(models.Model): return super()._is_compatible_with_journal(journal) return journal.type == 'sale' and journal.country_code == 'IT' + def _l10n_it_edi_is_required_for_invoice(self, invoice): + """ Is the edi required for this invoice based on the method (here: PEC mail) + Deprecated: in future release PEC mail will be removed. + TO OVERRIDE + """ + return invoice.is_sale_document() and invoice.l10n_it_send_state not in ('sent', 'delivered', 'delivered_accepted') and invoice.country_code == 'IT' + def _is_required_for_invoice(self, invoice): # OVERRIDE self.ensure_one() if self.code != 'fattura_pa': return super()._is_required_for_invoice(invoice) - # Determine on which invoices the Mexican CFDI must be generated. - return invoice.is_sale_document() and invoice.l10n_it_send_state not in ['sent', 'delivered', 'delivered_accepted'] and invoice.country_code == 'IT' - - def _post_invoice_edi(self, invoices, test_mode=False): - # OVERRIDE - self.ensure_one() - edi_result = super()._post_invoice_edi(invoices, test_mode=test_mode) - if self.code != 'fattura_pa': - return edi_result + return self._l10n_it_edi_is_required_for_invoice(invoice) + def _post_fattura_pa(self, invoices): + # TO OVERRIDE invoice = invoices # no batching ensure that we only have one invoice invoice.l10n_it_send_state = 'other' invoice._check_before_xml_exporting() + if invoice.l10n_it_einvoice_id and invoice.l10n_it_send_state not in ['invalid', 'to_send']: + return {'error': _("You can't regenerate an E-Invoice when the first one is sent and there are no errors")} + if invoice.l10n_it_einvoice_id: + invoice.l10n_it_einvoice_id.unlink() res = invoice.invoice_generate_xml() if len(invoice.commercial_partner_id.l10n_it_pa_index or '') == 6: invoice.message_post( @@ -64,6 +159,15 @@ class AccountEdiFormat(models.Model): invoice.l10n_it_send_state = 'to_send' return {invoice: res} + def _post_invoice_edi(self, invoices, test_mode=False): + # OVERRIDE + self.ensure_one() + edi_result = super()._post_invoice_edi(invoices) + if self.code != 'fattura_pa': + return edi_result + + return self._post_fattura_pa(invoices) + # ------------------------------------------------------------------------- # Import # ------------------------------------------------------------------------- @@ -109,6 +213,18 @@ class AccountEdiFormat(models.Model): invoice = self.env['account.move'] first_run = False + # Type must be present in the context to get the right behavior of the _default_journal method (account.move). + # journal_id must be present in the context to get the right behavior of the _default_account method (account.move.line). + elements = tree.xpath('//CessionarioCommittente//IdCodice') + company = elements and self.env['res.company'].search([('vat', 'ilike', elements[0].text)], limit=1) + if not company: + elements = tree.xpath('//CessionarioCommittente//CodiceFiscale') + company = elements and self.env['res.company'].search([('l10n_it_codice_fiscale', 'ilike', elements[0].text)], limit=1) + if not company: + # Only invoices with a correct VAT or Codice Fiscale can be imported + _logger.warning('No company found with VAT or Codice Fiscale like %r.', elements[0].text) + continue + # Refund type. # TD01 == invoice # TD02 == advance/down payment on invoice @@ -123,32 +239,14 @@ class AccountEdiFormat(models.Model): move_type = 'in_refund' elif elements and elements[0].text and elements[0].text != 'TD01': _logger.info('Document type not managed: %s. Invoice type is set by default.', elements[0].text) - invoice_ctx = invoice.with_context(default_move_type=move_type) - # type must be present in the context to get the right behavior of the _default_journal method (account.move). - # journal_id must be present in the context to get the right behavior of the _default_account method (account.move.line). - - elements = tree.xpath('//CessionarioCommittente//IdCodice') - company = elements and self.env['res.company'].search([('vat', 'ilike', elements[0].text)], limit=1) - if not company: - elements = tree.xpath('//CessionarioCommittente//CodiceFiscale') - company = elements and self.env['res.company'].search([('l10n_it_codice_fiscale', 'ilike', elements[0].text)], limit=1) - - if company: - invoice_ctx = invoice_ctx.with_context(company_id=company.id) - else: - company = self.env.company - if elements: - _logger.info('No company found with codice fiscale: %s. The user\'s company is set by default.', elements[0].text) - else: - _logger.info('Company not found. The user\'s company is set by default.') - - if not self.env.is_superuser(): - if self.env.company != company: - raise UserError(_("You can only import invoice concern your current company: %s", self.env.company.display_name)) + # Setup the context for the Invoice Form + invoice_ctx = invoice.with_company(company) \ + .with_context(default_move_type=move_type, + account_predictive_bills_disable_prediction=True) # move could be a single record (editing) or be empty (new). - with Form(invoice_ctx.with_context(account_predictive_bills_disable_prediction=True)) as invoice_form: + with Form(invoice_ctx) as invoice_form: message_to_log = [] # Partner (first step to avoid warning 'Warning! You must first select a partner.'). <1.2> @@ -190,7 +288,7 @@ class AccountEdiFormat(models.Model): if elements: date_str = elements[0].text date_obj = datetime.strptime(date_str, DEFAULT_FACTUR_ITALIAN_DATE_FORMAT) - invoice_form.invoice_date = date_obj.strftime(DEFAULT_FACTUR_ITALIAN_DATE_FORMAT) + invoice_form.invoice_date = date_obj # Dati Bollo. <2.1.1.6> elements = body_tree.xpath('.//DatiGeneraliDocumento/DatiBollo/ImportoBollo') @@ -295,7 +393,7 @@ class AccountEdiFormat(models.Model): if elements: message_to_log.append("%s
    %s" % ( _("Bank account not found, useful informations from XML file:"), - self._compose_info_message(body_tree, './/DatiPagamento'))) + invoice._compose_info_message(body_tree, './/DatiPagamento'))) # Invoice lines. <2.2.1> elements = body_tree.xpath('.//DettaglioLinee') diff --git a/addons/l10n_it_edi/models/account_invoice.py b/addons/l10n_it_edi/models/account_invoice.py index 1bcad97f11..df0449575f 100644 --- a/addons/l10n_it_edi/models/account_invoice.py +++ b/addons/l10n_it_edi/models/account_invoice.py @@ -56,87 +56,14 @@ class AccountMove(models.Model): invoice.l10n_it_einvoice_name = einvoice.attachment_id.name def _check_before_xml_exporting(self): - self.ensure_one() - seller = self.company_id - buyer = self.commercial_partner_id - - # <1.1.1.1> - if not seller.country_id: - raise UserError(_("%s must have a country") % (seller.display_name)) - - # <1.1.1.2> - if not seller.vat: - raise UserError(_("%s must have a VAT number") % (seller.display_name)) - elif len(seller.vat) > 30: - raise UserError(_("The maximum length for VAT number is 30. %s have a VAT number too long: %s.") % (seller.display_name, seller.vat)) - - # <1.2.1.2> - if not seller.l10n_it_codice_fiscale: - raise UserError(_("%s must have a codice fiscale number") % (seller.display_name)) - - # <1.2.1.8> - if not seller.l10n_it_tax_system: - raise UserError(_("The seller's company must have a tax system.")) - - # <1.2.2> - if not seller.street and not seller.street2: - raise UserError(_("%s must have a street.") % (seller.display_name)) - if not seller.zip: - raise UserError(_("%s must have a post code.") % (seller.display_name)) - if len(seller.zip) != 5 and seller.country_id.code == 'IT': - raise UserError(_("%s must have a post code of length 5.") % (seller.display_name)) - if not seller.city: - raise UserError(_("%s must have a city.") % (seller.display_name)) - if not seller.country_id: - raise UserError(_("%s must have a country.") % (seller.display_name)) - - if seller.l10n_it_has_tax_representative and not seller.l10n_it_tax_representative_partner_id.vat: - raise UserError(_("Tax representative partner %s of %s must have a tax number.") % (seller.l10n_it_tax_representative_partner_id.display_name, seller.display_name)) - - # <1.4.1> - if not buyer.vat and not buyer.l10n_it_codice_fiscale and buyer.country_id.code == 'IT': - raise UserError(_("The buyer, %s, or his company must have either a VAT number either a tax code (Codice Fiscale).") % (buyer.display_name)) - - # <1.4.2> - if not buyer.street and not buyer.street2: - raise UserError(_("%s must have a street.") % (buyer.display_name)) - if not buyer.zip: - raise UserError(_("%s must have a post code.") % (buyer.display_name)) - if len(buyer.zip) != 5 and buyer.country_id.code == 'IT': - raise UserError(_("%s must have a post code of length 5.") % (buyer.display_name)) - if not buyer.city: - raise UserError(_("%s must have a city.") % (buyer.display_name)) - if not buyer.country_id: - raise UserError(_("%s must have a country.") % (buyer.display_name)) - - # <2.2.1> - for invoice_line in self.invoice_line_ids: - if not invoice_line.display_type and len(invoice_line.tax_ids) != 1: - raise UserError(_("You must select one and only one tax by line.")) - - for tax_line in self.line_ids.filtered(lambda line: line.tax_line_id): - if not tax_line.tax_line_id.l10n_it_has_exoneration and tax_line.tax_line_id.amount == 0: - raise ValidationError(_("%s has an amount of 0.0, you must indicate the kind of exoneration.", tax_line.name)) + # DEPRECATED use AccountEdiFormat._l10n_it_edi_check_invoice_configuration instead + errors = self.env['account.edi.format']._l10n_it_edi_check_invoice_configuration(self) + if errors: + raise UserError(self.env['account.edi.format']._format_error_message(_("Invalid configuration:"), errors)) def invoice_generate_xml(self): self.ensure_one() - if self.l10n_it_einvoice_id and self.l10n_it_send_state not in ['invalid', 'to_send']: - return {'error': _("You can't regenerate an E-Invoice when the first one is sent and there are no errors")} - if self.l10n_it_einvoice_id: - self.l10n_it_einvoice_id.unlink() - - a = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" - n = self.id - progressive_number = "" - while n: - (n,m) = divmod(n,len(a)) - progressive_number = a[m] + progressive_number - - report_name = '%(country_code)s%(codice)s_%(progressive_number)s.xml' % { - 'country_code': self.company_id.country_id.code, - 'codice': self.company_id.l10n_it_codice_fiscale, - 'progressive_number': progressive_number.zfill(5), - } + report_name = self.env['account.edi.format']._l10n_it_edi_generate_electronic_invoice_filename(self) data = b"" + self._export_as_xml() description = _('Italian invoice: %s', self.move_type) @@ -155,9 +82,6 @@ class AccountMove(models.Model): return {'attachment': attachment} def _prepare_fatturapa_export_values(self): - ''' Create the xml file content. - :return: The XML content as str. - ''' self.ensure_one() def format_date(dt): @@ -252,6 +176,10 @@ class AccountMove(models.Model): return template_values def _export_as_xml(self): + '''DEPRECATED : this will be moved to AccountEdiFormat in a future version. + Create the xml file content. + :return: The XML content as str. + ''' template_values = self._prepare_fatturapa_export_values() content = self.env.ref('l10n_it_edi.account_invoice_it_FatturaPA_export')._render(template_values) return content diff --git a/addons/l10n_it_edi/models/ir_mail_server.py b/addons/l10n_it_edi/models/ir_mail_server.py index 5044c702c4..16d71ac456 100644 --- a/addons/l10n_it_edi/models/ir_mail_server.py +++ b/addons/l10n_it_edi/models/ir_mail_server.py @@ -135,7 +135,8 @@ class FetchmailServer(models.Model): # we have a receipt self._message_receipt_invoice(split_underscore[1], attachment) else: - match = re.search("([A-Z]{2}[A-Za-z0-9]{2,28}_[A-Za-z0-9]{0,5}.(xml.p7m|xml))", attachment.fname) + att_filename = attachment.fname + match = re.search("([A-Z]{2}[A-Za-z0-9]{2,28}_[A-Za-z0-9]{0,5}.(xml.p7m|xml))", att_filename) # If match, we have an invoice. if match: # If it's signed, the content has a bytes type and we just remove the signature's envelope @@ -143,12 +144,13 @@ class FetchmailServer(models.Model): att_content_data = remove_signature(attachment.content) # If the envelope cannot be removed, the remove_signature returns None, so we skip if not att_content_data: - _logger.warning("E-invoice couldn't be read: %s", attachment.fname) + _logger.warning("E-invoice couldn't be read: %s", att_filename) continue - # Otherwise, it should be an utf-8 encoded XML string + att_filename = att_filename.replace('.xml.p7m', '.xml') else: + # Otherwise, it should be an utf-8 encoded XML string att_content_data = attachment.content.encode() - self._create_invoice_from_mail(att_content_data, attachment.fname, from_address) + self._create_invoice_from_mail(att_content_data, att_filename, from_address) else: if split_underscore[1] == 'AT': # Attestazione di avvenuta trasmissione della fattura con impossibilità di recapito @@ -174,7 +176,7 @@ class FetchmailServer(models.Model): return invoices # Create the new attachment for the file - self.env['ir.attachment'].create({ + attachment = self.env['ir.attachment'].create({ 'name': att_name, 'raw': att_content_data, 'res_model': 'account.move', @@ -187,14 +189,16 @@ class FetchmailServer(models.Model): _logger.info('The xml file is badly formatted: %s', att_name) return invoices - # Create the invoice invoices = self.env.ref('l10n_it_edi.edi_fatturaPA')._create_invoice_from_xml_tree(att_name, tree) if not invoices: _logger.info('E-invoice not found in file: %s', att_name) return invoices - - invoices.l10n_it_send_state = 'new' + invoices.l10n_it_send_state = "new" invoices.invoice_source_email = from_address + for invoice in invoices: + invoice.with_context(no_new_invoice=True, default_res_id=invoice.id) \ + .message_post(body=(_("Original E-invoice XML file")), attachment_ids=[attachment.id]) + self._cr.commit() _logger.info('New E-invoices (%s), ids: %s', att_name, [x.id for x in invoices]) @@ -403,8 +407,10 @@ class FetchmailServer(models.Model): class IrMailServer(models.Model): _name = "ir.mail_server" _inherit = "ir.mail_server" + def _get_test_email_addresses(self): self.ensure_one() + company = self.env["res.company"].search([("l10n_it_mail_pec_server_id", "=", self.id)], limit=1) if not company: # it's not a PEC server diff --git a/addons/l10n_it_edi/tests/test_ir_mail_server.py b/addons/l10n_it_edi/tests/test_ir_mail_server.py index f93f14f654..10323bce76 100644 --- a/addons/l10n_it_edi/tests/test_ir_mail_server.py +++ b/addons/l10n_it_edi/tests/test_ir_mail_server.py @@ -5,6 +5,7 @@ import datetime import logging from collections import namedtuple from unittest.mock import patch +import freezegun from flectra import tools from flectra.addons.account_edi.tests.common import AccountEdiTestCommon @@ -44,8 +45,10 @@ class PecMailServerTests(AccountEdiTestCommon): super().setUpClass(chart_template_ref='l10n_it.l10n_it_chart_template_generic', edi_format_ref='l10n_it_edi.edi_fatturaPA') + # Use the company_data_2 to test that the e-invoice is imported for the right company + cls.company = cls.company_data_2['company'] + # Initialize the company's codice fiscale - cls.company = cls.company_data['company'] cls.company.l10n_it_codice_fiscale = 'IT01234560157' # Build test data. @@ -106,12 +109,14 @@ class PecMailServerTests(AccountEdiTestCommon): def test_receive_signed_vendor_bill(self): """ Test a signed (P7M) sample e-invoice file from https://www.fatturapa.gov.it/export/documenti/fatturapa/v1.2/IT01234567890_FPR01.xml """ - invoices = self._create_invoice(self.signed_invoice_content, self.signed_invoice_filename) - self.assertRecordValues(invoices, [{ - 'name': 'BILL/2014/12/0001', - 'date': datetime.date(2014, 12, 18), - 'ref': '01234567890', - }]) + with freezegun.freeze_time('2020-04-06'): + invoices = self._create_invoice(self.signed_invoice_content, self.signed_invoice_filename) + self.assertRecordValues(invoices, [{ + 'company_id': self.company.id, + 'name': 'BILL/2020/04/0001', + 'invoice_date': datetime.date(2014, 12, 18), + 'ref': '01234567890', + }]) def test_receive_same_vendor_bill_twice(self): """ Test that the second time we are receiving a PEC mail with the same attachment, the second is discarded """ diff --git a/addons/link_tracker/models/link_tracker.py b/addons/link_tracker/models/link_tracker.py index 1808e6a705..d8353c447f 100644 --- a/addons/link_tracker/models/link_tracker.py +++ b/addons/link_tracker/models/link_tracker.py @@ -85,8 +85,11 @@ class LinkTracker(models.Model): for tracker in self: parsed = urls.url_parse(tracker.url) utms = {} - for key, field, cook in self.env['utm.mixin'].tracking_fields(): - attr = getattr(tracker, field).name + for key, field_name, cook in self.env['utm.mixin'].tracking_fields(): + field = self._fields[field_name] + attr = getattr(tracker, field_name) + if field.type == 'many2one': + attr = attr.name if attr: utms[key] = attr utms.update(parsed.decode_query()) diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py index 9426144df1..b064c60622 100644 --- a/addons/mail/models/mail_activity.py +++ b/addons/mail/models/mail_activity.py @@ -688,6 +688,10 @@ class MailActivityMixin(models.AbstractModel): compute='_compute_activity_date_deadline', search='_search_activity_date_deadline', compute_sudo=False, readonly=True, store=False, groups="base.group_user") + my_activity_date_deadline = fields.Date( + 'My Activity Deadline', + compute='_compute_my_activity_date_deadline', search='_search_my_activity_date_deadline', + compute_sudo=False, readonly=True, groups="base.group_user") activity_summary = fields.Char( 'Next Activity Summary', related='activity_ids.summary', readonly=False, @@ -757,6 +761,24 @@ class MailActivityMixin(models.AbstractModel): def _search_activity_summary(self, operator, operand): return [('activity_ids.summary', operator, operand)] + @api.depends('activity_ids.date_deadline', 'activity_ids.user_id') + @api.depends_context('uid') + def _compute_my_activity_date_deadline(self): + for record in self: + record.my_activity_date_deadline = next(( + activity.date_deadline + for activity in record.activity_ids + if activity.user_id.id == record.env.uid + ), False) + + def _search_my_activity_date_deadline(self, operator, operand): + activity_ids = self.env['mail.activity']._search([ + ('date_deadline', operator, operand), + ('res_model', '=', self._name), + ('user_id', '=', self.env.user.id) + ]) + return [('activity_ids', 'in', activity_ids)] + def write(self, vals): # Delete activities of archived record. if 'active' in vals and vals['active'] is False: diff --git a/addons/mail/static/src/components/message/message_tests.js b/addons/mail/static/src/components/message/message_tests.js index d1c9c96635..6b02b052f0 100644 --- a/addons/mail/static/src/components/message/message_tests.js +++ b/addons/mail/static/src/components/message/message_tests.js @@ -1502,6 +1502,77 @@ QUnit.test('rendering of tracked field of type many2one: from no related record ); }); +QUnit.test('message should not be considered as "clicked" after clicking on its author name', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "

    Test

    ", + id: 100, + }); + await this.createMessageComponent(message); + document.querySelector(`.o_Message_authorName`).click(); + await nextAnimationFrame(); + assert.doesNotHaveClass( + document.querySelector(`.o_Message`), + 'o-clicked', + "message should not be considered as 'clicked' after clicking on its author name" + ); +}); + +QUnit.test('message should not be considered as "clicked" after clicking on its author avatar', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "

    Test

    ", + id: 100, + }); + await this.createMessageComponent(message); + document.querySelector(`.o_Message_authorAvatar`).click(); + await nextAnimationFrame(); + assert.doesNotHaveClass( + document.querySelector(`.o_Message`), + 'o-clicked', + "message should not be considered as 'clicked' after clicking on its author avatar" + ); +}); + +QUnit.test('message should not be considered as "clicked" after clicking on notification failure icon', async function (assert) { + assert.expect(1); + + await this.start(); + const threadViewer = this.env.models['mail.thread_viewer'].create({ + hasThreadView: true, + thread: [['create', { + id: 11, + model: 'mail.channel', + }]], + }); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'email', + notifications: [['insert', { + id: 11, + notification_status: 'exception', + notification_type: 'email', + }]], + originThread: [['link', threadViewer.thread]], + }); + await this.createMessageComponent(message, { + threadViewLocalId: threadViewer.threadView.localId + }); + document.querySelector('.o_Message_notificationIconClickable.o-error').click(); + await nextAnimationFrame(); + assert.doesNotHaveClass( + document.querySelector(`.o_Message`), + 'o-clicked', + "message should not be considered as 'clicked' after clicking on notification failure icon" + ); +}); + }); }); }); diff --git a/addons/mail/views/res_partner_views.xml b/addons/mail/views/res_partner_views.xml index 3eb92fe74c..8f06ea6eb6 100644 --- a/addons/mail/views/res_partner_views.xml +++ b/addons/mail/views/res_partner_views.xml @@ -49,12 +49,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/maintenance/views/maintenance_views.xml b/addons/maintenance/views/maintenance_views.xml index ed0ccb1b42..32ffa58608 100644 --- a/addons/maintenance/views/maintenance_views.xml +++ b/addons/maintenance/views/maintenance_views.xml @@ -32,12 +32,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> @@ -518,12 +518,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/mass_mailing/data/mailing_data_templates.xml b/addons/mass_mailing/data/mailing_data_templates.xml index 83c45bc501..47c22b55fb 100644 --- a/addons/mass_mailing/data/mailing_data_templates.xml +++ b/addons/mass_mailing/data/mailing_data_templates.xml @@ -22,7 +22,7 @@ - + diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 5be04e4c85..50be63d963 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -1715,7 +1715,9 @@ class MrpProduction(models.Model): ]) if duplicates: # Maybe some move lines have been compensated by unbuild - duplicates_unbuild = self.env['stock.move.line'].search_count(domain_unbuild) + duplicates_unbuild = self.env['stock.move.line'].search_count(domain_unbuild + [ + ('move_id.unbuild_id', '!=', False) + ]) if not (duplicates_unbuild and duplicates - duplicates_unbuild == 0): raise UserError(message) # Check presence of same sn in current production @@ -1749,7 +1751,9 @@ class MrpProduction(models.Model): ]) if duplicates: # Maybe some move lines have been compensated by unbuild - duplicates_unbuild = self.env['stock.move.line'].search_count(domain_unbuild) + duplicates_unbuild = self.env['stock.move.line'].search_count(domain_unbuild + [ + ('move_id.unbuild_id', '!=', False) + ]) if not (duplicates_unbuild and duplicates - duplicates_unbuild == 0): raise UserError(message) # Check presence of same sn in current production diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index 1514b5af91..a1e3297b8d 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -133,7 +133,7 @@ class StockMove(models.Model): operation_domain = [ ('id', 'in', move.raw_material_production_id.bom_id.operation_ids.ids), '|', - ('company_id', '=', self.company_id.id), + ('company_id', '=', move.company_id.id), ('company_id', '=', False) ] move.allowed_operation_ids = self.env['mrp.routing.workcenter'].search(operation_domain) @@ -359,12 +359,14 @@ class StockMove(models.Model): def _prepare_merge_moves_distinct_fields(self): distinct_fields = super()._prepare_merge_moves_distinct_fields() distinct_fields.append('created_production_id') + distinct_fields.append('bom_line_id') return distinct_fields @api.model def _prepare_merge_move_sort_method(self, move): keys_sorted = super()._prepare_merge_move_sort_method(move) keys_sorted.append(move.created_production_id.id) + keys_sorted.append(move.bom_line_id.id) return keys_sorted def _compute_kit_quantities(self, product_id, kit_qty, kit_bom, filters): diff --git a/addons/mrp/views/mrp_bom_views.xml b/addons/mrp/views/mrp_bom_views.xml index 6d92097b3b..839a268a23 100644 --- a/addons/mrp/views/mrp_bom_views.xml +++ b/addons/mrp/views/mrp_bom_views.xml @@ -123,11 +123,11 @@ - - + + - + diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index 0bef59618b..b6acbd3233 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -483,12 +483,12 @@ domain="['|', ('delay_alert_date', '!=', False), '&', ('date_deadline', '<', current_date), ('state', '=', 'confirmed')]"/> + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/mrp_account/models/product.py b/addons/mrp_account/models/product.py index 95f682fd96..dcc4d8b77a 100644 --- a/addons/mrp_account/models/product.py +++ b/addons/mrp_account/models/product.py @@ -54,7 +54,7 @@ class ProductProduct(models.Model): bom_line = move.bom_line_id bom_line_data = bom_lines[bom_line] bom_line_qty = bom_line_data['qty'] - value += move.product_id._compute_average_price(qty_invoiced * bom_line_qty, qty_to_invoice * bom_line_qty, move) + value += bom_line_qty * move.product_id._compute_average_price(qty_invoiced * bom_line_qty, qty_to_invoice * bom_line_qty, move) return value def _compute_bom_price(self, bom, boms_to_recompute=False): diff --git a/addons/mrp_account/models/stock_move.py b/addons/mrp_account/models/stock_move.py index 54ce9ac0ca..e6487eb9f4 100644 --- a/addons/mrp_account/models/stock_move.py +++ b/addons/mrp_account/models/stock_move.py @@ -22,3 +22,8 @@ class StockMove(models.Model): return super()._get_dest_account(accounts_data) else: return self.location_id.valuation_in_account_id.id or accounts_data['stock_output'].id + + def _filter_anglo_saxon_moves(self, product): + res = super(StockMove, self)._filter_anglo_saxon_moves(product) + res += self.filtered(lambda m: m.bom_line_id.bom_id.product_tmpl_id.id == product.product_tmpl_id.id) + return res diff --git a/addons/note/views/note_views.xml b/addons/note/views/note_views.xml index 3e31080518..646e4800a0 100644 --- a/addons/note/views/note_views.xml +++ b/addons/note/views/note_views.xml @@ -179,12 +179,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/payment_ingenico/controllers/main.py b/addons/payment_ingenico/controllers/main.py index 9cc7df583f..86b55b23e5 100644 --- a/addons/payment_ingenico/controllers/main.py +++ b/addons/payment_ingenico/controllers/main.py @@ -23,7 +23,7 @@ class OgoneController(http.Controller): '/payment/ogone/decline', '/payment/ogone/test/decline', '/payment/ogone/exception', '/payment/ogone/test/exception', '/payment/ogone/cancel', '/payment/ogone/test/cancel', - ], type='http', auth='public', csrf=False, method=['GET', 'POST']) + ], type='http', auth='public', csrf=False) def ogone_form_feedback(self, **post): """ Handle both redirection from Ingenico (GET) and s2s notification (POST/GET) """ _logger.info('Ogone: entering form_feedback with post data %s', pprint.pformat(post)) # debug diff --git a/addons/point_of_sale/controllers/main.py b/addons/point_of_sale/controllers/main.py index 029f74e5cc..35dc9d66b4 100644 --- a/addons/point_of_sale/controllers/main.py +++ b/addons/point_of_sale/controllers/main.py @@ -57,7 +57,9 @@ class PosController(http.Controller): 'session_info': session_info, 'login_number': pos_session.login(), } - return request.render('point_of_sale.index', qcontext=context) + response = request.render('point_of_sale.index', context) + response.headers['Cache-Control'] = 'no-store' + return response @http.route('/pos/ui/tests', type='http', auth="user") def test_suite(self, mod=None, **kwargs): diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 07c934ad35..8d0da03ff6 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -198,7 +198,7 @@ class PosOrder(models.Model): def _get_pos_anglo_saxon_price_unit(self, product, partner_id, quantity): moves = self.filtered(lambda o: o.partner_id.id == partner_id)\ .mapped('picking_ids.move_lines')\ - .filtered(lambda m: m.product_id.id == product.id)\ + ._filter_anglo_saxon_moves(product)\ .sorted(lambda x: x.date) price_unit = product._compute_average_price(0, quantity, moves) return price_unit diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index 5ce946efdf..d408eccff5 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -259,6 +259,8 @@ class PosSession(models.Model): def action_pos_session_closing_control(self): self._check_pos_session_balance() for session in self: + if any(order.state == 'draft' for order in session.order_ids): + raise UserError(_("You cannot close the POS when orders are still in draft")) if session.state == 'closed': raise UserError(_('This session is already closed.')) session.write({'state': 'closing_control', 'stop_at': fields.Datetime.now()}) @@ -296,6 +298,7 @@ class PosSession(models.Model): def _validate_session(self): self.ensure_one() + sudo = self.user_has_groups('point_of_sale.group_pos_user') if self.order_ids or self.statement_ids.line_ids: self.cash_real_transaction = self.cash_register_total_entry_encoding self.cash_real_expected = self.cash_register_balance_end @@ -310,7 +313,7 @@ class PosSession(models.Model): try: self.with_company(self.company_id)._create_account_move() except AccessError as e: - if self.user_has_groups('point_of_sale.group_pos_user'): + if sudo: self.sudo().with_company(self.company_id)._create_account_move() else: raise e @@ -319,6 +322,10 @@ class PosSession(models.Model): self.env['pos.order'].search([('session_id', '=', self.id), ('state', '=', 'paid')]).write({'state': 'done'}) else: self.move_id.unlink() + elif not self.cash_register_id.difference: + cash_register = self.cash_register_id.sudo() if sudo else self.cash_register_id + cash_register.pos_session_id = False + cash_register.unlink() self.write({'state': 'closed'}) return { 'type': 'ir.actions.client', @@ -657,12 +664,13 @@ class PosSession(models.Model): invoice_receivable_vals[commercial_partner].append(self._get_invoice_receivable_vals(account_id, amounts['amount'], amounts['amount_converted'], partner=commercial_partner)) for commercial_partner, vals in invoice_receivable_vals.items(): account_id = commercial_partner.property_account_receivable_id.id - receivable_line = MoveLine.create(vals) - if (not receivable_line.reconciled): - if account_id not in invoice_receivable_lines: - invoice_receivable_lines[account_id] = receivable_line - else: - invoice_receivable_lines[account_id] |= receivable_line + receivable_lines = MoveLine.create(vals) + for receivable_line in receivable_lines: + if (not receivable_line.reconciled): + if account_id not in invoice_receivable_lines: + invoice_receivable_lines[account_id] = receivable_line + else: + invoice_receivable_lines[account_id] |= receivable_line data.update({'invoice_receivable_lines': invoice_receivable_lines}) return data diff --git a/addons/point_of_sale/models/stock_picking.py b/addons/point_of_sale/models/stock_picking.py index 04653bff89..63da33cd89 100644 --- a/addons/point_of_sale/models/stock_picking.py +++ b/addons/point_of_sale/models/stock_picking.py @@ -117,7 +117,7 @@ class StockPicking(models.Model): 'product_id': line.product_id.id, 'name': lot.lot_name, }) - quant = existing_lot.quant_ids.filtered(lambda q: q.quantity > 0.0 or q.location_id.parent_path.startswith(move.location_id.parent_path))[-1:] + quant = existing_lot.quant_ids.filtered(lambda q: q.quantity > 0.0 and q.location_id.parent_path.startswith(move.location_id.parent_path))[-1:] ml_vals.update({ 'lot_id': existing_lot.id, 'location_id': quant.location_id.id or move.location_id.id diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css index ef441395f8..118443c045 100644 --- a/addons/point_of_sale/static/src/css/pos.css +++ b/addons/point_of_sale/static/src/css/pos.css @@ -1696,6 +1696,9 @@ td { } @media print { + body { + background: white; + } body * { visibility: hidden; } @@ -1717,7 +1720,7 @@ td { margin-left: auto !important; margin-right: auto !important; border: none !important; - font-size: 13px !important; + font-size: 14px !important; width: 266px !important; } } @@ -2973,10 +2976,6 @@ td { color: white; } -.order-receipt-hidden { - display: none; -} - .order-receipt { color: white; font-size: medium; diff --git a/addons/point_of_sale/static/src/css/pos_receipts.css b/addons/point_of_sale/static/src/css/pos_receipts.css index 53731cc06e..b8ed9af277 100644 --- a/addons/point_of_sale/static/src/css/pos_receipts.css +++ b/addons/point_of_sale/static/src/css/pos_receipts.css @@ -7,7 +7,7 @@ top: 0; text-align: left; direction: ltr; - font-size: 28px; + font-size: 27px; color: #000000; } diff --git a/addons/point_of_sale/static/src/js/Chrome.js b/addons/point_of_sale/static/src/js/Chrome.js index 57c5bcb928..b4c3746bfc 100644 --- a/addons/point_of_sale/static/src/js/Chrome.js +++ b/addons/point_of_sale/static/src/js/Chrome.js @@ -222,7 +222,7 @@ flectra.define('point_of_sale.Chrome', function(require) { // 3. Save the screen to the order. // - This screen is shown when the order is selected. - if (!(component.prototype instanceof IndependentToOrderScreen)) { + if (!(component.prototype instanceof IndependentToOrderScreen) && name !== "ReprintReceiptScreen") { this._setScreenData(name, props); } } diff --git a/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js index a408f2c395..d0d43dda98 100644 --- a/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js +++ b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js @@ -39,13 +39,9 @@ flectra.define('point_of_sale.AbstractReceiptScreen', function (require) { return await this._printWeb(); } } - /** - * https://stackoverflow.com/questions/21285902/printing-a-part-of-webpage-with-javascript - */ async _printWeb() { try { - const isPrinted = document.execCommand('print', false, null); - if (!isPrinted) window.print(); + window.print(); return true; } catch (err) { await this.showPopup('ErrorPopup', { diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js index 411f88e605..6e1bc62c23 100644 --- a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js +++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js @@ -10,7 +10,11 @@ flectra.define('point_of_sale.ClientDetailsEdit', function(require) { constructor() { super(...arguments); this.intFields = ['country_id', 'state_id', 'property_product_pricelist']; - this.changes = {}; + const partner = this.props.partner; + this.changes = { + 'country_id': partner.country_id && partner.country_id[0], + 'state_id': partner.state_id && partner.state_id[0], + }; } mounted() { this.env.bus.on('save-customer', this, this.saveChanges); diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js index 1ae083dfdc..fc82bc5668 100644 --- a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js @@ -18,15 +18,7 @@ flectra.define('point_of_sale.ReprintReceiptButton', function (require) { const order = this.orderManagementContext.selectedOrder; if (!order) return; - if (this.env.pos.proxy.printer) { - const receiptHtml = this.props.hiddenOrderReceipt.el.outerHTML; - const printResult = await this.env.pos.proxy.printer.print_receipt(receiptHtml); - if (!printResult.successful) { - this.showTempScreen('ReprintReceiptScreen', { order: order }); - } - } else { - this.showTempScreen('ReprintReceiptScreen', { order: order }); - } + this.showScreen('ReprintReceiptScreen', { order: order }); } } ReprintReceiptButton.template = 'ReprintReceiptButton'; diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js index 72d2b6fd18..c524352ec8 100644 --- a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js @@ -1,7 +1,7 @@ flectra.define('point_of_sale.OrderManagementScreen', function (require) { 'use strict'; - const { useContext, useRef } = owl.hooks; + const { useContext } = owl.hooks; const { useListener } = require('web.custom_hooks'); const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin'); const NumberBuffer = require('point_of_sale.NumberBuffer'); @@ -27,7 +27,6 @@ flectra.define('point_of_sale.OrderManagementScreen', function (require) { OrderFetcher.setComponent(this); OrderFetcher.setConfigId(this.env.pos.config_id); this.orderManagementContext = useContext(contexts.orderManagement); - this.receiptRef = useRef('order-receipt'); } mounted() { OrderFetcher.on('update', this, this.render); @@ -77,9 +76,9 @@ flectra.define('point_of_sale.OrderManagementScreen', function (require) { OrderFetcher.fetch(); } _onClickOrder({ detail: clickedOrder }) { - if (!clickedOrder || clickedOrder.locked) { + let currentPOSOrder = this.env.pos.get_order(); + if (currentPOSOrder && (!clickedOrder || clickedOrder.locked)) { this.orderManagementContext.selectedOrder = clickedOrder; - let currentPOSOrder = this.env.pos.get_order(); if (clickedOrder.attributes.client){ currentPOSOrder.set_client(clickedOrder.attributes.client); } diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js index deb1183995..f12bb3634a 100644 --- a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js @@ -6,12 +6,21 @@ flectra.define('point_of_sale.ReprintReceiptScreen', function (require) { const ReprintReceiptScreen = (AbstractReceiptScreen) => { class ReprintReceiptScreen extends AbstractReceiptScreen { - confirm() { - this.props.resolve({ confirmed: true, payload: null }); - this.trigger('close-temp-screen'); + mounted() { + this.printReceipt(); } - tryReprint() { - this._printReceipt(); + confirm() { + this.showScreen('OrderManagementScreen'); + } + async printReceipt() { + if(this.env.pos.proxy.printer && this.env.pos.config.iface_print_skip_screen) { + let result = await this._printReceipt(); + if(result) + this.showScreen('OrderManagementScreen'); + } + } + async tryReprint() { + await this._printReceipt(); } } ReprintReceiptScreen.template = 'ReprintReceiptScreen'; diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js index eaaeb90a34..894c06265e 100644 --- a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js @@ -31,6 +31,7 @@ flectra.define('point_of_sale.PaymentScreen', function (require) { onChangeOrder(this._onPrevOrder, this._onNewOrder); useErrorHandlers(); this.payment_interface = null; + this.error = false; this.payment_methods_from_config = this.env.pos.payment_methods.filter(method => this.env.pos.config.payment_method_ids.includes(method.id)); } get currentOrder() { @@ -75,7 +76,7 @@ flectra.define('point_of_sale.PaymentScreen', function (require) { } _updateSelectedPaymentline() { if (this.paymentLines.every((line) => line.paid)) { - this.currentOrder.add_paymentline(this.env.pos.payment_methods[0]); + this.currentOrder.add_paymentline(this.payment_methods_from_config[0]); } if (!this.selectedPaymentLine) return; // do nothing if no selected payment line // disable changing amount on paymentlines with running or done payments on a payment terminal @@ -177,6 +178,8 @@ flectra.define('point_of_sale.PaymentScreen', function (require) { syncedOrderBackendIds = await this.env.pos.push_single_order(this.currentOrder); } } catch (error) { + if (error.code == 700) + this.error = true; if (error instanceof Error) { throw error; } else { @@ -217,7 +220,7 @@ flectra.define('point_of_sale.PaymentScreen', function (require) { } } get nextScreen() { - return 'ReceiptScreen'; + return !this.error? 'ReceiptScreen' : 'ProductScreen'; } async _isOrderValid(isForceValidate) { if (this.currentOrder.get_orderlines().length === 0) { diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js index 9b52155986..206a978359 100644 --- a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js @@ -63,6 +63,9 @@ flectra.define('point_of_sale.ReceiptScreen', function (require) { get nextScreen() { return { name: 'ProductScreen' }; } + whenClosing() { + this.orderDone(); + } /** * This function is called outside the rendering call stack. This way, * we don't block the displaying of ReceiptScreen when it is mounted; additionally, @@ -72,7 +75,7 @@ flectra.define('point_of_sale.ReceiptScreen', function (require) { if (this._shouldAutoPrint()) { await this.printReceipt(); if (this.currentOrder._printed && this._shouldCloseImmediately()) { - this.orderDone(); + this.whenClosing(); } } } diff --git a/addons/point_of_sale/static/src/js/custom_hooks.js b/addons/point_of_sale/static/src/js/custom_hooks.js index 79bffe3da3..1aef387520 100644 --- a/addons/point_of_sale/static/src/js/custom_hooks.js +++ b/addons/point_of_sale/static/src/js/custom_hooks.js @@ -38,6 +38,14 @@ flectra.define('point_of_sale.custom_hooks', function (require) { error.data.debug || this.env._t('The server encountered an error while receiving your order.'), }); + } else if (error.code === 700) { + // Fiscal module errors + await this.showPopup('ErrorPopup', { + title: this.env._t('Fiscal data module error'), + body: + error.data.error.status || + this.env._t('The fiscal data module encountered an error while receiving your order.'), + }); } else { // ??? await this.showPopup('ErrorPopup', { diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 84f675da52..b47a09d07b 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -2735,7 +2735,7 @@ exports.Order = Backbone.Model.extend({ amount_paid: this.get_total_paid() - this.get_change(), amount_total: this.get_total_with_tax(), amount_tax: this.get_total_tax(), - amount_return: this.amount_return ? this.amount_return : this.get_change(), + amount_return: this.get_change(), lines: orderLines, statement_ids: paymentLines, pos_session_id: this.pos_session_id, diff --git a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml index 94d7358e7f..8992e2c80e 100644 --- a/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml +++ b/addons/point_of_sale/static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml @@ -9,7 +9,7 @@
    - +
    @@ -26,9 +26,6 @@
    -
    - -
    diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml index e0d44c7616..e4ede63651 100644 --- a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml @@ -62,7 +62,7 @@
      - +
    • SN
    • diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml index 9950009ebf..1fbb7c1718 100644 --- a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml +++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml @@ -33,7 +33,7 @@ -
      +
      @@ -93,7 +93,7 @@
        - +
      • SN
      • diff --git a/addons/point_of_sale/tests/test_point_of_sale_flow.py b/addons/point_of_sale/tests/test_point_of_sale_flow.py index a11ad40aea..eb9750f91d 100644 --- a/addons/point_of_sale/tests/test_point_of_sale_flow.py +++ b/addons/point_of_sale/tests/test_point_of_sale_flow.py @@ -405,6 +405,74 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): # I close the session to generate the journal entries self.pos_config.current_session_id.action_pos_session_closing_control() + def test_order_to_picking02(self): + """ This test is similar to test_order_to_picking except that this time, the product is tracked and its + location is a sublocation of the main warehouse + """ + product = self.env['product.product'].create({ + 'name': 'SuperProduct', + 'type': 'product', + 'tracking': 'lot', + 'available_in_pos': True, + }) + wh_location = self.company_data['default_warehouse'].lot_stock_id + shelf1_location = self.env['stock.location'].create({ + 'name': 'shelf1', + 'usage': 'internal', + 'location_id': wh_location.id, + }) + lot = self.env['stock.production.lot'].create({ + 'name': 'SuperLot', + 'product_id': product.id, + 'company_id': self.env.company.id, + }) + qty = 2 + self.env['stock.quant']._update_available_quantity(product, shelf1_location, qty, lot_id=lot) + + self.pos_config.open_session_cb() + self.pos_config.current_session_id.update_stock_at_closing = False + + untax, atax = self.compute_tax(product, 1.15, 1) + + for i in range(qty): + pos_order = self.PosOrder.create({ + 'company_id': self.env.company.id, + 'session_id': self.pos_config.current_session_id.id, + 'pricelist_id': self.partner1.property_product_pricelist.id, + 'partner_id': self.partner1.id, + 'lines': [(0, 0, { + 'name': "OL/0001", + 'product_id': product.id, + 'price_unit': untax + atax, + 'discount': 0.0, + 'qty': 1.0, + 'tax_ids': [(6, 0, product.taxes_id.ids)], + 'price_subtotal': untax, + 'price_subtotal_incl': untax + atax, + 'pack_lot_ids': [[0, 0, {'lot_name': lot.name}]], + })], + 'amount_tax': atax, + 'amount_total': untax + atax, + 'amount_paid': 0, + 'amount_return': 0, + }) + + context_make_payment = { + "active_ids": [pos_order.id], + "active_id": pos_order.id, + } + pos_make_payment = self.PosMakePayment.with_context(context_make_payment).create({ + 'amount': untax + atax, + }) + context_payment = {'active_id': pos_order.id} + pos_make_payment.with_context(context_payment).check() + + self.assertEqual(pos_order.state, 'paid') + self.assertEqual(pos_order.picking_ids[0].move_line_ids.lot_id, lot) + self.assertEqual(pos_order.picking_ids[0].move_line_ids.location_id, shelf1_location) + + self.pos_config.current_session_id.action_pos_session_closing_control() + def test_order_to_invoice(self): self.pos_config.open_session_cb(check_coa=False) diff --git a/addons/portal_rating/controllers/portal_rating.py b/addons/portal_rating/controllers/portal_rating.py index 8c9821bf12..ca32db9bb5 100644 --- a/addons/portal_rating/controllers/portal_rating.py +++ b/addons/portal_rating/controllers/portal_rating.py @@ -7,7 +7,7 @@ from flectra.http import request class PortalRating(http.Controller): - @http.route(['/website/rating/comment'], type='json', auth="user", method=['POST'], website=True) + @http.route(['/website/rating/comment'], type='json', auth="user", methods=['POST'], website=True) def publish_rating_comment(self, rating_id, publisher_comment): rating = request.env['rating.rating'].search([('id', '=', int(rating_id))]) if not rating: diff --git a/addons/pos_restaurant/static/src/js/Screens/BillScreen.js b/addons/pos_restaurant/static/src/js/Screens/BillScreen.js index c2c71ff2d4..17bffd1357 100644 --- a/addons/pos_restaurant/static/src/js/Screens/BillScreen.js +++ b/addons/pos_restaurant/static/src/js/Screens/BillScreen.js @@ -10,6 +10,9 @@ flectra.define('pos_restaurant.BillScreen', function (require) { this.props.resolve({ confirmed: true, payload: null }); this.trigger('close-temp-screen'); } + whenClosing() { + this.confirm(); + } } BillScreen.template = 'BillScreen'; return BillScreen; diff --git a/addons/pos_restaurant/static/src/js/Screens/TipScreen.js b/addons/pos_restaurant/static/src/js/Screens/TipScreen.js index 7340b0f1f6..716746b0b1 100644 --- a/addons/pos_restaurant/static/src/js/Screens/TipScreen.js +++ b/addons/pos_restaurant/static/src/js/Screens/TipScreen.js @@ -136,8 +136,7 @@ flectra.define('pos_restaurant.TipScreen', function (require) { async _printWeb(receipt) { try { $(this.el).find('.pos-receipt-container').html(receipt); - const isPrinted = document.execCommand('print', false, null); - if (!isPrinted) window.print(); + window.print(); } catch (err) { await this.showPopup('ErrorPopup', { title: this.env._t('Printing is not supported on some browsers'), diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index af31a0f87e..e8ec0847e9 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -142,12 +142,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 4263ff79e8..63a5ebc77b 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -248,7 +248,9 @@ class PurchaseOrder(models.Model): def _track_subtype(self, init_values): self.ensure_one() if 'state' in init_values and self.state == 'purchase': - return self.env.ref('purchase.mt_rfq_approved') + if init_values['state'] == 'to approve': + return self.env.ref('purchase.mt_rfq_approved') + return self.env.ref('purchase.mt_rfq_confirmed') elif 'state' in init_values and self.state == 'to approve': return self.env.ref('purchase.mt_rfq_confirmed') elif 'state' in init_values and self.state == 'done': @@ -1045,8 +1047,9 @@ class PurchaseOrderLine(models.Model): # If not seller, use the standard price. It needs a proper currency conversion. if not seller: + po_line_uom = self.product_uom or self.product_id.uom_po_id price_unit = self.env['account.tax']._fix_tax_included_price_company( - self.product_id.uom_id._compute_price(self.product_id.standard_price, self.product_id.uom_po_id), + self.product_id.uom_id._compute_price(self.product_id.standard_price, po_line_uom), self.product_id.supplier_taxes_id, self.taxes_id, self.company_id, @@ -1059,9 +1062,6 @@ class PurchaseOrderLine(models.Model): self.date_order or fields.Date.today(), ) - if self.product_uom: - price_unit = self.product_id.uom_id._compute_price(price_unit, self.product_uom) - self.price_unit = price_unit return diff --git a/addons/purchase/tests/test_purchase.py b/addons/purchase/tests/test_purchase.py index 0b803ab525..4884db9bab 100644 --- a/addons/purchase/tests/test_purchase.py +++ b/addons/purchase/tests/test_purchase.py @@ -170,3 +170,36 @@ class TestPurchase(AccountTestInvoicingCommon): '

        partner_a modified receipt dates for the following products:

        \xa0 - product_a from 2020-06-06 to %s

        \xa0 - product_b from 2020-06-06 to %s

        ' % (fields.Date.today(), fields.Date.today()), activity.note, ) + + def test_with_different_uom(self): + """ This test ensures that the unit price is correctly computed""" + uom_units = self.env['ir.model.data'].xmlid_to_object('uom.product_uom_unit') + uom_dozens = self.env['ir.model.data'].xmlid_to_object('uom.product_uom_dozen') + uom_pairs = self.env['uom.uom'].create({ + 'name': 'Pairs', + 'category_id': uom_units.category_id.id, + 'uom_type': 'bigger', + 'factor_inv': 2, + 'rounding': 1, + }) + product_data = { + 'name': 'SuperProduct', + 'type': 'consu', + 'uom_id': uom_units.id, + 'uom_po_id': uom_pairs.id, + 'standard_price': 100 + } + product_01 = self.env['product.product'].create(product_data) + product_02 = self.env['product.product'].create(product_data) + + po_form = Form(self.env['purchase.order']) + po_form.partner_id = self.partner_a + with po_form.order_line.new() as po_line: + po_line.product_id = product_01 + with po_form.order_line.new() as po_line: + po_line.product_id = product_02 + po_line.product_uom = uom_dozens + po = po_form.save() + + self.assertEqual(po.order_line[0].price_unit, 200) + self.assertEqual(po.order_line[1].price_unit, 1200) diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index c8dff066d9..a35e70e55d 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -447,12 +447,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/purchase_requisition/views/purchase_requisition_views.xml b/addons/purchase_requisition/views/purchase_requisition_views.xml index 17b4e81ce8..31b9fc5433 100644 --- a/addons/purchase_requisition/views/purchase_requisition_views.xml +++ b/addons/purchase_requisition/views/purchase_requisition_views.xml @@ -259,12 +259,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/repair/views/repair_views.xml b/addons/repair/views/repair_views.xml index 61c920dd67..da52cd1b4e 100644 --- a/addons/repair/views/repair_views.xml +++ b/addons/repair/views/repair_views.xml @@ -269,12 +269,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/sale/models/account_move.py b/addons/sale/models/account_move.py index b84214210c..d70f76a0f9 100644 --- a/addons/sale/models/account_move.py +++ b/addons/sale/models/account_move.py @@ -140,7 +140,8 @@ class AccountMoveLine(models.Model): # create the sale lines in batch new_sale_lines = self.env['sale.order.line'].create(sale_line_values_to_create) - new_sale_lines._onchange_discount() + for sol in new_sale_lines: + sol._onchange_discount() # build result map by replacing index with newly created record of sale.order.line result = {} @@ -205,12 +206,16 @@ class AccountMoveLine(models.Model): amount = (self.credit or 0.0) - (self.debit or 0.0) if self.product_id.expense_policy == 'sales_price': - return self.product_id.with_context( + product = self.product_id.with_context( partner=order.partner_id.id, date_order=order.date_order, pricelist=order.pricelist_id.id, - uom=self.product_uom_id.id - ).price + uom=self.product_uom_id.id, + quantity=unit_amount + ) + if order.pricelist_id.discount_policy == 'with_discount': + return product.price + return product.lst_price uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') if float_is_zero(unit_amount, precision_digits=uom_precision_digits): diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index e908e63c9d..6bcc0faf3d 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -138,8 +138,22 @@ class SaleOrder(models.Model): """, (list(value),)) so_ids = self.env.cr.fetchone()[0] or [] return [('id', 'in', so_ids)] - if operator == '=' and not value: - return [('order_line.invoice_lines', '=', False)] + elif operator == '=' and not value: + # special case for [('invoice_ids', '=', False)], i.e. "Invoices is not set" + # + # We cannot just search [('order_line.invoice_lines', '=', False)] + # because it returns orders with uninvoiced lines, which is not + # same "Invoices is not set" (some lines may have invoices and some + # doesn't) + # + # A solution is making inverted search first ("orders with invoiced + # lines") and then invert results ("get all other orders") + # + # Domain below returns subset of ('order_line.invoice_lines', '!=', False) + order_ids = self._search([ + ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund')) + ]) + return [('id', 'not in', order_ids)] return ['&', ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund')), ('order_line.invoice_lines.move_id', operator, value)] name = fields.Char(string='Order Reference', required=True, copy=False, readonly=True, states={'draft': [('readonly', False)]}, index=True, default=lambda self: _('New')) @@ -297,6 +311,7 @@ class SaleOrder(models.Model): """ For service and consumable, we only take the min dates. This method is extended in sale_stock to take the picking_policy of SO into account. """ + self.mapped("order_line") # Prefetch indication for order in self: dates_list = [] for line in order.order_line.filtered(lambda x: x.state != 'cancel' and not x._is_delivery() and not x.display_type): diff --git a/addons/sale/security/sale_security.xml b/addons/sale/security/sale_security.xml index c12f2b7dfa..523d5bc3e7 100644 --- a/addons/sale/security/sale_security.xml +++ b/addons/sale/security/sale_security.xml @@ -168,42 +168,42 @@ Personal Invoices - [('move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')), '|', ('invoice_user_id', '=', user.id), ('invoice_user_id', '=', False)] + [('move_type', 'in', ('out_invoice', 'out_refund')), '|', ('invoice_user_id', '=', user.id), ('invoice_user_id', '=', False)] All Invoices - [('move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))] + [('move_type', 'in', ('out_invoice', 'out_refund'))] Personal Invoice Lines - [('move_id.move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')), '|', ('move_id.invoice_user_id', '=', user.id), ('move_id.invoice_user_id', '=', False)] + [('move_id.move_type', 'in', ('out_invoice', 'out_refund')), '|', ('move_id.invoice_user_id', '=', user.id), ('move_id.invoice_user_id', '=', False)] All Invoice Lines - [('move_id.move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))] + [('move_id.move_type', 'in', ('out_invoice', 'out_refund'))] Personal Invoice Send and Print - [('invoice_ids.move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')), '|', ('invoice_ids.invoice_user_id', '=', user.id), ('invoice_ids.invoice_user_id', '=', False)] + [('invoice_ids.move_type', 'in', ('out_invoice', 'out_refund')), '|', ('invoice_ids.invoice_user_id', '=', user.id), ('invoice_ids.invoice_user_id', '=', False)] All Invoice Send and Print - [('invoice_ids.move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))] + [('invoice_ids.move_type', 'in', ('out_invoice', 'out_refund'))] diff --git a/addons/sale/tests/test_sale_to_invoice.py b/addons/sale/tests/test_sale_to_invoice.py index f5da9a4146..0c419789cc 100644 --- a/addons/sale/tests/test_sale_to_invoice.py +++ b/addons/sale/tests/test_sale_to_invoice.py @@ -66,12 +66,37 @@ class TestSaleToInvoice(TestSaleCommon): 'default_journal_id': cls.company_data['default_journal_sale'].id, } + def _check_order_search(self, orders, domain, expected_result): + domain += [('id', 'in', orders.ids)] + result = self.env['sale.order'].search(domain) + self.assertEqual(result, expected_result, "Unexpected result on search orders") + + def test_search_invoice_ids(self): + """Test searching on computed fields invoice_ids""" + + # Make qty zero to have a line without invoices + self.sol_prod_order.product_uom_qty = 0 + self.sale_order.action_confirm() + + # Tests before creating an invoice + self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.sale_order) + self._check_order_search(self.sale_order, [('invoice_ids', '!=', False)], self.env['sale.order']) + + # Create invoice + moves = self.sale_order._create_invoices() + + # Tests after creating the invoice + self._check_order_search(self.sale_order, [('invoice_ids', 'in', moves.ids)], self.sale_order) + self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.env['sale.order']) + self._check_order_search(self.sale_order, [('invoice_ids', '!=', False)], self.sale_order) + def test_downpayment(self): """ Test invoice with a way of downpayment and check downpayment's SO line is created and also check a total amount of invoice is equal to a respective sale order's total amount """ # Confirm the SO self.sale_order.action_confirm() + self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.sale_order) # Let's do an invoice for a deposit of 100 payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({ 'advance_payment_method': 'fixed', @@ -79,6 +104,7 @@ class TestSaleToInvoice(TestSaleCommon): 'deposit_account_id': self.company_data['default_account_revenue'].id }) payment.create_invoices() + self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.env['sale.order']) self.assertEqual(len(self.sale_order.invoice_ids), 1, 'Invoice should be created for the SO') downpayment_line = self.sale_order.order_line.filtered(lambda l: l.is_downpayment) @@ -161,7 +187,9 @@ class TestSaleToInvoice(TestSaleCommon): payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({ 'advance_payment_method': 'delivered' }) + self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.sale_order) payment.create_invoices() + self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.env['sale.order']) invoice = self.sale_order.invoice_ids[0] invoice.action_post() diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index cbe6d05155..66fcc54e40 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -746,12 +746,12 @@ --> + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/sale_coupon/models/coupon_program.py b/addons/sale_coupon/models/coupon_program.py index 1b3aada592..26033c2d7a 100644 --- a/addons/sale_coupon/models/coupon_program.py +++ b/addons/sale_coupon/models/coupon_program.py @@ -23,9 +23,10 @@ class CouponProgram(models.Model): 'name': _('Sales Orders'), 'view_mode': 'tree,form', 'res_model': 'sale.order', + 'search_view_id': [self.env.ref('sale.sale_order_view_search_inherit_quotation').id], 'type': 'ir.actions.act_window', - 'domain': [('id', 'in', orders.ids), ('state', 'not in', ('draft', 'sent', 'cancel'))], - 'context': dict(self._context, create=False) + 'domain': [('id', 'in', orders.ids)], + 'context': dict(self._context, create=False), } def _check_promo_code(self, order, coupon_code): diff --git a/addons/sale_mrp/models/account_move.py b/addons/sale_mrp/models/account_move.py index 3aac0e5aa1..82739a76cb 100644 --- a/addons/sale_mrp/models/account_move.py +++ b/addons/sale_mrp/models/account_move.py @@ -11,8 +11,8 @@ class AccountMoveLine(models.Model): so_line = self.sale_line_ids and self.sale_line_ids[-1] or False if so_line: - bom = so_line.product_id.product_tmpl_id.bom_ids and so_line.product_id.product_tmpl_id.bom_ids[0] - if bom.type == 'phantom': + bom = so_line.product_id.product_tmpl_id.bom_ids.filtered(lambda b: not b.company_id or b.company_id == so_line.company_id)[:1] + if bom and bom.type == 'phantom': qty_to_invoice = self.product_uom_id._compute_quantity(self.quantity, self.product_id.uom_id) qty_invoiced = sum([x.product_uom_id._compute_quantity(x.quantity, x.product_id.uom_id) for x in so_line.invoice_lines if x.move_id.state == 'posted']) moves = so_line.move_ids diff --git a/addons/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py index 92f159d7b8..abfa6ec15f 100644 --- a/addons/sale_stock/models/stock.py +++ b/addons/sale_stock/models/stock.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. +from collections import defaultdict + from flectra import api, fields, models, _ @@ -155,14 +157,13 @@ class ProductionLot(models.Model): @api.depends('name') def _compute_sale_order_ids(self): + sale_orders = defaultdict(lambda: self.env['sale.order']) + for move_line in self.env['stock.move.line'].search([('lot_id', 'in', self.ids), ('state', '=', 'done')]): + move = move_line.move_id + if move.picking_id.location_dest_id.usage == 'customer' and move.sale_line_id.order_id: + sale_orders[move_line.lot_id.id] |= move.sale_line_id.order_id for lot in self: - stock_moves = self.env['stock.move.line'].search([ - ('lot_id', '=', lot.id), - ('state', '=', 'done') - ]).mapped('move_id') - stock_moves = stock_moves.search([('id', 'in', stock_moves.ids)]).filtered( - lambda move: move.picking_id.location_dest_id.usage == 'customer' and move.state == 'done') - lot.sale_order_ids = stock_moves.mapped('sale_line_id.order_id') + lot.sale_order_ids = sale_orders[lot.id] lot.sale_order_count = len(lot.sale_order_ids) def action_view_so(self): diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 92bb1c8dd4..27822cd714 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -87,7 +87,7 @@ class Project(models.Model): employees = self.env['account.analytic.line'].read_group([('task_id', 'in', tasks.ids), ('non_allow_billable', '=', False)], ['employee_id', 'project_id'], ['employee_id', 'project_id'], ['employee_id', 'project_id'], lazy=False) dict_project_employee = defaultdict(list) for line in employees: - dict_project_employee[line['project_id'][0]] += [line['employee_id'][0]] + dict_project_employee[line['project_id'][0]] += [line['employee_id'][0]] if line['employee_id'] else [] for project in projects: project.warning_employee_rate = any(x not in project.sale_line_employee_ids.employee_id.ids for x in dict_project_employee[project.id]) @@ -232,7 +232,7 @@ class ProjectTask(models.Model): # TODO: [XBO] remove me in master non_allow_billable = fields.Boolean("Non-Billable", help="Your timesheets linked to this task will not be billed.") - remaining_hours_so = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours_so') + remaining_hours_so = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours_so', compute_sudo=True) remaining_hours_available = fields.Boolean(related="sale_line_id.remaining_hours_available") @api.depends('sale_line_id', 'timesheet_ids', 'timesheet_ids.unit_amount') diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py index c1ffd5087f..1c518ff426 100644 --- a/addons/sale_timesheet/models/project_overview.py +++ b/addons/sale_timesheet/models/project_overview.py @@ -3,7 +3,6 @@ import babel.dates from dateutil.relativedelta import relativedelta import itertools import json -from flectra.osv import expression from flectra import fields, _, models from flectra.osv import expression @@ -22,9 +21,7 @@ class Project(models.Model): def _qweb_prepare_qcontext(self, view_id, domain): values = super()._qweb_prepare_qcontext(view_id, domain) - project_ids = self.env.context.get('active_ids') - if project_ids: - domain = expression.AND([[('id', 'in', project_ids)], domain]) + projects = self.search(domain) values.update(projects._plan_prepare_values()) values['actions'] = projects._plan_prepare_actions(values) @@ -32,14 +29,8 @@ class Project(models.Model): return values def _plan_get_employee_ids(self): - user_ids = self.env['project.task'].sudo().read_group([('project_id', 'in', self.ids), ('user_id', '!=', False)], ['user_id'], ['user_id']) - user_ids = [user_id['user_id'][0] for user_id in user_ids] - employee_ids = self.env['res.users'].sudo().search_read([('id', 'in', user_ids)], ['employee_ids']) - # flatten the list of list - employee_ids = list(itertools.chain.from_iterable([employee_id['employee_ids'] for employee_id in employee_ids])) - aal_employee_ids = self.env['account.analytic.line'].read_group([('project_id', 'in', self.ids), ('employee_id', '!=', False)], ['employee_id'], ['employee_id']) - employee_ids.extend(list(map(lambda x: x['employee_id'][0], aal_employee_ids))) + employee_ids = list(map(lambda x: x['employee_id'][0], aal_employee_ids)) return employee_ids def _plan_prepare_values(self): diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 9df4ce0252..c95d019cc0 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -176,8 +176,8 @@ class StockMove(models.Model): next_serial = fields.Char('First SN') next_serial_count = fields.Integer('Number of SN') orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Original Reordering Rule', check_company=True) - forecast_availability = fields.Float('Forecast Availability', compute='_compute_forecast_information', digits='Product Unit of Measure') - forecast_expected_date = fields.Datetime('Forecasted Expected date', compute='_compute_forecast_information') + forecast_availability = fields.Float('Forecast Availability', compute='_compute_forecast_information', digits='Product Unit of Measure', compute_sudo=True) + forecast_expected_date = fields.Datetime('Forecasted Expected date', compute='_compute_forecast_information', compute_sudo=True) lot_ids = fields.Many2many('stock.production.lot', compute='_compute_lot_ids', inverse='_set_lot_ids', string='Serial Numbers', readonly=False) @api.onchange('product_id', 'picking_type_id') @@ -656,6 +656,19 @@ class StockMove(models.Model): self._generate_serial_numbers() return self.action_show_details() + def action_clear_lines_show_details(self): + """ Unlink `self.move_line_ids` before returning `self.action_show_details`. + Useful for if a user creates too many SNs by accident via action_assign_serial_show_details + since there's no way to undo the action. + """ + self.ensure_one() + if self.picking_type_id.show_reserved: + move_lines = self.move_line_ids + else: + move_lines = self.move_line_nosuggest_ids + move_lines.unlink() + return self.action_show_details() + def action_assign_serial(self): """ Opens a wizard to assign SN's name on each move lines. """ diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 4146009348..4b357b211a 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -668,7 +668,7 @@ class Picking(models.Model): return res def write(self, vals): - if vals.get('picking_type_id') and self.state != 'draft': + if vals.get('picking_type_id') and any(picking.state != 'draft' for picking in self): raise UserError(_("Changing the operation type of this record is forbidden at this point.")) # set partner as a follower and unfollow old partner if vals.get('partner_id'): diff --git a/addons/stock/static/img/replenishment.svg b/addons/stock/static/img/replenishment.svg index f8d339cbf7..5e56d8e5a5 100644 --- a/addons/stock/static/img/replenishment.svg +++ b/addons/stock/static/img/replenishment.svg @@ -1 +1 @@ -MaximumMinimumSafety StockLead timeTodayTime \ No newline at end of file +MaximumMinimumSafety StockLead timeTodayTime \ No newline at end of file diff --git a/addons/stock/views/stock_move_views.xml b/addons/stock/views/stock_move_views.xml index 231d36ac9e..baa650dade 100644 --- a/addons/stock/views/stock_move_views.xml +++ b/addons/stock/views/stock_move_views.xml @@ -164,6 +164,12 @@ title="Assign Serial Numbers"> Assign Serial Numbers +
      diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index 18c425790c..794c5d3ff2 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -479,12 +479,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py index 76dd1d6057..264720f439 100644 --- a/addons/stock_account/models/product.py +++ b/addons/stock_account/models/product.py @@ -245,7 +245,7 @@ class ProductProduct(models.Model): product = stock_valuation_layer.product_id value = stock_valuation_layer.value - if product.valuation != 'real_time': + if product.type != 'product' or product.valuation != 'real_time': continue # Sanity check. diff --git a/addons/stock_account/models/stock_move.py b/addons/stock_account/models/stock_move.py index 3e8bd8ec03..0d39badac4 100644 --- a/addons/stock_account/models/stock_move.py +++ b/addons/stock_account/models/stock_move.py @@ -19,6 +19,9 @@ class StockMove(models.Model): account_move_ids = fields.One2many('account.move', 'stock_move_id') stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'stock_move_id') + def _filter_anglo_saxon_moves(self, product): + return self.filtered(lambda m: m.product_id.id == product.id) + def action_get_account_moves(self): self.ensure_one() action_data = self.env['ir.actions.act_window']._for_xml_id('account.action_move_journal_line') diff --git a/addons/stock_account/wizard/stock_valuation_layer_revaluation.py b/addons/stock_account/wizard/stock_valuation_layer_revaluation.py index 6b72786a59..24ae321190 100644 --- a/addons/stock_account/wizard/stock_valuation_layer_revaluation.py +++ b/addons/stock_account/wizard/stock_valuation_layer_revaluation.py @@ -16,12 +16,12 @@ class StockValuationLayerRevaluation(models.TransientModel): res = super().default_get(default_fields) if res.get('product_id'): product = self.env['product.product'].browse(res['product_id']) - accounts = product.product_tmpl_id.get_product_accounts() if product.categ_id.property_cost_method == 'standard': raise UserError(_("You cannot revalue a product with a standard cost method.")) if product.quantity_svl <= 0: raise UserError(_("You cannot revalue a product with an empty or negative stock.")) - if 'account_journal_id' not in res and 'account_journal_id' in default_fields: + if 'account_journal_id' not in res and 'account_journal_id' in default_fields and product.categ_id.property_valuation == 'real_time': + accounts = product.product_tmpl_id.get_product_accounts() res['account_journal_id'] = accounts['stock_journal'].id return res @@ -39,7 +39,7 @@ class StockValuationLayerRevaluation(models.TransientModel): new_value_by_qty = fields.Monetary("New value by quantity", compute='_compute_new_value') reason = fields.Char("Reason", help="Reason of the revaluation") - account_journal_id = fields.Many2one('account.journal', "Journal", required=True, check_company=True) + account_journal_id = fields.Many2one('account.journal', "Journal", check_company=True) account_id = fields.Many2one('account.account', "Counterpart Account", domain=[('deprecated', '=', False)], check_company=True) date = fields.Date("Accounting Date") diff --git a/addons/stock_account/wizard/stock_valuation_layer_revaluation_views.xml b/addons/stock_account/wizard/stock_valuation_layer_revaluation_views.xml index 351661a5d0..d862c6bf8d 100644 --- a/addons/stock_account/wizard/stock_valuation_layer_revaluation_views.xml +++ b/addons/stock_account/wizard/stock_valuation_layer_revaluation_views.xml @@ -25,7 +25,7 @@ - + diff --git a/addons/stock_landed_costs/models/product.py b/addons/stock_landed_costs/models/product.py index fb4bd1b4d5..b904a9f107 100644 --- a/addons/stock_landed_costs/models/product.py +++ b/addons/stock_landed_costs/models/product.py @@ -3,6 +3,8 @@ from flectra import fields, models from flectra.addons.stock_landed_costs.models.stock_landed_cost import SPLIT_METHOD +from flectra.exceptions import UserError +from flectra import _ class ProductTemplate(models.Model): @@ -11,3 +13,12 @@ class ProductTemplate(models.Model): landed_cost_ok = fields.Boolean('Is a Landed Cost', help='Indicates whether the product is a landed cost.') split_method_landed_cost = fields.Selection(SPLIT_METHOD, string="Default Split Method", help="Default Split Method when used for Landed Cost") + + def write(self, vals): + for product in self: + if (('type' in vals and vals['type'] != 'service') or ('landed_cost_ok' in vals and vals['landed_cost_ok'] == False)) and product.type == 'service' and product.landed_cost_ok: + if self.env['account.move.line'].search_count([('product_id', 'in', product.product_variant_ids.ids), ('is_landed_costs_line', '=', True)]): + raise UserError(_("You cannot change the product type or disable landed cost option because the product is used in an account move line.")) + else: + vals['landed_cost_ok'] = False + super(ProductTemplate, product).write(vals) 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 74925273fd..bcdb37815f 100644 --- a/addons/stock_landed_costs/views/stock_landed_cost_views.xml +++ b/addons/stock_landed_costs/views/stock_landed_cost_views.xml @@ -196,12 +196,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/stock_picking_batch/views/stock_picking_batch_views.xml b/addons/stock_picking_batch/views/stock_picking_batch_views.xml index c5715e94c6..d324e7117f 100644 --- a/addons/stock_picking_batch/views/stock_picking_batch_views.xml +++ b/addons/stock_picking_batch/views/stock_picking_batch_views.xml @@ -198,12 +198,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 8d6688050f..7b5a7a9923 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -467,8 +467,8 @@ class Survey(http.Controller): errors = {} # Prepare answers / comment by question, validate and save answers - inactive_questions = request.env['survey.question'] if answer_sudo.is_session_answer else answer_sudo._get_inactive_conditional_questions() for question in questions: + inactive_questions = request.env['survey.question'] if answer_sudo.is_session_answer else answer_sudo._get_inactive_conditional_questions() if question in inactive_questions: # if question is inactive, skip validation and save continue answer, comment = self._extract_comment_from_answers(question, post.get(str(question.id))) diff --git a/addons/survey/tests/__init__.py b/addons/survey/tests/__init__.py index 9af683a650..67cacde934 100644 --- a/addons/survey/tests/__init__.py +++ b/addons/survey/tests/__init__.py @@ -4,6 +4,7 @@ from . import common from . import test_survey from . import test_survey_flow +from . import test_survey_flow_with_conditions from . import test_certification_flow from . import test_survey_invite from . import test_survey_security diff --git a/addons/survey/tests/common.py b/addons/survey/tests/common.py index ae5c055eb8..6ed08b0e25 100644 --- a/addons/survey/tests/common.py +++ b/addons/survey/tests/common.py @@ -197,6 +197,18 @@ class SurveyCase(common.SavepointCase): response = self._access_page(question.survey_id, answer_token) self.assertResponse(response, 200) + def _answer_page(self, page, answers, answer_token, csrf_token): + post_data = {} + for question, answer in answers.items(): + post_data[question.id] = answer.id + post_data['page_id'] = page.id + post_data['csrf_token'] = csrf_token + post_data['token'] = answer_token + response = self._access_submit(page.survey_id, answer_token, post_data) + self.assertResponse(response, 200) + response = self._access_page(page.survey_id, answer_token) + self.assertResponse(response, 200) + def _format_submission_data(self, question, answer, additional_post_data): post_data = {} post_data['question_id'] = question.id diff --git a/addons/survey/tests/test_survey_flow_with_conditions.py b/addons/survey/tests/test_survey_flow_with_conditions.py new file mode 100644 index 0000000000..21dd7c2d43 --- /dev/null +++ b/addons/survey/tests/test_survey_flow_with_conditions.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. + +from flectra.addons.survey.tests import common +from flectra.tests import tagged +from flectra.tests.common import HttpCase + + +@tagged('-at_install', 'post_install', 'functional') +class TestSurveyFlowWithConditions(common.TestSurveyCommon, HttpCase): + def test_conditional_flow_with_scoring(self): + with self.with_user('survey_user'): + survey = self.env['survey.survey'].create({ + 'title': 'Survey', + 'access_mode': 'public', + 'questions_layout': 'page_per_section', + 'scoring_type': 'scoring_with_answers', + 'scoring_success_min': 85.0, + 'state': 'open', + }) + + page_0 = self.env['survey.question'].with_user(self.survey_manager).create({ + 'title': 'First page', + 'survey_id': survey.id, + 'sequence': 1, + 'is_page': True, + }) + + q01 = self._add_question( + page_0, 'Question 1', 'simple_choice', + sequence=1, + constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id, + labels=[ + {'value': 'Answer 1'}, + {'value': 'Answer 2'}, + {'value': 'Answer 3'}, + {'value': 'Answer 4', 'is_correct': True, 'answer_score': 1.0} + ]) + + q02 = self._add_question( + page_0, 'Question 2', 'simple_choice', + sequence=2, + constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id, + is_conditional=True, triggering_question_id=q01.id, triggering_answer_id=q01.suggested_answer_ids.filtered(lambda q: q.is_correct).id, + labels=[ + {'value': 'Answer 1'}, + {'value': 'Answer 2', 'is_correct': True, 'answer_score': 1.0}, + {'value': 'Answer 3'}, + {'value': 'Answer 4'} + ]) + + q03 = self._add_question( + page_0, 'Question 3', 'simple_choice', + sequence=1, + constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id, + labels=[ + {'value': 'Answer 1'}, + {'value': 'Answer 2'}, + {'value': 'Answer 3'}, + {'value': 'Answer 4', 'is_correct': True, 'answer_score': 1.0} + ]) + + q04 = self._add_question( + page_0, 'Question 4', 'simple_choice', + sequence=2, + constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id, + is_conditional=True, triggering_question_id=q03.id, triggering_answer_id=q03.suggested_answer_ids.filtered(lambda q: q.is_correct).id, + labels=[ + {'value': 'Answer 1'}, + {'value': 'Answer 2', 'is_correct': True, 'answer_score': 1.0}, + {'value': 'Answer 3'}, + {'value': 'Answer 4'} + ]) + + q05 = self._add_question( + page_0, 'Question 5', 'simple_choice', + sequence=1, + constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id, + labels=[ + {'value': 'Answer 1'}, + {'value': 'Answer 2'}, + {'value': 'Answer 3'}, + {'value': 'Answer 4', 'is_correct': True, 'answer_score': 1.0} + ]) + + q06 = self._add_question( + page_0, 'Question 6', 'simple_choice', + sequence=2, + constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=survey.id, + is_conditional=True, triggering_question_id=q05.id, triggering_answer_id=q05.suggested_answer_ids.filtered(lambda q: q.is_correct).id, + labels=[ + {'value': 'Answer 1'}, + {'value': 'Answer 2', 'is_correct': True, 'answer_score': 1.0}, + {'value': 'Answer 3'}, + {'value': 'Answer 4'} + ]) + + # User opens start page + self._access_start(survey) + + # -> this should have generated a new user_input with a token + user_inputs = self.env['survey.user_input'].search([('survey_id', '=', survey.id)]) + self.assertEqual(len(user_inputs), 1) + answer_token = user_inputs.access_token + + # User begins survey with first page + response = self._access_page(survey, answer_token) + self.assertResponse(response, 200) + csrf_token = self._find_csrf_token(response.text) + + r = self._access_begin(survey, answer_token) + self.assertResponse(r, 200) + + answers = { + q01: q01.suggested_answer_ids[3], # Right + q02: q02.suggested_answer_ids[1], # Right + q03: q03.suggested_answer_ids[0], # Wrong + q05: q05.suggested_answer_ids[3], # Right + q06: q06.suggested_answer_ids[2], # Wrong + } + + self._answer_page(page_0, answers, answer_token, csrf_token) + + user_inputs.invalidate_cache() + self.assertEqual(round(user_inputs.scoring_percentage), 60, "Three right answers out of five (the fourth one is still hidden)") + self.assertFalse(user_inputs.scoring_success) diff --git a/addons/survey/views/survey_survey_views.xml b/addons/survey/views/survey_survey_views.xml index d28fb668ee..b18dc2a6d0 100644 --- a/addons/survey/views/survey_survey_views.xml +++ b/addons/survey/views/survey_survey_views.xml @@ -263,12 +263,12 @@ + domain="[('my_activity_date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/> + domain="[('my_activity_date_deadline', '>', context_today().strftime('%Y-%m-%d'))]"/> diff --git a/addons/test_mail/tests/test_mail_activity.py b/addons/test_mail/tests/test_mail_activity.py index bdbb0e6d13..8d25a20cce 100644 --- a/addons/test_mail/tests/test_mail_activity.py +++ b/addons/test_mail/tests/test_mail_activity.py @@ -333,3 +333,31 @@ class TestActivityMixin(TestActivityCommon): user_id=self.user_admin.id, new_user_id=self.user_employee.id) self.assertEqual(rec.activity_ids[0].user_id, self.user_employee) + + @mute_logger('flectra.addons.mail.models.mail_mail') + def test_my_activity_flow_employee(self): + Activity = self.env['mail.activity'] + activity_1 = Activity.create({ + 'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id, + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': self.test_record.id, + 'user_id': self.user_admin.id, + }) + activity_2 = Activity.create({ + 'activity_type_id': self.env.ref('test_mail.mail_act_test_call').id, + 'date_deadline': date.today() + relativedelta(days=1), + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': self.test_record.id, + 'user_id': self.user_employee.id, + }) + + test_record_1 = self.env['mail.test.activity'].with_context(self._test_context).create({'name': 'Test 1'}) + activity_3 = Activity.create({ + 'activity_type_id': self.env.ref('test_mail.mail_act_test_todo').id, + 'res_model_id': self.env.ref('test_mail.model_mail_test_activity').id, + 'res_id': test_record_1.id, + 'user_id': self.user_employee.id, + }) + with self.with_user('employee'): + record = self.env['mail.test.activity'].search([('my_activity_date_deadline', '=', datetime.today())]) + self.assertEqual(test_record_1, record) diff --git a/addons/uom/views/uom_uom_views.xml b/addons/uom/views/uom_uom_views.xml index 771d814b78..052ec7890c 100644 --- a/addons/uom/views/uom_uom_views.xml +++ b/addons/uom/views/uom_uom_views.xml @@ -25,21 +25,21 @@
    Label %Click (Total)