[IMP] sale: Only record down payments when confirmed

Until now, there was no way on a given sales order to distinguish
draft, cancelled and posted down payments.

This commit makes sure the down payments state is clearly
shown as draft or cancelled when the linked invoice is
in the corresponding states.

Also remove those draft and cancelled down payments from the sale
portal & report as it can be confusing for customers to see line(s)
of down payments they never heard of.

Lastly, follow the existing behavior of invoices, where the down
payments were shown in a separate section.  Now a dedicated section
will be added to the sales order and propagated to the invoice,
if there are down payments to deduct.

Task - 2702407

closes odoo/odoo#82376

Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com>
This commit is contained in:
Horacio Tellez
2022-08-30 20:21:47 +02:00
committed by Victor Feyens
parent dc53124a09
commit d5b4534bf9
9 changed files with 109 additions and 19 deletions
+24 -2
View File
@@ -61,8 +61,12 @@ class AccountMove(models.Model):
def action_post(self):
#inherit of the function from account.move to validate a new tax and the priceunit of a downpayment
res = super(AccountMove, self).action_post()
line_ids = self.mapped('line_ids').filtered(lambda line: line.sale_line_ids.is_downpayment)
for line in line_ids:
down_payment_lines = self.line_ids.filtered(lambda line: line.sale_line_ids.is_downpayment)
for line in down_payment_lines:
if not line.sale_line_ids.display_type:
line.sale_line_ids._compute_name()
try:
line.sale_line_ids.tax_id = line.tax_ids
if all(line.tax_ids.mapped('price_include')):
@@ -77,6 +81,24 @@ class AccountMove(models.Model):
pass
return res
def button_draft(self):
res = super().button_draft()
self.line_ids.filtered(
lambda line: line.sale_line_ids.is_downpayment and not line.sale_line_ids.display_type
).sale_line_ids._compute_name()
return res
def button_cancel(self):
res = super().button_cancel()
self.line_ids.filtered(
lambda line: line.sale_line_ids.is_downpayment and not line.sale_line_ids.display_type
).sale_line_ids._compute_name()
return res
def _post(self, soft=True):
# OVERRIDE
# Auto-reconcile the invoice with payments coming from transactions.
+19
View File
@@ -1245,6 +1245,25 @@ class SaleOrder(models.Model):
self.ensure_one()
return self.transaction_ids._get_last()
def _get_order_lines_to_report(self):
down_payment_lines = self.order_line.filtered(lambda line:
line.is_downpayment
and not line.display_type
and not line._get_downpayment_state()
)
def show_line(line):
if not line.is_downpayment:
return True
elif line.display_type and down_payment_lines:
return True # Only show the down payment section if down payments were posted
elif line in down_payment_lines:
return True # Only show posted down payments
else:
return False
return self.order_line.filtered(show_line)
# PORTAL #
def has_to_be_signed(self, include_draft=False):
+25 -1
View File
@@ -291,7 +291,17 @@ class SaleOrderLine(models.Model):
for line in self:
if not line.product_id:
continue
line.name = line.with_context(lang=line.order_partner_id.lang)._get_sale_order_line_multiline_description_sale()
name = line.with_context(lang=line.order_partner_id.lang)._get_sale_order_line_multiline_description_sale()
if line.is_downpayment and not line.display_type:
context = {'lang': line.order_partner_id.lang}
dp_state = line._get_downpayment_state()
if dp_state == 'draft':
name = _("%(line_description)s (Draft)", line_description=name)
elif dp_state == 'cancel':
name = _("%(line_description)s (Canceled)", line_description=name)
del context
line.name = name
def _get_sale_order_line_multiline_description_sale(self):
""" Compute a default multiline description for this sales order line.
@@ -654,6 +664,20 @@ class SaleOrderLine(models.Model):
for so_line in lines_by_analytic:
so_line.qty_delivered = mapping.get(so_line.id or so_line._origin.id, 0.0)
def _get_downpayment_state(self):
self.ensure_one()
if self.display_type:
return ''
invoice_lines = self._get_invoice_lines()
if all(line.parent_state == 'draft' for line in invoice_lines):
return 'draft'
if all(line.parent_state == 'cancel' for line in invoice_lines):
return 'cancel'
return ''
def _get_delivered_quantity_by_analytic(self, additional_domain):
""" Compute and write the delivered quantity of current SO lines, based on their related
analytic lines.
@@ -63,7 +63,8 @@
</div>
<!-- Is there a discount on at least one line? -->
<t t-set="display_discount" t-value="any(l.discount for l in doc.order_line)"/>
<t t-set="lines_to_report" t-value="doc._get_order_lines_to_report()"/>
<t t-set="display_discount" t-value="any(l.discount for l in lines_to_report)"/>
<table class="table table-sm o_main_table mt-4">
<!-- In case we want to repeat the header, remove "display: table-row-group" -->
@@ -86,7 +87,7 @@
<t t-set="current_subtotal" t-value="0"/>
<t t-foreach="doc.order_line" t-as="line">
<t t-foreach="lines_to_report" t-as="line">
<t t-set="current_subtotal" t-value="current_subtotal + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
<t t-set="current_subtotal" t-value="current_subtotal + line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
@@ -107,7 +108,7 @@
<td name="td_taxes" class="text-end">
<span t-esc="', '.join(map(lambda x: (x.description or x.name), line.tax_id))"/>
</td>
<td name="td_subtotal" class="text-end o_price_total">
<td t-if="not line.is_downpayment" name="td_subtotal" class="text-end o_price_total">
<span t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
<span t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
</td>
@@ -126,7 +127,7 @@
</t>
</tr>
<t t-if="current_section and (line_last or doc.order_line[line_index+1].display_type == 'line_section')">
<t t-if="current_section and (line_last or doc.order_line[line_index+1].display_type == 'line_section') and not line.is_downpayment">
<tr class="is-subtotal text-end">
<td name="td_section_subtotal" colspan="99">
<strong class="mr16">Subtotal</strong>
+4 -2
View File
@@ -344,7 +344,8 @@ class TestSaleRefund(TestSaleCommon):
})
downpayment.create_invoices()
sale_order_refund.invoice_ids[0].action_post()
sol_downpayment = sale_order_refund.order_line[1]
# order_line[1] is the down payment section
sol_downpayment = sale_order_refund.order_line[2]
payment = self.env['sale.advance.payment.inv'].with_context(so_context).create({
'deposit_account_id': self.company_data['default_account_revenue'].id
@@ -352,7 +353,8 @@ class TestSaleRefund(TestSaleCommon):
payment.create_invoices()
so_invoice = max(sale_order_refund.invoice_ids)
self.assertEqual(len(so_invoice.invoice_line_ids.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))), len(sale_order_refund.order_line), 'All lines should be invoiced')
self.assertEqual(len(so_invoice.invoice_line_ids.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))),
len(sale_order_refund.order_line.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))), 'All lines should be invoiced')
self.assertEqual(len(so_invoice.invoice_line_ids.filtered(lambda l: l.display_type == 'line_section' and l.name == "Down Payments")), 1, 'A single section for downpayments should be present')
self.assertEqual(so_invoice.amount_total, sale_order_refund.amount_total - sol_downpayment.price_unit, 'Downpayment should be applied')
so_invoice.action_post()
+4 -3
View File
@@ -108,7 +108,7 @@ class TestSaleToInvoice(TestSaleCommon):
self._check_order_search(self.sale_order, [('invoice_ids', '=', False)], self.env['sale.order'])
self.assertEqual(len(self.sale_order.invoice_ids), 2, 'Invoice should be created for the SO')
downpayment_line = self.sale_order.order_line.filtered(lambda l: l.is_downpayment)
downpayment_line = self.sale_order.order_line.filtered(lambda l: l.is_downpayment and not l.display_type)
self.assertEqual(len(downpayment_line), 2, 'SO line downpayment should be created on SO')
# Update delivered quantity of SO lines
@@ -124,7 +124,8 @@ class TestSaleToInvoice(TestSaleCommon):
self.assertEqual(len(self.sale_order.invoice_ids), 3, 'Invoice should be created for the SO')
invoice = max(self.sale_order.invoice_ids)
self.assertEqual(len(invoice.invoice_line_ids.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))), len(self.sale_order.order_line), 'All lines should be invoiced')
self.assertEqual(len(invoice.invoice_line_ids.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))),
len(self.sale_order.order_line.filtered(lambda l: not (l.display_type == 'line_section' and l.name == "Down Payments"))), 'All lines should be invoiced')
self.assertEqual(len(invoice.invoice_line_ids.filtered(lambda l: l.display_type == 'line_section' and l.name == "Down Payments")), 1, 'A single section for downpayments should be present')
self.assertEqual(invoice.amount_total, self.sale_order.amount_total - sum(downpayment_line.mapped('price_unit')), 'Downpayment should be applied')
@@ -147,7 +148,7 @@ class TestSaleToInvoice(TestSaleCommon):
payment.create_invoices()
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)
downpayment_line = self.sale_order.order_line.filtered(lambda l: l.is_downpayment and not l.display_type)
self.assertEqual(len(downpayment_line), 1, 'SO line downpayment should be created on SO')
self.assertEqual(downpayment_line.price_unit, self.sale_order.amount_total/2, 'downpayment should have the correct amount')
+3 -2
View File
@@ -503,8 +503,9 @@
optional="show"
/>
<field name="discount" string="Disc.%" groups="product.group_discount_per_so_line" optional="show" widget="product_discount"/>
<field name="price_subtotal" widget="monetary" groups="account.group_show_line_subtotals_tax_excluded"/>
<field name="price_total" widget="monetary" groups="account.group_show_line_subtotals_tax_included"/>
<field name="is_downpayment" invisible="1"/>
<field name="price_subtotal" widget="monetary" groups="account.group_show_line_subtotals_tax_excluded" attrs="{'invisible': [('is_downpayment', '=', True)]}"/>
<field name="price_total" widget="monetary" groups="account.group_show_line_subtotals_tax_included" attrs="{'invisible': [('is_downpayment', '=', True)]}"/>
<field name="state" invisible="1"/>
<field name="invoice_status" invisible="1"/>
<field name="currency_id" invisible="1"/>
+4 -4
View File
@@ -483,8 +483,9 @@
<tbody class="sale_tbody">
<t t-set="current_subtotal" t-value="0"/>
<t t-set="lines_to_report" t-value="sale_order._get_order_lines_to_report()"/>
<t t-foreach="sale_order.order_line" t-as="line">
<t t-foreach="lines_to_report" t-as="line">
<t t-set="current_subtotal" t-value="current_subtotal + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
<t t-set="current_subtotal" t-value="current_subtotal + line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
@@ -519,7 +520,7 @@
<td t-attf-class="text-end {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
<span t-esc="', '.join(map(lambda x: (x.description or x.name), line.tax_id))"/>
</td>
<td class="text-end">
<td t-if="not line.is_downpayment" class="text-end">
<span class="oe_order_line_price_subtotal" t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
<span class="oe_order_line_price_total" t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
</td>
@@ -537,8 +538,7 @@
</td>
</t>
</tr>
<tr t-if="current_section and (line_last or sale_order.order_line[line_index+1].display_type == 'line_section')"
<tr t-if="current_section and (line_last or lines_to_report[line_index+1].display_type == 'line_section') and not line.is_downpayment"
class="is-subtotal text-end">
<td colspan="99">
<strong class="mr16">Subtotal</strong>
@@ -136,10 +136,25 @@ class SaleAdvancePaymentInv(models.TransientModel):
subtype_id=self.env.ref('mail.mt_note').id)
return invoice
def _prepare_downpayment_section(self, order):
context = {'lang': order.partner_id.lang}
so_values = {
'name': _('Down Payments'),
'product_uom_qty': 0.0,
'order_id': order.id,
'display_type': 'line_section',
'is_downpayment': True,
'sequence': order.order_line and order.order_line[-1].sequence + 1 or 10,
}
del context
return so_values
def _prepare_so_line(self, order, analytic_tag_ids, tax_ids, amount):
context = {'lang': order.partner_id.lang}
so_values = {
'name': _('Down Payment: %s') % (time.strftime('%m %Y'),),
'name': _('Down Payment: %s (Draft)') % (time.strftime('%m %Y'),),
'price_unit': amount,
'product_uom_qty': 0.0,
'order_id': order.id,
@@ -179,6 +194,11 @@ class SaleAdvancePaymentInv(models.TransientModel):
for line in order.order_line:
analytic_tag_ids = [(4, analytic_tag.id, None) for analytic_tag in line.analytic_tag_ids]
# verify that no down payment section already exists
if not any(line.display_type and line.is_downpayment for line in order.order_line):
so_downpayment_section_values = self._prepare_downpayment_section(order)
sale_line_obj.create(so_downpayment_section_values)
so_line_values = self._prepare_so_line(order, analytic_tag_ids, tax_ids, amount)
so_line = sale_line_obj.create(so_line_values)
invoices += self._create_invoice(order, so_line, amount)