mirror of
https://gitlab.com/flectra-hq/flectra.git
synced 2026-08-17 16:54:42 -05:00
[PATCH] Upstream patch - 13112022
This commit is contained in:
@@ -4,6 +4,7 @@ from flectra import api, fields, models, _
|
||||
from flectra.exceptions import RedirectWarning, UserError, ValidationError, AccessError
|
||||
from flectra.tools import float_compare, date_utils, email_split, email_re, float_is_zero
|
||||
from flectra.tools.misc import formatLang, format_date, get_lang
|
||||
from flectra.osv import expression
|
||||
|
||||
from datetime import date, timedelta
|
||||
from collections import defaultdict
|
||||
@@ -3874,6 +3875,13 @@ class AccountMoveLine(models.Model):
|
||||
# Add the domain and order by in order to compute the cumulated balance in _compute_cumulated_balance
|
||||
return super(AccountMoveLine, self.with_context(domain_cumulated_balance=to_tuple(domain or []), order_cumulated_balance=order)).search_read(domain, fields, offset, limit, order)
|
||||
|
||||
@api.model
|
||||
def fields_get(self, allfields=None, attributes=None):
|
||||
res = super().fields_get(allfields, attributes)
|
||||
if res.get('cumulated_balance'):
|
||||
res['cumulated_balance']['exportable'] = False
|
||||
return res
|
||||
|
||||
@api.depends_context('order_cumulated_balance', 'domain_cumulated_balance')
|
||||
def _compute_cumulated_balance(self):
|
||||
if not self.env.context.get('order_cumulated_balance'):
|
||||
@@ -4381,11 +4389,11 @@ class AccountMoveLine(models.Model):
|
||||
@api.model
|
||||
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
|
||||
if operator == 'ilike':
|
||||
args = ['|', '|',
|
||||
domain = ['|', '|',
|
||||
('name', 'ilike', name),
|
||||
('move_id', 'ilike', name),
|
||||
('product_id', 'ilike', name)]
|
||||
return self._search(args, limit=limit, access_rights_uid=name_get_uid)
|
||||
return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
|
||||
|
||||
return super()._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
|
||||
|
||||
|
||||
@@ -293,7 +293,7 @@ class AccountPartialReconcile(models.Model):
|
||||
'tax_repartition_line_id': tax_line.tax_repartition_line_id.id,
|
||||
'tax_ids': [(6, 0, tax_line.tax_ids.ids)],
|
||||
'tax_tag_ids': [(6, 0, tax_line._convert_tags_for_cash_basis(tax_line.tax_tag_ids).ids)],
|
||||
'account_id': tax_line.tax_repartition_line_id.account_id.id or tax_line.account_id.id,
|
||||
'account_id': tax_line.tax_repartition_line_id.account_id.id or tax_line.company_id.account_cash_basis_base_account_id.id or tax_line.account_id.id,
|
||||
'amount_currency': amount_currency,
|
||||
'currency_id': tax_line.currency_id.id,
|
||||
'partner_id': tax_line.partner_id.id,
|
||||
|
||||
@@ -2833,3 +2833,103 @@ class TestAccountMoveReconcile(AccountTestInvoicingCommon):
|
||||
._create_payments()
|
||||
|
||||
bill.button_draft()
|
||||
|
||||
def test_cash_basis_taxline_without_account(self):
|
||||
"""
|
||||
Make sure that cash basis taxlines that don't have an account are handled properly.
|
||||
"""
|
||||
self.env.company.tax_exigibility = True
|
||||
|
||||
tax = self.env['account.tax'].create({
|
||||
'name': 'cash basis 20%',
|
||||
'type_tax_use': 'purchase',
|
||||
'amount': 20,
|
||||
'tax_exigibility': 'on_payment',
|
||||
'cash_basis_transition_account_id': self.cash_basis_transfer_account.id,
|
||||
'invoice_repartition_line_ids': [
|
||||
(0, 0, {
|
||||
'factor_percent': 100,
|
||||
'repartition_type': 'base',
|
||||
}),
|
||||
(0, 0, {
|
||||
'factor_percent': 40,
|
||||
'account_id': self.tax_account_1.id,
|
||||
'repartition_type': 'tax',
|
||||
}),
|
||||
|
||||
(0, 0, {
|
||||
'factor_percent': 60,
|
||||
'repartition_type': 'tax',
|
||||
}),
|
||||
],
|
||||
'refund_repartition_line_ids': [
|
||||
(0, 0, {
|
||||
'factor_percent': 100,
|
||||
'repartition_type': 'base',
|
||||
}),
|
||||
(0, 0, {
|
||||
'factor_percent': 40,
|
||||
'account_id': self.tax_account_1.id,
|
||||
'repartition_type': 'tax',
|
||||
}),
|
||||
|
||||
(0, 0, {
|
||||
'factor_percent': 60,
|
||||
'repartition_type': 'tax',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
# create invoice
|
||||
move_form = Form(self.env['account.move'].with_context(
|
||||
default_move_type='in_invoice'))
|
||||
move_form.partner_id = self.partner_a
|
||||
move_form.invoice_date = fields.Date.from_string('2017-01-01')
|
||||
with move_form.invoice_line_ids.new() as line_form:
|
||||
line_form.product_id = self.product_a
|
||||
line_form.tax_ids.clear()
|
||||
line_form.tax_ids.add(tax)
|
||||
invoice = move_form.save()
|
||||
invoice.action_post()
|
||||
|
||||
# make payment
|
||||
self.env['account.payment.register'].with_context(active_model='account.move', active_ids=invoice.ids).create({
|
||||
'payment_date': invoice.date,
|
||||
})._create_payments()
|
||||
|
||||
# check caba move
|
||||
partial_rec = invoice.mapped('line_ids.matched_debit_ids')
|
||||
caba_move = self.env['account.move'].search(
|
||||
[('tax_cash_basis_rec_id', '=', partial_rec.id)])
|
||||
expected_values = [
|
||||
{
|
||||
'account_id': self.cash_basis_base_account.id,
|
||||
'debit': 0.0,
|
||||
'credit': 800.0
|
||||
},
|
||||
{
|
||||
'account_id': self.cash_basis_base_account.id,
|
||||
'debit': 800.0,
|
||||
'credit': 0.0
|
||||
},
|
||||
{
|
||||
'account_id': self.cash_basis_transfer_account.id,
|
||||
'debit': 0.0,
|
||||
'credit': 64.0
|
||||
},
|
||||
{
|
||||
'account_id': self.tax_account_1.id,
|
||||
'debit': 64.0,
|
||||
'credit': 0.0},
|
||||
{
|
||||
'account_id': self.cash_basis_transfer_account.id,
|
||||
'debit': 0.0,
|
||||
'credit': 96.0
|
||||
},
|
||||
{
|
||||
'account_id': self.cash_basis_base_account.id,
|
||||
'debit': 96.0,
|
||||
'credit': 0.0
|
||||
}
|
||||
]
|
||||
self.assertRecordValues(caba_move.line_ids, expected_values)
|
||||
|
||||
@@ -490,13 +490,9 @@ class AccountEdiFormat(models.Model):
|
||||
res = False
|
||||
try:
|
||||
if file_data['type'] == 'xml':
|
||||
res = edi_format\
|
||||
.with_context(default_move_type=invoice.move_type)\
|
||||
._update_invoice_from_xml_tree(file_data['filename'], file_data['xml_tree'], invoice)
|
||||
res = edi_format._update_invoice_from_xml_tree(file_data['filename'], file_data['xml_tree'], invoice)
|
||||
elif file_data['type'] == 'pdf':
|
||||
res = edi_format\
|
||||
.with_context(default_move_type=invoice.move_type)\
|
||||
._update_invoice_from_pdf_reader(file_data['filename'], file_data['pdf_reader'], invoice)
|
||||
res = edi_format._update_invoice_from_pdf_reader(file_data['filename'], file_data['pdf_reader'], invoice)
|
||||
file_data['pdf_reader'].stream.close()
|
||||
else: # file_data['type'] == 'binary'
|
||||
res = edi_format._update_invoice_from_binary(file_data['filename'], file_data['content'], file_data['extension'], invoice)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from flectra import fields, models
|
||||
from flectra import api, fields, models
|
||||
|
||||
|
||||
class ResCountry(models.Model):
|
||||
@@ -16,3 +16,8 @@ class ResCountry(models.Model):
|
||||
"\n%(street_number)s: the house number"
|
||||
"\n%(street_number2)s: the door number",
|
||||
default='%(street_number)s/%(street_number2)s %(street_name)s', required=True)
|
||||
|
||||
@api.onchange("street_format")
|
||||
def onchange_street_format(self):
|
||||
# Prevent unexpected truncation with whitespaces in front of the street format
|
||||
self.street_format = self.street_format.strip()
|
||||
|
||||
@@ -99,6 +99,7 @@ class ImDispatch(object):
|
||||
def __init__(self):
|
||||
self.channels = {}
|
||||
self.started = False
|
||||
self.Event = None
|
||||
|
||||
def poll(self, dbname, channels, last, options=None, timeout=TIMEOUT):
|
||||
if options is None:
|
||||
@@ -181,7 +182,7 @@ class ImDispatch(object):
|
||||
def start(self):
|
||||
if flectra.evented:
|
||||
# gevent mode
|
||||
import gevent
|
||||
import gevent.event # pylint: disable=import-outside-toplevel
|
||||
self.Event = gevent.event.Event
|
||||
gevent.spawn(self.run)
|
||||
else:
|
||||
|
||||
@@ -742,7 +742,7 @@ class Meeting(models.Model):
|
||||
if values.get('activity_ids'):
|
||||
continue
|
||||
res_model_id = values.get('res_model_id', defaults.get('res_model_id'))
|
||||
res_id = values.get('res_id', defaults.get('res_id'))
|
||||
values['res_id'] = res_id = values.get('res_id') or defaults.get('res_id')
|
||||
user_id = values.get('user_id', defaults.get('user_id'))
|
||||
if not res_model_id or not res_id:
|
||||
continue
|
||||
|
||||
@@ -128,6 +128,6 @@ class EventRegistration(models.Model):
|
||||
res.update({
|
||||
'payment_status': self.payment_status,
|
||||
'payment_status_value': dict(self._fields['payment_status']._description_selection(self.env))[self.payment_status],
|
||||
'has_to_pay': not self.is_paid,
|
||||
'has_to_pay': self.payment_status == 'to_pay',
|
||||
})
|
||||
return res
|
||||
|
||||
@@ -528,8 +528,13 @@ class IrHttp(models.AbstractModel):
|
||||
raise Exception("Rerouting limit exceeded")
|
||||
request.httprequest.environ['PATH_INFO'] = path
|
||||
# void werkzeug cached_property. TODO: find a proper way to do this
|
||||
for key in ('path', 'full_path', 'url', 'base_url'):
|
||||
for key in ('full_path', 'url', 'base_url'):
|
||||
request.httprequest.__dict__.pop(key, None)
|
||||
# since werkzeug 2.0 `path`` became an attribute and is not a cached property anymore
|
||||
if hasattr(type(request.httprequest), 'path'): # cached property
|
||||
request.httprequest.__dict__.pop('path', None)
|
||||
else: # direct attribute
|
||||
request.httprequest.path = '/' + path.lstrip('/')
|
||||
|
||||
return cls._dispatch()
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
import re
|
||||
|
||||
from werkzeug import urls, utils
|
||||
from html import unescape
|
||||
from werkzeug import urls
|
||||
|
||||
from flectra import api, models, tools
|
||||
|
||||
@@ -40,7 +41,7 @@ class MailRenderMixin(models.AbstractModel):
|
||||
label = (match[3] or '').strip()
|
||||
|
||||
if not blacklist or not [s for s in blacklist if s in long_url] and not long_url.startswith(short_schema):
|
||||
create_vals = dict(link_tracker_vals, url=utils.unescape(long_url), label=utils.unescape(label))
|
||||
create_vals = dict(link_tracker_vals, url=unescape(long_url), label=unescape(label))
|
||||
link = self.env['link.tracker'].create(create_vals)
|
||||
if link.short_url:
|
||||
new_href = href.replace(long_url, link.short_url)
|
||||
@@ -69,7 +70,7 @@ class MailRenderMixin(models.AbstractModel):
|
||||
if blacklist and any(item in parsed.path for item in blacklist):
|
||||
continue
|
||||
|
||||
create_vals = dict(link_tracker_vals, url= utils.unescape(original_url))
|
||||
create_vals = dict(link_tracker_vals, url=unescape(original_url))
|
||||
link = self.env['link.tracker'].create(create_vals)
|
||||
if link.short_url:
|
||||
content = content.replace(original_url, link.short_url, 1)
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
import requests
|
||||
import werkzeug
|
||||
|
||||
from html import unescape
|
||||
|
||||
from flectra import models, api, service
|
||||
from flectra.tools.translate import _
|
||||
@@ -69,7 +70,7 @@ class MercuryTransaction(models.Model):
|
||||
try:
|
||||
r = requests.post(url, data=xml_transaction, headers=headers, timeout=65)
|
||||
r.raise_for_status()
|
||||
response = werkzeug.utils.unescape(r.content.decode())
|
||||
response = unescape(r.content.decode())
|
||||
except Exception:
|
||||
response = "timeout"
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class StockMove(models.Model):
|
||||
purchase_line_id = fields.Many2one('purchase.order.line',
|
||||
'Purchase Order Line', ondelete='set null', index=True, readonly=True)
|
||||
created_purchase_line_id = fields.Many2one('purchase.order.line',
|
||||
'Created Purchase Order Line', ondelete='set null', readonly=True, copy=False)
|
||||
'Created Purchase Order Line', ondelete='set null', readonly=True, copy=False, index=True)
|
||||
|
||||
@api.model
|
||||
def _prepare_merge_moves_distinct_fields(self):
|
||||
|
||||
@@ -2233,46 +2233,3 @@ class TestSaleMrpFlow(ValuationReconciliationTestCommon):
|
||||
|
||||
price = line.product_id.with_company(line.company_id)._compute_average_price(0, line.product_uom_qty, line.move_ids)
|
||||
self.assertEqual(price, 10)
|
||||
|
||||
def test_kit_cost_calculation(self):
|
||||
""" Check that the average cost price is computed correctly after SO confirmation:
|
||||
BOM 1:
|
||||
- 1 unit of “super kit”:
|
||||
- 2 units of “component a”
|
||||
BOM 2:
|
||||
- 1 unit of “component a”:
|
||||
- 3 units of "component b"
|
||||
1 unit of "component b" = $10
|
||||
1 unit of "super kit" = 2 * 3 * $10 = *$60
|
||||
"""
|
||||
super_kit = self._cls_create_product('Super Kit', self.uom_unit)
|
||||
(super_kit + self.component_a + self.component_b).categ_id.property_cost_method = 'average'
|
||||
self.env['mrp.bom'].create({
|
||||
'product_tmpl_id': self.component_a.product_tmpl_id.id,
|
||||
'product_qty': 1.0,
|
||||
'type': 'phantom',
|
||||
'bom_line_ids': [(0, 0, {
|
||||
'product_id': self.component_b.id,
|
||||
'product_qty': 3.0,
|
||||
})]
|
||||
})
|
||||
self.env['mrp.bom'].create({
|
||||
'product_tmpl_id': super_kit.product_tmpl_id.id,
|
||||
'product_qty': 1.0,
|
||||
'type': 'phantom',
|
||||
'bom_line_ids': [(0, 0, {
|
||||
'product_id': self.component_a.id,
|
||||
'product_qty': 2.0,
|
||||
})]
|
||||
})
|
||||
self.component_b.standard_price = 10
|
||||
self.component_a.button_bom_cost()
|
||||
super_kit.button_bom_cost()
|
||||
so_form = Form(self.env['sale.order'])
|
||||
so_form.partner_id = self.partner_a
|
||||
with so_form.order_line.new() as line:
|
||||
line.product_id = super_kit
|
||||
so = so_form.save()
|
||||
self.assertEqual(so.order_line.purchase_price, 60)
|
||||
so.action_confirm()
|
||||
self.assertEqual(so.order_line.purchase_price, 60)
|
||||
|
||||
@@ -33,11 +33,23 @@ class AccountMove(models.Model):
|
||||
all_invoices_amls = current_invoice_amls.sale_line_ids.invoice_lines.filtered(lambda aml: aml.move_id.state == 'posted').sorted(lambda aml: (aml.date, aml.move_name, aml.id))
|
||||
index = all_invoices_amls.ids.index(current_invoice_amls[:1].id) if current_invoice_amls[:1] in all_invoices_amls else 0
|
||||
previous_amls = all_invoices_amls[:index]
|
||||
|
||||
previous_qties_invoiced = previous_amls._get_invoiced_qty_per_product()
|
||||
invoiced_qties = current_invoice_amls._get_invoiced_qty_per_product()
|
||||
invoiced_products = invoiced_qties.keys()
|
||||
|
||||
if self.move_type == 'out_invoice':
|
||||
# filter out the invoices that have been fully refund and re-invoice otherwise, the quantities would be
|
||||
# consumed by the reversed invoice and won't be print on the new draft invoice
|
||||
previous_amls = previous_amls.filtered(lambda aml: aml.move_id.payment_state != 'reversed')
|
||||
|
||||
previous_qties_invoiced = previous_amls._get_invoiced_qty_per_product()
|
||||
|
||||
if self.move_type == 'out_refund':
|
||||
# we swap the sign because it's a refund, and it would print negative number otherwise
|
||||
for p in previous_qties_invoiced:
|
||||
previous_qties_invoiced[p] = -previous_qties_invoiced[p]
|
||||
for p in invoiced_qties:
|
||||
invoiced_qties[p] = -invoiced_qties[p]
|
||||
|
||||
qties_per_lot = defaultdict(float)
|
||||
previous_qties_delivered = defaultdict(float)
|
||||
stock_move_lines = current_invoice_amls.sale_line_ids.move_ids.move_line_ids.filtered(lambda sml: sml.state == 'done' and sml.lot_id).sorted(lambda sml: (sml.date, sml.id))
|
||||
@@ -48,7 +60,13 @@ class AccountMove(models.Model):
|
||||
product_uom = product.uom_id
|
||||
qty_done = sml.product_uom_id._compute_quantity(sml.qty_done, product_uom)
|
||||
|
||||
if sml.location_id.usage == 'customer':
|
||||
# is it a stock return considering the document type (should it be it thought of as positively or negatively?)
|
||||
is_stock_return = (
|
||||
self.move_type == 'out_invoice' and (sml.location_id.usage, sml.location_dest_id.usage) == ('customer', 'internal')
|
||||
or
|
||||
self.move_type == 'out_refund' and (sml.location_id.usage, sml.location_dest_id.usage) == ('internal', 'customer')
|
||||
)
|
||||
if is_stock_return:
|
||||
returned_qty = min(qties_per_lot[sml.lot_id], qty_done)
|
||||
qties_per_lot[sml.lot_id] -= returned_qty
|
||||
qty_done = returned_qty - qty_done
|
||||
@@ -60,7 +78,7 @@ class AccountMove(models.Model):
|
||||
# try to reach the previous_qty_invoiced
|
||||
if float_compare(qty_done, 0, precision_rounding=product_uom.rounding) < 0 or \
|
||||
float_compare(previous_qty_delivered, previous_qty_invoiced, precision_rounding=product_uom.rounding) < 0:
|
||||
previously_done = qty_done if sml.location_id.usage == 'customer' else min(previous_qty_invoiced - previous_qty_delivered, qty_done)
|
||||
previously_done = qty_done if is_stock_return else min(previous_qty_invoiced - previous_qty_delivered, qty_done)
|
||||
previous_qties_delivered[product] += previously_done
|
||||
qty_done -= previously_done
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ class TestSaleStockInvoices(TestSaleCommon):
|
||||
'product_id': self.product_by_usn.id,
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
self.usn01 = usn01
|
||||
self.usn02 = usn02
|
||||
self.env['stock.quant']._update_available_quantity(self.product_by_lot, self.stock_location, 10, lot_id=lot)
|
||||
self.env['stock.quant']._update_available_quantity(self.product_by_usn, self.stock_location, 1, lot_id=usn01)
|
||||
self.env['stock.quant']._update_available_quantity(self.product_by_usn, self.stock_location, 1, lot_id=usn02)
|
||||
@@ -281,3 +283,109 @@ class TestSaleStockInvoices(TestSaleCommon):
|
||||
self.assertRegex(text, r'Product By Lot\n6.00\nUnits\nLOT0002', "There should be a line that specifies 6 x LOT0002")
|
||||
self.assertRegex(text, r'Product By Lot\n2.00\nUnits\nLOT0003', "There should be a line that specifies 2 x LOT0003")
|
||||
self.assertNotIn('LOT0001', text)
|
||||
|
||||
def test_refund_cancel_invoices(self):
|
||||
"""
|
||||
Suppose the lots are printed on the invoices.
|
||||
The user sells 2 tracked-by-usn products, he delivers 2 products and invoices them
|
||||
Then he adds credit notes and issues a full refund. Receive the products.
|
||||
The reversed invoice should also have correct USN
|
||||
"""
|
||||
|
||||
report = self.env['ir.actions.report']._get_report_from_name('account.report_invoice_with_payments')
|
||||
display_lots = self.env.ref('sale_stock.group_lot_on_invoice')
|
||||
display_uom = self.env.ref('uom.group_uom')
|
||||
self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
|
||||
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_a.id,
|
||||
'order_line': [
|
||||
(0, 0, {'name': self.product_by_usn.name, 'product_id': self.product_by_usn.id, 'product_uom_qty': 2}),
|
||||
],
|
||||
})
|
||||
so.action_confirm()
|
||||
|
||||
picking = so.picking_ids
|
||||
picking.move_lines.move_line_ids[0].qty_done = 1
|
||||
picking.move_lines.move_line_ids[1].qty_done = 1
|
||||
picking.button_validate()
|
||||
|
||||
invoice01 = so._create_invoices()
|
||||
invoice01.action_post()
|
||||
|
||||
html = report._render_qweb_html(invoice01.ids)[0]
|
||||
text = html2plaintext(html)
|
||||
self.assertRegex(text, r'Product By USN\n1.00\nUnits\nUSN0001', "There should be a line that specifies 1 x USN0001")
|
||||
self.assertRegex(text, r'Product By USN\n1.00\nUnits\nUSN0002', "There should be a line that specifies 1 x USN0002")
|
||||
|
||||
# Refund the invoice
|
||||
refund_invoice_wiz = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=[invoice01.id]).create({
|
||||
'refund_method': 'cancel',
|
||||
})
|
||||
refund_invoice = self.env['account.move'].browse(refund_invoice_wiz.reverse_moves()['res_id'])
|
||||
|
||||
# recieve the returned product
|
||||
stock_return_picking_form = Form(self.env['stock.return.picking'].with_context(active_ids=picking.ids, active_id=picking.sorted().ids[0], active_model='stock.picking'))
|
||||
return_wiz = stock_return_picking_form.save()
|
||||
res = return_wiz.create_returns()
|
||||
pick_return = self.env['stock.picking'].browse(res['res_id'])
|
||||
|
||||
move_form = Form(pick_return.move_lines, view='stock.view_stock_move_nosuggest_operations')
|
||||
with move_form.move_line_nosuggest_ids.new() as line:
|
||||
line.lot_id = self.usn01
|
||||
line.qty_done = 1
|
||||
with move_form.move_line_nosuggest_ids.new() as line:
|
||||
line.lot_id = self.usn02
|
||||
line.qty_done = 1
|
||||
move_form.save()
|
||||
pick_return.button_validate()
|
||||
|
||||
# reversed invoice
|
||||
html = report._render_qweb_html(refund_invoice.ids)[0]
|
||||
text = html2plaintext(html)
|
||||
self.assertRegex(text, r'Product By USN\n1.00\nUnits\nUSN0001', "There should be a line that specifies 1 x USN0001")
|
||||
self.assertRegex(text, r'Product By USN\n1.00\nUnits\nUSN0002', "There should be a line that specifies 1 x USN0002")
|
||||
|
||||
def test_refund_modify_invoices(self):
|
||||
"""
|
||||
Suppose the lots are printed on the invoices.
|
||||
The user sells 1 tracked-by-usn products, he delivers 1 and invoices it
|
||||
Then he adds credit notes and issues full refund and new draft invoice.
|
||||
The new draft invoice should have correct USN
|
||||
"""
|
||||
|
||||
report = self.env['ir.actions.report']._get_report_from_name('account.report_invoice_with_payments')
|
||||
display_lots = self.env.ref('sale_stock.group_lot_on_invoice')
|
||||
display_uom = self.env.ref('uom.group_uom')
|
||||
self.env.user.write({'groups_id': [(4, display_lots.id), (4, display_uom.id)]})
|
||||
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_a.id,
|
||||
'order_line': [
|
||||
(0, 0, {'name': self.product_by_usn.name, 'product_id': self.product_by_usn.id, 'product_uom_qty': 1}),
|
||||
],
|
||||
})
|
||||
so.action_confirm()
|
||||
|
||||
picking = so.picking_ids
|
||||
picking.move_lines.move_line_ids[0].qty_done = 1
|
||||
picking.button_validate()
|
||||
|
||||
invoice01 = so._create_invoices()
|
||||
invoice01.action_post()
|
||||
|
||||
html = report._render_qweb_html(invoice01.ids)[0]
|
||||
text = html2plaintext(html)
|
||||
self.assertRegex(text, r'Product By USN\n1.00\nUnits\nUSN0001', "There should be a line that specifies 1 x USN0001")
|
||||
|
||||
# Refund the invoice with full refund and new draft invoice
|
||||
refund_invoice_wiz = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=[invoice01.id]).create({
|
||||
'refund_method': 'modify',
|
||||
})
|
||||
invoice02 = self.env['account.move'].browse(refund_invoice_wiz.reverse_moves()['res_id'])
|
||||
invoice02.action_post()
|
||||
|
||||
# new draft invoice
|
||||
html = report._render_qweb_html(invoice02.ids)[0]
|
||||
text = html2plaintext(html)
|
||||
self.assertRegex(text, r'Product By USN\n1.00\nUnits\nUSN0001', "There should be a line that specifies 1 x USN0001")
|
||||
|
||||
@@ -762,11 +762,17 @@ class ProductTemplate(models.Model):
|
||||
if 'type' in vals and vals['type'] != 'product' and sum(self.mapped('nbr_reordering_rules')) != 0:
|
||||
raise UserError(_('You still have some active reordering rules on this product. Please archive or delete them first.'))
|
||||
if any('type' in vals and vals['type'] != prod_tmpl.type for prod_tmpl in self):
|
||||
existing_move_lines = self.env['stock.move.line'].search([
|
||||
existing_done_move_lines = self.env['stock.move.line'].search([
|
||||
('product_id', 'in', self.mapped('product_variant_ids').ids),
|
||||
('state', '=', 'done'),
|
||||
], limit=1)
|
||||
if existing_done_move_lines:
|
||||
raise UserError(_("You can not change the type of a product that was already used."))
|
||||
existing_reserved_move_lines = self.env['stock.move.line'].search([
|
||||
('product_id', 'in', self.mapped('product_variant_ids').ids),
|
||||
('state', 'in', ['partially_available', 'assigned']),
|
||||
])
|
||||
if existing_move_lines:
|
||||
if existing_reserved_move_lines:
|
||||
raise UserError(_("You can not change the type of a product that is currently reserved on a stock move. If you need to change the type, you should first unreserve the stock move."))
|
||||
if 'type' in vals and vals['type'] != 'product' and any(p.type == 'product' and not float_is_zero(p.qty_available, precision_rounding=p.uom_id.rounding) for p in self):
|
||||
raise UserError(_("Available quantity should be set to zero before changing type"))
|
||||
|
||||
@@ -12,7 +12,7 @@ class StockInventory(models.Model):
|
||||
help="Date at which the accounting entries will be created"
|
||||
" in case of automated inventory valuation."
|
||||
" If empty, the inventory date will be used.")
|
||||
has_account_moves = fields.Boolean(compute='_compute_has_account_moves')
|
||||
has_account_moves = fields.Boolean(compute='_compute_has_account_moves', compute_sudo=True)
|
||||
|
||||
def _compute_has_account_moves(self):
|
||||
for inventory in self:
|
||||
|
||||
@@ -1099,10 +1099,9 @@ class Proxy(http.Controller):
|
||||
if not data:
|
||||
raise werkzeug.exceptions.BadRequest()
|
||||
from werkzeug.test import Client
|
||||
from werkzeug.wrappers import BaseResponse
|
||||
base_url = request.httprequest.base_url
|
||||
query_string = request.httprequest.query_string
|
||||
client = Client(http.root, BaseResponse)
|
||||
client = Client(http.root, werkzeug.wrappers.Response)
|
||||
headers = {'X-Openerp-Session-Id': request.session.sid}
|
||||
return client.post('/' + path, base_url=base_url, query_string=query_string,
|
||||
headers=headers, data=data)
|
||||
|
||||
@@ -129,7 +129,7 @@ class Website(Home):
|
||||
"""
|
||||
if not redirect and request.params.get('login_success'):
|
||||
if request.env['res.users'].browse(uid).has_group('base.group_user'):
|
||||
redirect = b'/web?' + request.httprequest.query_string
|
||||
redirect = '/web?' + request.httprequest.query_string.decode()
|
||||
else:
|
||||
redirect = '/my'
|
||||
return super()._login_redirect(uid, redirect=redirect)
|
||||
|
||||
@@ -48,22 +48,46 @@ weSnippetEditor.Class.include({
|
||||
* if not already defined.
|
||||
*
|
||||
* @private
|
||||
* @param {boolean} [reconfigure=false]
|
||||
* @param {boolean} [onlyIfUndefined=false]
|
||||
* @param {boolean} [reconfigure=false] // TODO name is confusing "alwaysReconfigure" is better
|
||||
* @param {boolean} [onlyIfUndefined=false] // TODO name is confusing "configureIfNecessary" is better
|
||||
*/
|
||||
async _configureGMapAPI({reconfigure, onlyIfUndefined}) {
|
||||
if (!reconfigure && !onlyIfUndefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const apiKey = await new Promise(resolve => {
|
||||
this.getParent().trigger_up('gmap_api_key_request', {
|
||||
onSuccess: key => resolve(key),
|
||||
});
|
||||
});
|
||||
if (!reconfigure && (apiKey || !onlyIfUndefined)) {
|
||||
const apiKeyValidation = apiKey ? await this._validateGMapAPIKey(apiKey) : {
|
||||
isValid: false,
|
||||
message: undefined,
|
||||
};
|
||||
if (!reconfigure && onlyIfUndefined && apiKey && apiKeyValidation.isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let websiteId;
|
||||
this.trigger_up('context_get', {
|
||||
callback: ctx => websiteId = ctx['website_id'],
|
||||
});
|
||||
|
||||
function applyError(message) {
|
||||
const $apiKeyInput = this.find('#api_key_input');
|
||||
const $apiKeyHelp = this.find('#api_key_help');
|
||||
$apiKeyInput.addClass('is-invalid');
|
||||
$apiKeyHelp.empty().text(message);
|
||||
}
|
||||
|
||||
const $content = $(qweb.render('website.s_google_map_modal', {
|
||||
apiKey: apiKey,
|
||||
}));
|
||||
if (!apiKeyValidation.isValid && apiKeyValidation.message) {
|
||||
applyError.call($content, apiKeyValidation.message);
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let invalidated = false;
|
||||
const dialog = new Dialog(this, {
|
||||
@@ -71,55 +95,57 @@ weSnippetEditor.Class.include({
|
||||
title: _t("Google Map API Key"),
|
||||
buttons: [
|
||||
{text: _t("Save"), classes: 'btn-primary', click: async (ev) => {
|
||||
const $apiKeyInput = dialog.$('#api_key_input');
|
||||
const valueAPIKey = $apiKeyInput.val();
|
||||
const $apiKeyHelp = dialog.$('#api_key_help');
|
||||
const valueAPIKey = dialog.$('#api_key_input').val();
|
||||
if (!valueAPIKey) {
|
||||
$apiKeyInput.addClass('is-invalid');
|
||||
$apiKeyHelp.text(_t("Enter an API Key"));
|
||||
applyError.call(dialog.$el, _t("Enter an API Key"));
|
||||
return;
|
||||
}
|
||||
const $button = $(ev.currentTarget);
|
||||
$button.prop('disabled', true);
|
||||
try {
|
||||
const response = await fetch(`https://maps.googleapis.com/maps/api/staticmap?center=belgium&size=10x10&key=${valueAPIKey}`);
|
||||
if (response.status === 200) {
|
||||
await this._rpc({
|
||||
model: 'website',
|
||||
method: 'write',
|
||||
args: [
|
||||
[websiteId],
|
||||
{google_maps_api_key: valueAPIKey},
|
||||
],
|
||||
});
|
||||
invalidated = true;
|
||||
dialog.close();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
$apiKeyInput.addClass('is-invalid');
|
||||
$apiKeyHelp.empty().text(
|
||||
_t("Invalid API Key. The following error was returned by Google:")
|
||||
).append($('<i/>', {
|
||||
text: text,
|
||||
class: 'ml-1',
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
$apiKeyHelp.text(_t("Check your connection and try again"));
|
||||
} finally {
|
||||
$button.prop("disabled", false);
|
||||
const res = await this._validateGMapAPIKey(valueAPIKey);
|
||||
if (res.isValid) {
|
||||
await this._rpc({
|
||||
model: 'website',
|
||||
method: 'write',
|
||||
args: [
|
||||
[websiteId],
|
||||
{google_maps_api_key: valueAPIKey},
|
||||
],
|
||||
});
|
||||
invalidated = true;
|
||||
dialog.close();
|
||||
} else {
|
||||
applyError.call(dialog.$el, res.message);
|
||||
}
|
||||
$button.prop("disabled", false);
|
||||
}},
|
||||
{text: _t("Cancel"), close: true}
|
||||
],
|
||||
$content: $(qweb.render('website.s_google_map_modal', {
|
||||
apiKey: apiKey,
|
||||
})),
|
||||
$content: $content,
|
||||
});
|
||||
dialog.on('closed', this, () => resolve(invalidated));
|
||||
dialog.open();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _validateGMapAPIKey(key) {
|
||||
try {
|
||||
const response = await fetch(`https://maps.googleapis.com/maps/api/staticmap?center=belgium&size=10x10&key=${key}`);
|
||||
const isValid = (response.status === 200);
|
||||
return {
|
||||
isValid: isValid,
|
||||
message: !isValid &&
|
||||
_t("Invalid API Key. The following error was returned by Google:") + " " + (await response.text()),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: _t("Check your connection and try again"),
|
||||
};
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
|
||||
@@ -279,7 +279,7 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({
|
||||
});
|
||||
|
||||
const GPSPicker = InputUserValueWidget.extend({
|
||||
events: { // Explicitely not consider all InputUserValueWidget events
|
||||
events: { // Explicitly not consider all InputUserValueWidget events
|
||||
'blur input': '_onInputBlur',
|
||||
},
|
||||
|
||||
@@ -299,10 +299,20 @@ const GPSPicker = InputUserValueWidget.extend({
|
||||
this.trigger_up('gmap_api_request', {
|
||||
editableMode: true,
|
||||
configureIfNecessary: true,
|
||||
onSuccess: key => resolve(!!key),
|
||||
onSuccess: key => {
|
||||
if (!key) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO see _notifyGMapError, this tries to trigger an error
|
||||
// early but this is not consistent with new gmap keys.
|
||||
this._nearbySearch('(50.854975,4.3753899)', !!key)
|
||||
.then(place => resolve(!!place));
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!this._gmapLoaded) {
|
||||
if (!this._gmapLoaded && !this._gmapErrorNotified) {
|
||||
this.trigger_up('user_value_widget_critical');
|
||||
return;
|
||||
}
|
||||
@@ -336,17 +346,36 @@ const GPSPicker = InputUserValueWidget.extend({
|
||||
*/
|
||||
async setValue() {
|
||||
await this._super(...arguments);
|
||||
if (!this._gmapLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise(resolve => {
|
||||
const gps = this._value;
|
||||
if (this._gmapCacheGPSToPlace[gps]) {
|
||||
this._gmapPlace = this._gmapCacheGPSToPlace[gps];
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this._gmapPlace = await this._nearbySearch(this._value);
|
||||
|
||||
if (this._gmapPlace) {
|
||||
this.inputEl.value = this._gmapPlace.formatted_address;
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} gps
|
||||
* @param {boolean} [notify=true]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _nearbySearch(gps, notify = true) {
|
||||
if (this._gmapCacheGPSToPlace[gps]) {
|
||||
return this._gmapCacheGPSToPlace[gps];
|
||||
}
|
||||
|
||||
const p = gps.substring(1).slice(0, -1).split(',');
|
||||
const location = new google.maps.LatLng(p[0] || 0, p[1] || 0);
|
||||
return new Promise(resolve => {
|
||||
const service = new google.maps.places.PlacesService(document.createElement('div'));
|
||||
const p = gps.substring(1).slice(0, -1).split(',');
|
||||
const location = new google.maps.LatLng(p[0] || 0, p[1] || 0);
|
||||
service.nearbySearch({
|
||||
// Do a 'nearbySearch' followed by 'getDetails' to avoid using
|
||||
// GMap Geocoder which the user may not have enabled... but
|
||||
@@ -361,23 +390,59 @@ const GPSPicker = InputUserValueWidget.extend({
|
||||
placeId: results[0].place_id,
|
||||
fields: ['geometry', 'formatted_address'],
|
||||
}, (place, status) => {
|
||||
resolve();
|
||||
if (status === google.maps.places.PlacesServiceStatus.OK) {
|
||||
this._gmapCacheGPSToPlace[gps] = place;
|
||||
this._gmapPlace = place;
|
||||
resolve(place);
|
||||
} else if (GMAP_CRITICAL_ERRORS.includes(status)) {
|
||||
this.trigger_up('user_value_widget_critical');
|
||||
if (notify) {
|
||||
this._notifyGMapError();
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} else if (GMAP_CRITICAL_ERRORS.includes(status)) {
|
||||
if (notify) {
|
||||
this._notifyGMapError();
|
||||
}
|
||||
resolve();
|
||||
} else {
|
||||
resolve();
|
||||
this.trigger_up('user_value_widget_critical');
|
||||
}
|
||||
});
|
||||
});
|
||||
if (this._gmapPlace) {
|
||||
this.inputEl.value = this._gmapPlace.formatted_address;
|
||||
},
|
||||
/**
|
||||
* Indicates to the user there is an error with the google map API and
|
||||
* re-opens the configuration dialog. For good measures, this also notifies
|
||||
* a critical error which normally removes the related snippet entirely.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_notifyGMapError() {
|
||||
// TODO this should be better to detect all errors. This is random.
|
||||
// When misconfigured (wrong APIs enabled), sometimes Google throw
|
||||
// errors immediately (which then reaches this code), sometimes it
|
||||
// throws them later (which then induces an error log in the console
|
||||
// and random behaviors).
|
||||
if (this._gmapErrorNotified) {
|
||||
return;
|
||||
}
|
||||
this._gmapErrorNotified = true;
|
||||
|
||||
this.displayNotification({
|
||||
type: 'danger',
|
||||
sticky: true,
|
||||
message: _t("A Google Map error occurred. Make sure to read the key configuration popup carefully."),
|
||||
});
|
||||
this.trigger_up('gmap_api_request', {
|
||||
editableMode: true,
|
||||
reconfigure: true,
|
||||
onSuccess: () => {
|
||||
this._gmapErrorNotified = false;
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => this.trigger_up('user_value_widget_critical'));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -393,11 +458,25 @@ const GPSPicker = InputUserValueWidget.extend({
|
||||
if (gmapPlace && gmapPlace.geometry) {
|
||||
this._gmapPlace = gmapPlace;
|
||||
const location = this._gmapPlace.geometry.location;
|
||||
const oldValue = this._value;
|
||||
this._value = `(${location.lat()},${location.lng()})`;
|
||||
this._gmapCacheGPSToPlace[this._value] = gmapPlace;
|
||||
this._onUserValueChange(ev);
|
||||
if (oldValue !== this._value) {
|
||||
this._onUserValueChange(ev);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_onInputBlur() {
|
||||
// As a stable fix: do not call the _super as we actually don't want
|
||||
// input focusout messing with the google map API. Because of this,
|
||||
// clicking on google map autocomplete suggestion on Firefox was not
|
||||
// working properly. This is kept as an empty function because of stable
|
||||
// policy (ensures custo can still extend this).
|
||||
// TODO review in master.
|
||||
},
|
||||
});
|
||||
|
||||
options.userValueWidgetsRegistry['we-urlpicker'] = UrlPickerUserValueWidget;
|
||||
|
||||
@@ -66,7 +66,7 @@ publicWidget.registry.GoogleMap = publicWidget.Widget.extend({
|
||||
map.setCenter(gps);
|
||||
|
||||
// Update Map on screen resize
|
||||
google.maps.event.addDomListener(window, 'resize', () => {
|
||||
window.addEventListener('resize', () => {
|
||||
map.setCenter(gps);
|
||||
});
|
||||
|
||||
|
||||
@@ -143,6 +143,25 @@
|
||||
Enable billing on your Google Project
|
||||
</a>
|
||||
</div>
|
||||
<div class="alert alert-info mb-0 mt-3">
|
||||
Make sure your settings are properly configured:
|
||||
<ul class="mb-0">
|
||||
<li>
|
||||
Enable the right google map APIs in your google account
|
||||
<ul>
|
||||
<li>Maps Static API</li>
|
||||
<li>Maps JavaScript API</li>
|
||||
<li>Places API</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Make sure billing is enabled
|
||||
</li>
|
||||
<li>
|
||||
Make sure to wait if errors keep being shown: sometimes enabling an API allows to use it immediately but Google keeps triggering errors for a while
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user