[MERGE] sale_subscription: Merge sale.subscription into sale.order
Purpose ======= Migrate subscription to sales order to simplify the code, the UX & make it more flexible for the future and easier to maintain. Subscription still appears as a separate module. This PR aims to move the sale.subscription models to sale.order. The implementation is quite similar to the sale_renting module. Before this PR, sale.subscription records are similar to sale.order ones. A lot of logic is duplicated to handle the subscription line computations, tax handling, etc. The current implementation of subscription has several drawbacks: * the sale.subscription records depends on required sale order template. * it is not possible to mix different periodicity in a subscription. Each partner has as many contract as periodicity needed (monthly, yearly etc) * when the flow starts in sale_management, the first invoice must be created from the sale application. * upsell prorata calculation to apply a discount is flawed when the salesman want to apply another discount. This PR fixes these issues and make subscription more flexible. One record can have subscription lines with different periodicity. Each line have a next_invoice_date and invoices are generated when they are due with the matching lines. Before this commit, the sale module could not be used to manage subscriptions in an override. This commit adapts the sale module to odoo/enterprise#21825 closes odoo/odoo#82632 Taskid: 2568007 Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
@@ -63,7 +63,9 @@ This module contains all the common features of Sales Management and eCommerce.
|
||||
'sale/static/src/scss/product_configurator.scss',
|
||||
'sale/static/src/js/sale.js',
|
||||
'sale/static/src/js/tours/sale.js',
|
||||
'sale/static/src/js/sale_order_line_mixin.js',
|
||||
'sale/static/src/js/product_configurator_widget.js',
|
||||
'sale/static/src/js/sale_order_controller.js',
|
||||
'sale/static/src/js/sale_order_view.js',
|
||||
'sale/static/src/js/product_discount_widget.js',
|
||||
],
|
||||
|
||||
@@ -236,7 +236,6 @@
|
||||
</record>
|
||||
|
||||
<record id="sale_order_8" model="sale.order">
|
||||
<field name="name">Test/001</field>
|
||||
<field name="partner_id" ref="base.res_partner_3"/>
|
||||
<field name="partner_invoice_id" ref="base.res_partner_address_25"/>
|
||||
<field name="partner_shipping_id" ref="base.res_partner_address_25"/>
|
||||
|
||||
@@ -841,10 +841,11 @@ class SaleOrder(models.Model):
|
||||
lines_to_recompute.discount = 0.0
|
||||
lines_to_recompute._compute_discount()
|
||||
self.show_update_pricelist = False
|
||||
self.message_post(body=_(
|
||||
"Product prices have been recomputed according to pricelist %s.",
|
||||
self.pricelist_id._get_html_link(),
|
||||
))
|
||||
if self.partner_id and self.id:
|
||||
self.message_post(body=_(
|
||||
"Product prices have been recomputed according to pricelist %s.",
|
||||
self.pricelist_id._get_html_link(),
|
||||
))
|
||||
|
||||
# INVOICING #
|
||||
|
||||
@@ -916,9 +917,9 @@ class SaleOrder(models.Model):
|
||||
def _get_invoice_grouping_keys(self):
|
||||
return ['company_id', 'partner_id', 'currency_id']
|
||||
|
||||
@api.model
|
||||
def _nothing_to_invoice_error(self):
|
||||
return UserError(_(
|
||||
def _nothing_to_invoice_error_message(self):
|
||||
self.ensure_one()
|
||||
return _(
|
||||
"There is nothing to invoice!\n\n"
|
||||
"Reason(s) of this behavior could be:\n"
|
||||
"- You should deliver your products before invoicing them: Click on the \"truck\" icon "
|
||||
@@ -927,7 +928,7 @@ class SaleOrder(models.Model):
|
||||
"\"Sales\" tab and modify invoicing policy from \"delivered quantities\" to \"ordered "
|
||||
"quantities\". For Services, you should modify the Service Invoicing Policy to "
|
||||
"'Prepaid'."
|
||||
))
|
||||
)
|
||||
|
||||
def _get_invoiceable_lines(self, final=False):
|
||||
"""Return the invoiceable lines for order `self`."""
|
||||
@@ -1007,7 +1008,7 @@ class SaleOrder(models.Model):
|
||||
invoice_vals_list.append(invoice_vals)
|
||||
|
||||
if not invoice_vals_list:
|
||||
raise self._nothing_to_invoice_error()
|
||||
raise UserError(order._nothing_to_invoice_error_message())
|
||||
|
||||
# 2) Manage 'grouped' parameter: group by (partner_id, currency_id).
|
||||
if not grouped:
|
||||
|
||||
@@ -731,7 +731,7 @@ class SaleOrderLine(models.Model):
|
||||
calculated from the ordered quantity. Otherwise, the quantity delivered is used.
|
||||
"""
|
||||
for line in self:
|
||||
if line.state in ['sale', 'done']:
|
||||
if line.state in ['sale', 'done'] and not line.display_type:
|
||||
if line.product_id.invoice_policy == 'order':
|
||||
line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced
|
||||
else:
|
||||
@@ -904,7 +904,6 @@ class SaleOrderLine(models.Model):
|
||||
}
|
||||
|
||||
#=== CRUD METHODS ===#
|
||||
|
||||
def _add_precomputed_values(self, vals_list):
|
||||
""" In the specific case where the discount is provided in the create values
|
||||
without being rounded, we have to 'manually' round it otherwise it won't be,
|
||||
|
||||
@@ -3,6 +3,7 @@ odoo.define('sale.product_discount', function (require) {
|
||||
|
||||
const BasicFields = require('web.basic_fields');
|
||||
const FieldsRegistry = require('web.field_registry');
|
||||
const UpdateAllLinesMixin = require('sale.UpdateAllLinesMixin');
|
||||
|
||||
/**
|
||||
* The sale.product_discount widget is a simple widget extending FieldFloat
|
||||
@@ -11,22 +12,10 @@ odoo.define('sale.product_discount', function (require) {
|
||||
* !!! WARNING !!!
|
||||
*
|
||||
* This widget is only designed for sale_order_line creation/updates.
|
||||
* !!! It should only be used on a discount field !!!
|
||||
*/
|
||||
const ProductDiscountWidget = BasicFields.FieldFloat.extend({
|
||||
|
||||
/**
|
||||
* Override changes at a discount.
|
||||
*
|
||||
* @override
|
||||
* @param {OdooEvent} ev
|
||||
*
|
||||
*/
|
||||
async reset(record, ev) {
|
||||
if (ev && ev.data.changes && ev.data.changes.discount >= 0) {
|
||||
this.trigger_up('open_discount_wizard');
|
||||
}
|
||||
this._super(...arguments);
|
||||
const ProductDiscountWidget = BasicFields.FieldFloat.extend(UpdateAllLinesMixin, {
|
||||
_getUpdateAllLinesAction: function () {
|
||||
return 'open_update_all_wizard';
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
odoo.define('sale.SaleOrderFormController', function (require) {
|
||||
"use strict";
|
||||
|
||||
const FormController = require('web.FormController');
|
||||
const Dialog = require('web.Dialog');
|
||||
const core = require('web.core');
|
||||
const _t = core._t;
|
||||
|
||||
const SaleOrderFormController = FormController.extend({
|
||||
custom_events: _.extend({}, FormController.prototype.custom_events, {
|
||||
open_update_all_wizard: '_onOpenUpdateAllWizard',
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handler called if user changes a value in the sale order line.
|
||||
* The wizard will open only if
|
||||
* (1) Sale order line is 3 or more
|
||||
* (2) First sale order line is changed
|
||||
* (3) value is the same in all other sale order line
|
||||
*/
|
||||
_onOpenUpdateAllWizard(ev) {
|
||||
const orderLines = this._DialogReady(ev, 'normal')
|
||||
const confirmCallback = () => {
|
||||
orderLines.slice(1).forEach((line) => {
|
||||
this.trigger_up('field_changed', {
|
||||
dataPointID: this.renderer.state.id,
|
||||
changes: {order_line: {operation: "UPDATE", id: line.id, data: {[ev.data.fieldName]: ev.data.value}}},
|
||||
});
|
||||
});
|
||||
};
|
||||
if (orderLines) {
|
||||
Dialog.confirm(this, _t("Do you want to apply this value to all order lines?"), {
|
||||
buttons: [{
|
||||
text: _t('YES'),
|
||||
classes: 'btn-primary',
|
||||
close: true,
|
||||
click: () => confirmCallback(),
|
||||
}, {
|
||||
text: _t("NO"),
|
||||
close: true,
|
||||
}],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_isEqualValue (type, fieldName, orderLines) {
|
||||
let isEqualValue;
|
||||
let secondValue = orderLines[1].data[fieldName];
|
||||
if (type === 'normal') {
|
||||
if (secondValue instanceof moment) {
|
||||
isEqualValue = orderLines.slice(1).every(line => secondValue.isSame(line.data[fieldName]));
|
||||
} else {
|
||||
isEqualValue = orderLines.slice(1).every(line => line.data[fieldName] === secondValue);
|
||||
}
|
||||
} else if (type === 'one2many') {
|
||||
// only works for display_name because we want to be able to apply different id with similar properties
|
||||
if (secondValue.data && secondValue.data.display_name) {
|
||||
secondValue = secondValue.data.display_name;
|
||||
isEqualValue = orderLines.slice(1).every(line => line.data[fieldName].data && line.data[fieldName].data.display_name === secondValue);
|
||||
} else {
|
||||
isEqualValue = orderLines.slice(1).every(line => line.data[fieldName] === secondValue);
|
||||
}
|
||||
}
|
||||
return isEqualValue;
|
||||
},
|
||||
|
||||
_DialogReady (ev, type) {
|
||||
const recordData = ev.target.recordData;
|
||||
const fieldName = ev.data.fieldName;
|
||||
const orderLines = this.renderer.state.data.order_line.data.filter(line => !line.data.display_type);
|
||||
if (orderLines.length < 3) {
|
||||
return false;
|
||||
}
|
||||
if (recordData.id === orderLines[0].data.id && this._isEqualValue(type, fieldName, orderLines)) {
|
||||
return orderLines;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return SaleOrderFormController;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
odoo.define('sale.UpdateAllLinesMixin', function (require) {
|
||||
'use strict';
|
||||
|
||||
const UpdateAllLinesMixin = {
|
||||
_onFieldChanged: function (ev) {
|
||||
if (ev && ev.data.changes) {
|
||||
const fieldName = Object.keys(ev.data.changes)[0];
|
||||
const value = ev.data.changes[fieldName];
|
||||
this.trigger_up(this._getUpdateAllLinesAction(), {fieldName: fieldName, value: value});
|
||||
}
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
_getUpdateAllLinesAction: function () {
|
||||
return '';
|
||||
},
|
||||
};
|
||||
return UpdateAllLinesMixin;
|
||||
});
|
||||
@@ -1,47 +1,9 @@
|
||||
odoo.define('sale.SaleOrderView', function (require) {
|
||||
"use strict";
|
||||
|
||||
const FormController = require('web.FormController');
|
||||
const SaleOrderFormController = require('sale.SaleOrderFormController');
|
||||
const FormView = require('web.FormView');
|
||||
const viewRegistry = require('web.view_registry');
|
||||
const Dialog = require('web.Dialog');
|
||||
const core = require('web.core');
|
||||
const _t = core._t;
|
||||
|
||||
const SaleOrderFormController = FormController.extend({
|
||||
custom_events: _.extend({}, FormController.prototype.custom_events, {
|
||||
open_discount_wizard: '_onOpenDiscountWizard',
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handler called if user changes the discount field in the sale order line.
|
||||
* The wizard will open only if
|
||||
* (1) Sale order line is 3 or more
|
||||
* (2) First sale order line is changed to discount
|
||||
* (3) Discount is the same in all sale order line
|
||||
*/
|
||||
_onOpenDiscountWizard(ev) {
|
||||
const orderLines = this.renderer.state.data.order_line.data.filter(line => !line.data.display_type);
|
||||
const recordData = ev.target.recordData;
|
||||
const isEqualDiscount = orderLines.slice(1).every(line => line.data.discount === recordData.discount);
|
||||
if (orderLines.length >= 3 && recordData.sequence === orderLines[0].data.sequence && isEqualDiscount) {
|
||||
Dialog.confirm(this, _t("Do you want to apply this discount to all order lines?"), {
|
||||
confirm_callback: () => {
|
||||
orderLines.slice(1).forEach((line) => {
|
||||
this.trigger_up('field_changed', {
|
||||
dataPointID: this.renderer.state.id,
|
||||
changes: {order_line: {operation: "UPDATE", id: line.id, data: {discount: orderLines[0].data.discount}}},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const SaleOrderView = FormView.extend({
|
||||
config: _.extend({}, FormView.prototype.config, {
|
||||
|
||||
@@ -195,10 +195,10 @@
|
||||
string="Void Transaction" data-hotkey="shift+v"
|
||||
confirm="Are you sure you want to void the authorized transaction? This action can't be undone."
|
||||
attrs="{'invisible': [('authorized_transaction_ids', '=', [])]}"/>
|
||||
<button name="%(sale.action_view_sale_advance_payment_inv)d" string="Create Invoice"
|
||||
<button id="create_invoice" name="%(sale.action_view_sale_advance_payment_inv)d" string="Create Invoice"
|
||||
type="action" class="btn-primary" data-hotkey="q"
|
||||
attrs="{'invisible': [('invoice_status', '!=', 'to invoice')]}"/>
|
||||
<button name="%(sale.action_view_sale_advance_payment_inv)d" string="Create Invoice"
|
||||
<button id="create_invoice_percentage" name="%(sale.action_view_sale_advance_payment_inv)d" string="Create Invoice"
|
||||
type="action" context="{'default_advance_payment_method': 'percentage'}" data-hotkey="q"
|
||||
attrs="{'invisible': ['|',('invoice_status', '!=', 'no'), ('state', '!=', 'sale')]}"/>
|
||||
<button name="action_quotation_send" string="Send by Email" type="object" states="draft" class="btn-primary" data-hotkey="g"/>
|
||||
@@ -589,7 +589,7 @@
|
||||
<field name="client_order_ref"/>
|
||||
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
|
||||
</group>
|
||||
<group name="sale_info" string="Invoicing">
|
||||
<group name="sale_info" string="Invoicing and Payments">
|
||||
<field name="fiscal_position_id" options="{'no_create': True}"/>
|
||||
<field name="analytic_account_id" context="{'default_partner_id':partner_invoice_id, 'default_name':name}" attrs="{'readonly': [('invoice_count','!=',0),('state','=','sale')]}" groups="analytic.group_analytic_accounting" force_save="1"/>
|
||||
<field name="invoice_status" states="sale,done" groups="base.group_no_one"/>
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
from . import coupon
|
||||
from . import coupon_program
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
|
||||
@@ -63,7 +63,6 @@ class SaleOrder(models.Model):
|
||||
return res
|
||||
|
||||
def _get_reward_lines(self):
|
||||
self.ensure_one()
|
||||
return self.order_line.filtered(lambda line: line.is_reward_line)
|
||||
|
||||
def _is_reward_in_order_lines(self, program):
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
class OrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
def _compute_name(self):
|
||||
# Avoid computing the name for reward lines
|
||||
reward = self.filtered('is_reward_line')
|
||||
super(OrderLine, self - reward)._compute_name()
|
||||
@@ -252,6 +252,8 @@ class SaleOrderOption(models.Model):
|
||||
new_sol = self.env['sale.order.line'].new(values)
|
||||
new_sol._compute_price_unit()
|
||||
option.price_unit = new_sol.price_unit
|
||||
# Avoid attaching the new line when called on template change
|
||||
new_sol.order_id = False
|
||||
|
||||
@api.depends('product_id', 'uom_id', 'quantity')
|
||||
def _compute_discount(self):
|
||||
@@ -263,6 +265,8 @@ class SaleOrderOption(models.Model):
|
||||
new_sol = self.env['sale.order.line'].new(values)
|
||||
new_sol._compute_discount()
|
||||
option.discount = new_sol.discount
|
||||
# Avoid attaching the new line when called on template change
|
||||
new_sol.order_id = False
|
||||
|
||||
def _get_values_to_add_to_order(self):
|
||||
self.ensure_one()
|
||||
|
||||
@@ -25,11 +25,10 @@ class SaleOrderLine(models.Model):
|
||||
|
||||
@api.depends('product_id.type')
|
||||
def _compute_product_updatable(self):
|
||||
super()._compute_product_updatable()
|
||||
for line in self:
|
||||
if line.product_id.type == 'service' and line.state == 'sale':
|
||||
line.product_updatable = False
|
||||
else:
|
||||
super(SaleOrderLine, line)._compute_product_updatable()
|
||||
|
||||
def _auto_init(self):
|
||||
"""
|
||||
|
||||
@@ -209,10 +209,9 @@ class SaleOrderLine(models.Model):
|
||||
|
||||
@api.depends('move_ids')
|
||||
def _compute_product_updatable(self):
|
||||
super()._compute_product_updatable()
|
||||
for line in self:
|
||||
if not line.move_ids.filtered(lambda m: m.state != 'cancel'):
|
||||
super(SaleOrderLine, line)._compute_product_updatable()
|
||||
else:
|
||||
if line.move_ids.filtered(lambda m: m.state != 'cancel'):
|
||||
line.product_updatable = False
|
||||
|
||||
@api.depends('product_id')
|
||||
|
||||
@@ -52,7 +52,7 @@ class TestPERF(common.TransactionCase):
|
||||
# + 1 warehouse fetch
|
||||
# + 1 query to get analytic default account
|
||||
# + 1 followers queries ?
|
||||
with self.assertQueryCount(admin=40):
|
||||
with self.assertQueryCount(admin=42):
|
||||
self.env['sale.order'].create([{
|
||||
'partner_id': self.partners[0].id,
|
||||
'user_id': self.salesmans[0].id,
|
||||
@@ -63,7 +63,7 @@ class TestPERF(common.TransactionCase):
|
||||
def test_dummy_sales_orders_batch_creation_perf(self):
|
||||
""" Dummy SOlines (notes/sections) should not add any custom queries other than their insert"""
|
||||
# + 2 SOL (batched) insert
|
||||
with self.assertQueryCount(admin=42):
|
||||
with self.assertQueryCount(admin=44):
|
||||
self.env['sale.order'].create([{
|
||||
'partner_id': self.partners[0].id,
|
||||
'user_id': self.salesmans[0].id,
|
||||
@@ -80,7 +80,7 @@ class TestPERF(common.TransactionCase):
|
||||
# + 2 SQL insert
|
||||
# + 2 queries to get analytic default tags
|
||||
# + 9 follower queries ?
|
||||
with self.assertQueryCount(admin=53):
|
||||
with self.assertQueryCount(admin=57):
|
||||
self.env['sale.order'].create([{
|
||||
'partner_id': self.partners[0].id,
|
||||
'user_id': self.salesmans[0].id,
|
||||
|
||||
@@ -29,13 +29,8 @@ class TestSaleService(TestCommonSaleTimesheet):
|
||||
'product_uom_qty': 50,
|
||||
})
|
||||
|
||||
self.sale_order.order_line._compute_product_updatable()
|
||||
self.assertTrue(sale_order_line.product_updatable)
|
||||
self.sale_order.action_confirm()
|
||||
self.sale_order.order_line._compute_product_updatable()
|
||||
|
||||
self.sale_order.action_confirm()
|
||||
self.sale_order.order_line._compute_product_updatable()
|
||||
self.assertFalse(sale_order_line.product_updatable)
|
||||
self.assertEqual(self.sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation')
|
||||
|
||||
|
||||
@@ -859,13 +859,13 @@ tour.stepUtils.openBuggerMenu("li.breadcrumb-item.active:contains('Quotations')"
|
||||
position: "bottom"
|
||||
}, {
|
||||
mobile: false,
|
||||
trigger: ".o_data_row:first",
|
||||
trigger: ".o_data_cell:contains('the_flow.customer')",
|
||||
extra_trigger: '.o_control_panel .breadcrumb:contains("Sales Orders")',
|
||||
content: _t("Go to the last sale order"),
|
||||
position: "right"
|
||||
}, {
|
||||
mobile: true,
|
||||
trigger: ".o_kanban_record:first",
|
||||
trigger: ".o_kanban_record .o_kanban_record_title:contains('the_flow.customer')",
|
||||
extra_trigger: '.o_control_panel .breadcrumb:contains("Sales Orders")',
|
||||
content: _t("Go to the last sale order"),
|
||||
position: "bottom"
|
||||
|
||||
Reference in New Issue
Block a user