diff --git a/addons/account/models/product.py b/addons/account/models/product.py
index 5d9d80c1e1..bb0c230a29 100644
--- a/addons/account/models/product.py
+++ b/addons/account/models/product.py
@@ -99,20 +99,20 @@ class ProductProduct(models.Model):
# Apply fiscal position.
if product_taxes and fiscal_position:
product_taxes_after_fp = fiscal_position.map_tax(product_taxes)
+ flattened_taxes_after_fp = product_taxes_after_fp._origin.flatten_taxes_hierarchy()
+ flattened_taxes_before_fp = product_taxes._origin.flatten_taxes_hierarchy()
+ taxes_before_included = any(tax.price_include for tax in flattened_taxes_before_fp)
- if set(product_taxes.ids) != set(product_taxes_after_fp.ids):
- flattened_taxes_before_fp = product_taxes._origin.flatten_taxes_hierarchy()
- if any(tax.price_include for tax in flattened_taxes_before_fp):
- taxes_res = flattened_taxes_before_fp.compute_all(
- product_price_unit,
- quantity=1.0,
- currency=currency,
- product=product,
- is_refund=is_refund_document,
- )
- product_price_unit = taxes_res['total_excluded']
+ if set(product_taxes.ids) != set(product_taxes_after_fp.ids) and taxes_before_included:
+ taxes_res = flattened_taxes_before_fp.compute_all(
+ product_price_unit,
+ quantity=1.0,
+ currency=currency,
+ product=product,
+ is_refund=is_refund_document,
+ )
+ product_price_unit = taxes_res['total_excluded']
- flattened_taxes_after_fp = product_taxes_after_fp._origin.flatten_taxes_hierarchy()
if any(tax.price_include for tax in flattened_taxes_after_fp):
taxes_res = flattened_taxes_after_fp.compute_all(
product_price_unit,
diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py
index 000d477360..8b61aec221 100644
--- a/addons/calendar/models/calendar_event.py
+++ b/addons/calendar/models/calendar_event.py
@@ -3,6 +3,8 @@
from datetime import timedelta
import math
+from itertools import repeat
+
import babel.dates
import logging
import pytz
@@ -116,7 +118,7 @@ class Meeting(models.Model):
return self._get_recurrent_fields() | self._get_time_fields() | self._get_custom_fields() | {
'id', 'active', 'allday',
'duration', 'user_id', 'interval',
- 'count', 'rrule', 'recurrence_id', 'show_as'}
+ 'count', 'rrule', 'recurrence_id', 'show_as', 'privacy'}
@api.model
def _get_display_time(self, start, stop, zduration, zallday):
@@ -742,51 +744,33 @@ class Meeting(models.Model):
self.env['calendar.alarm_manager']._notify_next_alarm(event.partner_ids.ids)
return events
- def read(self, fields=None, load='_classic_read'):
- def hide(field, value):
- """
- :param field: field name
- :param value: field value
- :return: obfuscated field value
- """
- if field in {'name', 'display_name'}:
- return _('Busy')
- return [] if isinstance(value, list) else False
+ def _read(self, fields):
+ if self.env.is_system():
+ super()._read(fields)
+ return
- def split_privacy(events):
- """
- :param events: list of event values (dict)
- :return: tuple(private events, public events)
- """
- private = [event for event in events if event.get('privacy') == 'private']
- public = [event for event in events if event.get('privacy') != 'private']
- return private, public
+ fields = set(fields)
+ private_fields = fields - self._get_public_fields()
+ if not private_fields:
+ super()._read(fields)
+ return
- def my_events(events):
- """
- :param events: list of event values (dict)
- :return: tuple(my events, other events)
- """
- my = [event for event in events if event.get('user_id') and event.get('user_id')[0] == self.env.uid]
- others = [event for event in events if not event.get('user_id') or event.get('user_id')[0] != self.env.uid]
- return my, others
+ super()._read(fields | {'privacy', 'user_id', 'partner_ids'})
+ current_partner_id = self.env.user.partner_id
+ others_private_events = self.filtered(
+ lambda e: e.privacy == 'private' \
+ and e.user_id != self.env.user \
+ and current_partner_id not in e.partner_ids
+ )
+ if not others_private_events:
+ return
- def obfuscated(events):
- """
- :param events: list of event values (dict)
- :return: events with private field values obfuscated
- """
- public_fields = self._get_public_fields()
- return [{
- field: hide(field, value) if field not in public_fields else value
- for field, value in event.items()
- } for event in events]
-
- events = super().read(fields=fields + ['privacy', 'user_id'], load=load)
- private_events, public_events = split_privacy(events)
- my_private_events, others_private_events = my_events(private_events)
-
- return public_events + my_private_events + obfuscated(others_private_events)
+ for field_name in private_fields:
+ field = self._fields[field_name]
+ replacement = field.convert_to_cache(
+ _('Busy') if field_name == 'name' else False,
+ others_private_events)
+ self.env.cache.update(others_private_events, field, repeat(replacement))
def name_get(self):
""" Hide private events' name for events which don't belong to the current user
diff --git a/addons/calendar/tests/test_access_rights.py b/addons/calendar/tests/test_access_rights.py
index a4e5c95a05..b5a0ed8072 100644
--- a/addons/calendar/tests/test_access_rights.py
+++ b/addons/calendar/tests/test_access_rights.py
@@ -5,54 +5,82 @@ from datetime import datetime
from flectra.tests.common import SavepointCase, new_test_user
from flectra.exceptions import AccessError
+from flectra.tools import mute_logger
class TestAccessRights(SavepointCase):
@classmethod
+ @mute_logger('flectra.tests', 'flectra.addons.auth_signup.models.res_users')
def setUpClass(cls):
super().setUpClass()
cls.john = new_test_user(cls.env, login='john', groups='base.group_user')
cls.raoul = new_test_user(cls.env, login='raoul', groups='base.group_user')
+ cls.george = new_test_user(cls.env, login='george', groups='base.group_user')
cls.portal = new_test_user(cls.env, login='pot', groups='base.group_portal')
def create_event(self, user, **values):
- return self.env['calendar.event'].with_user(user).create(dict({
+ e = self.env['calendar.event'].with_user(user).create(dict({
'name': 'Event',
'start': datetime(2020, 2, 2, 8, 0),
'stop': datetime(2020, 2, 2, 18, 0),
'user_id': user.id,
}, **values))
+ e.partner_ids += self.george.partner_id
+ return e
def read_event(self, user, events, field):
data = events.with_user(user).read([field])
if len(events) == 1:
return data[0][field]
- mapped_data = {record['id']: record for record in data}
- # Keep the same order
- return [mapped_data[eid][field] for eid in events.ids]
+ return [r[field] for r in data]
- def test_private_read_name(self):
+ # don't spam logs with ACL failures from portal
+ @mute_logger('flectra.addons.base.models.ir_rule')
+ def test_privacy(self):
event = self.create_event(
self.john,
privacy='private',
name='my private event',
+ location='in the Sky'
)
- self.assertEqual(self.read_event(self.john, event, 'name'), 'my private event', "Owner should be able to read the event")
- self.assertEqual(self.read_event(self.raoul, event, 'name'), 'Busy', "Private value should be obfuscated")
- with self.assertRaises(AccessError):
- self.read_event(self.portal, event, 'name')
-
- def test_private_other_field(self):
- event = self.create_event(
- self.john,
- privacy='private',
- location='in the Sky',
- )
- self.assertEqual(self.read_event(self.john, event, 'location'), 'in the Sky', "Owner should be able to read the event")
- self.assertEqual(self.read_event(self.raoul, event, 'location'), False, "Private value should be obfuscated")
- with self.assertRaises(AccessError):
- self.read_event(self.portal, event, 'location')
+ for user, field, expect, error in [
+ # public field, any employee can read
+ (self.john, 'privacy', 'private', None),
+ (self.george, 'privacy', 'private', None),
+ (self.raoul, 'privacy', 'private', None),
+ (self.portal, 'privacy', None, AccessError),
+ # substituted private field, only owner and invitees can read, other
+ # employees get substitution
+ (self.john, 'name', 'my private event', None),
+ (self.george, 'name', 'my private event', None),
+ (self.raoul, 'name', 'Busy', None),
+ (self.portal, 'name', None, AccessError),
+ # computed from private field
+ (self.john, 'display_name', 'my private event', None),
+ (self.george, 'display_name', 'my private event', None),
+ (self.raoul, 'display_name', 'Busy', None),
+ (self.portal, 'display_name', None, AccessError),
+ # non-substituted private field, only owner and invitees can read,
+ # other employees get an empty field
+ (self.john, 'location', 'in the Sky', None),
+ (self.george, 'location', 'in the Sky', None),
+ (self.raoul, 'location', False, None),
+ (self.portal, 'location', None, AccessError),
+ # non-substituted sequence field
+ (self.john, 'partner_ids', self.john.partner_id | self.george.partner_id, None),
+ (self.george, 'partner_ids', self.john.partner_id | self.george.partner_id, None),
+ (self.raoul, 'partner_ids', self.env['res.partner'], None),
+ (self.portal, 'partner_ids', None, AccessError),
+ ]:
+ event.invalidate_cache()
+ with self.subTest("private read", user=user.display_name, field=field, error=error):
+ e = event.with_user(user)
+ if error:
+ with self.assertRaises(error):
+ _ = e[field]
+ else:
+ self.assertEqual(e[field], expect)
def test_private_and_public(self):
private = self.create_event(
diff --git a/addons/event/models/event_event.py b/addons/event/models/event_event.py
index 5c745b30d8..231a61dfef 100644
--- a/addons/event/models/event_event.py
+++ b/addons/event/models/event_event.py
@@ -103,7 +103,9 @@ class EventEvent(models.Model):
return event_stages[0] if event_stages else False
def _default_description(self):
- return self.env['ir.ui.view']._render_template('event.event_default_descripton')
+ # avoid template branding with rendering_bundle=True
+ return self.env['ir.ui.view'].with_context(rendering_bundle=True) \
+ ._render_template('event.event_default_descripton')
name = fields.Char(string='Event', translate=True, required=True)
note = fields.Text(string='Note')
diff --git a/addons/gamification/models/gamification_karma_rank.py b/addons/gamification/models/gamification_karma_rank.py
index fe67c3dd3f..90f2949276 100644
--- a/addons/gamification/models/gamification_karma_rank.py
+++ b/addons/gamification/models/gamification_karma_rank.py
@@ -35,23 +35,25 @@ class KarmaRank(models.Model):
@api.model_create_multi
def create(self, values_list):
res = super(KarmaRank, self).create(values_list)
- users = self.env['res.users'].sudo().search([('karma', '>', 0)])
- users._recompute_rank()
+ if any(res.mapped('karma_min')) > 0:
+ users = self.env['res.users'].sudo().search([('karma', '>=', max(min(res.mapped('karma_min')), 1))])
+ if users:
+ users._recompute_rank()
return res
def write(self, vals):
if 'karma_min' in vals:
previous_ranks = self.env['gamification.karma.rank'].search([], order="karma_min DESC").ids
- low = min(vals['karma_min'], self.karma_min)
- high = max(vals['karma_min'], self.karma_min)
+ low = min(vals['karma_min'], min(self.mapped('karma_min')))
+ high = max(vals['karma_min'], max(self.mapped('karma_min')))
res = super(KarmaRank, self).write(vals)
if 'karma_min' in vals:
after_ranks = self.env['gamification.karma.rank'].search([], order="karma_min DESC").ids
if previous_ranks != after_ranks:
- users = self.env['res.users'].sudo().search([('karma', '>', 0)])
+ users = self.env['res.users'].sudo().search([('karma', '>=', max(low, 1))])
else:
- users = self.env['res.users'].sudo().search([('karma', '>=', low), ('karma', '<=', high)])
+ users = self.env['res.users'].sudo().search([('karma', '>=', max(low, 1)), ('karma', '<=', high)])
users._recompute_rank()
return res
diff --git a/addons/gamification/models/res_users.py b/addons/gamification/models/res_users.py
index 7c9c32a785..64dca5b4db 100644
--- a/addons/gamification/models/res_users.py
+++ b/addons/gamification/models/res_users.py
@@ -180,6 +180,10 @@ WHERE sub.user_id IN %%s""" % {
"""
Method that can be called on a batch of users with the same new rank
"""
+ if self.env.context.get('install_mode', False):
+ # avoid sending emails in install mode (prevents spamming users when creating data ranks)
+ return
+
template = self.env.ref('gamification.mail_template_data_new_rank_reached', raise_if_not_found=False)
if template:
for u in self:
diff --git a/addons/l10n_ch/report/swissqr_report.xml b/addons/l10n_ch/report/swissqr_report.xml
index 1ceb71d2e1..15022fe9fb 100644
--- a/addons/l10n_ch/report/swissqr_report.xml
+++ b/addons/l10n_ch/report/swissqr_report.xml
@@ -22,8 +22,8 @@
-
-
+
+
padding-top:6.2mm; padding-left:8.2mm; padding-right:8.2mm;
@@ -209,5 +209,10 @@
+
+
+ report_xml_id
+
+
diff --git a/addons/l10n_ch/static/src/scss/report_swissqr.scss b/addons/l10n_ch/static/src/scss/report_swissqr.scss
index 38592ed356..1b3f8d3ce8 100644
--- a/addons/l10n_ch/static/src/scss/report_swissqr.scss
+++ b/addons/l10n_ch/static/src/scss/report_swissqr.scss
@@ -8,8 +8,7 @@
src: url('../font/LiberationSans-Regular.woff') format('woff');
}
-
-body.l10n_ch_qr {
+body.l10n_ch_qr, body[data-oe-report="l10n_ch.l10n_ch_qr_report"] {
padding: 0;
/* Disable custom bakground */
diff --git a/addons/l10n_it_edi/data/invoice_it_template.xml b/addons/l10n_it_edi/data/invoice_it_template.xml
index 82ea7880da..874e4a574d 100644
--- a/addons/l10n_it_edi/data/invoice_it_template.xml
+++ b/addons/l10n_it_edi/data/invoice_it_template.xml
@@ -8,18 +8,18 @@
EAN
-
+
INTERNAL
-
+
-
+
-
+
@@ -36,9 +36,9 @@
-
+
-
+
@@ -54,14 +54,14 @@
-
+
-
-
+
+
@@ -74,7 +74,7 @@
-
+
@@ -84,7 +84,7 @@
-
+
@@ -99,9 +99,9 @@
-
-
-
+
+
+
@@ -122,9 +122,9 @@
-
-
-
+
+
+
@@ -139,7 +139,7 @@
-
+
SI
@@ -147,11 +147,11 @@
-
+
-
+
@@ -172,7 +172,7 @@
-
+
@@ -185,15 +185,15 @@
MP05
-
+
-
+
-
+
PDF
diff --git a/addons/l10n_it_edi/models/account_invoice.py b/addons/l10n_it_edi/models/account_invoice.py
index 9876569cd2..bf3e29e19f 100644
--- a/addons/l10n_it_edi/models/account_invoice.py
+++ b/addons/l10n_it_edi/models/account_invoice.py
@@ -142,6 +142,9 @@ class AccountMove(models.Model):
return True
return False
+ def format_alphanumeric(text_to_convert):
+ return text_to_convert.encode('latin-1', 'replace').decode('latin-1') if text_to_convert else False
+
formato_trasmissione = "FPA12" if self._is_commercial_partner_pa() else "FPR12"
if self.move_type == 'out_invoice':
@@ -184,6 +187,7 @@ class AccountMove(models.Model):
'format_numbers': format_numbers,
'format_numbers_two': format_numbers_two,
'format_phone': format_phone,
+ 'format_alphanumeric': format_alphanumeric,
'discount_type': discount_type,
'get_vat_number': get_vat_number,
'get_vat_country': get_vat_country,
diff --git a/addons/l10n_it_edi/models/ir_mail_server.py b/addons/l10n_it_edi/models/ir_mail_server.py
index 16d71ac456..9e22c88cb9 100644
--- a/addons/l10n_it_edi/models/ir_mail_server.py
+++ b/addons/l10n_it_edi/models/ir_mail_server.py
@@ -84,9 +84,9 @@ class FetchmailServer(models.Model):
# To leave the mail in the state in which they were.
if "Seen" not in data[1].decode("utf-8"):
- imap_server.uid('STORE', uid, '+FLAGS', '\\Seen')
+ imap_server.store(uid, '+FLAGS', '\\Seen')
else:
- imap_server.uid('STORE', uid, '-FLAGS', '\\Seen')
+ imap_server.store(uid, '-FLAGS', '\\Seen')
# See details in message_process() in mail_thread.py
if isinstance(message, xmlrpclib.Binary):
diff --git a/addons/l10n_it_edi_sdicoop/tests/expected_xmls/IT00470550013_basis.xml b/addons/l10n_it_edi_sdicoop/tests/expected_xmls/IT00470550013_basis.xml
index 56c49b8793..7930605299 100644
--- a/addons/l10n_it_edi_sdicoop/tests/expected_xmls/IT00470550013_basis.xml
+++ b/addons/l10n_it_edi_sdicoop/tests/expected_xmls/IT00470550013_basis.xml
@@ -37,8 +37,7 @@
00465840031
- Alessi
-
+ Alessi
diff --git a/addons/l10n_it_edi_sdicoop/tests/test_edi_xml.py b/addons/l10n_it_edi_sdicoop/tests/test_edi_xml.py
index 3e4675adfe..a9f8aa1098 100644
--- a/addons/l10n_it_edi_sdicoop/tests/test_edi_xml.py
+++ b/addons/l10n_it_edi_sdicoop/tests/test_edi_xml.py
@@ -55,6 +55,7 @@ class TestItEdi(AccountEdiTestCommon):
'zip': '28887',
'city': 'Milan',
'company_id': cls.company.id,
+ 'is_company': True,
})
cls.standard_line = {
@@ -124,6 +125,27 @@ class TestItEdi(AccountEdiTestCommon):
],
})
+ cls.non_latin_and_latin_invoice = cls.env['account.move'].with_company(cls.company).create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': datetime.date(2022, 3, 24),
+ 'partner_id': cls.italian_partner_a.id,
+ 'partner_bank_id': cls.test_bank.id,
+ 'invoice_line_ids': [
+ (0, 0, {
+ **cls.standard_line,
+ 'name': 'ʢ◉ᴥ◉ʡ',
+ }),
+ (0, 0, {
+ **cls.standard_line,
+ 'name': '–-',
+ }),
+ (0, 0, {
+ **cls.standard_line,
+ 'name': 'this should be the same as it was',
+ }),
+ ],
+ })
+
# We create this because we are unable to post without a proxy user existing
cls.proxy_user = cls.env['account_edi_proxy_client.user'].create({
'id_client': 'l10n_it_edi_sdicoop_test',
@@ -137,6 +159,7 @@ class TestItEdi(AccountEdiTestCommon):
cls.price_included_invoice._post()
cls.partial_discount_invoice._post()
cls.full_discount_invoice._post()
+ cls.non_latin_and_latin_invoice._post()
cls.edi_basis_xml = cls._get_test_file_content('IT00470550013_basis.xml')
@@ -316,3 +339,50 @@ class TestItEdi(AccountEdiTestCommon):
''')
invoice_etree = self.with_applied_xpath(invoice_etree, "")
self.assertXmlTreeEqual(invoice_etree, expected_etree)
+
+ @freeze_time('2020-03-24')
+ def test_non_latin_and_latin_inovice(self):
+ invoice_etree = etree.fromstring(self.non_latin_and_latin_invoice._export_as_xml())
+ expected_etree = self.with_applied_xpath(
+ etree.fromstring(self.edi_basis_xml),
+ '''
+
+
+
+ 1
+ ?????
+ 1.00
+ 800.400000
+ 800.40
+ 22.00
+
+
+ 2
+ ?-
+ 1.00
+ 800.400000
+ 800.40
+ 22.00
+
+
+ 3
+ this should be the same as it was
+ 1.00
+ 800.400000
+ 800.40
+ 22.00
+
+
+ 22.00
+ 2401.20
+ 528.27
+ I
+
+
+
+
+ 2929.47
+
+ ''')
+ invoice_etree = self.with_applied_xpath(invoice_etree, "")
+ self.assertXmlTreeEqual(invoice_etree, expected_etree)
diff --git a/addons/mrp/models/mrp_document.py b/addons/mrp/models/mrp_document.py
index 4a4646d298..d8eceb5ca1 100644
--- a/addons/mrp/models/mrp_document.py
+++ b/addons/mrp/models/mrp_document.py
@@ -20,7 +20,7 @@ class MrpDocument(models.Model):
if ir_default:
ir_fields = list(self.env['ir.attachment']._fields)
ir_default = {field : default[field] for field in default.keys() if field in ir_fields}
- new_attach = self.ir_attachment_id.copy(ir_default)
+ new_attach = self.ir_attachment_id.with_context(no_document=True).copy(ir_default)
return super().copy(dict(default, ir_attachment_id=new_attach.id))
ir_attachment_id = fields.Many2one('ir.attachment', string='Related attachment', required=True, ondelete='cascade')
diff --git a/addons/mrp/wizard/change_production_qty.py b/addons/mrp/wizard/change_production_qty.py
index a2bc720b24..3b670f5a26 100644
--- a/addons/mrp/wizard/change_production_qty.py
+++ b/addons/mrp/wizard/change_production_qty.py
@@ -36,9 +36,19 @@ class ChangeProductionQty(models.TransientModel):
for move in production.move_finished_ids:
if move.state in ('done', 'cancel'):
continue
- qty = (new_qty - old_qty) * move.unit_factor
+ done_qty = sum(production.move_finished_ids.filtered(
+ lambda r:
+ r.product_id == move.product_id and
+ r.state == 'done'
+ ).mapped('product_uom_qty')
+ )
+ qty = (new_qty - old_qty) * move.unit_factor + done_qty
modification[move] = (move.product_uom_qty + qty, move.product_uom_qty)
- move.write({'product_uom_qty': move.product_uom_qty + qty})
+ if (move.product_uom_qty + qty) > 0:
+ move.write({'product_uom_qty': move.product_uom_qty + qty})
+ else:
+ move._action_cancel()
+
return modification
def change_prod_qty(self):
diff --git a/addons/sale/tests/test_onchange.py b/addons/sale/tests/test_onchange.py
index 3c09955afc..339c691745 100644
--- a/addons/sale/tests/test_onchange.py
+++ b/addons/sale/tests/test_onchange.py
@@ -77,49 +77,139 @@ class TestOnchangeProductId(TransactionCase):
'name': "George"
})
tax_include_src = self.tax_model.create({
- 'name': "Include tax",
+ 'name': "Include 21%",
'amount': 21.00,
+ 'amount_type': 'percent',
'price_include': True,
})
tax_include_dst = self.tax_model.create({
- 'name': "Exclude tax",
+ 'name': "Include 6%",
'amount': 6.00,
+ 'amount_type': 'percent',
'price_include': True,
})
-
- product_tmpl = self.product_tmpl_model.create({
+ tax_exclude_src = self.tax_model.create({
+ 'name': "Exclude 15%",
+ 'amount': 15.00,
+ 'amount_type': 'percent',
+ 'price_include': False,
+ })
+ tax_exclude_dst = self.tax_model.create({
+ 'name': "Exclude 21%",
+ 'amount': 21.00,
+ 'amount_type': 'percent',
+ 'price_include': False,
+ })
+ product_tmpl_a = self.product_tmpl_model.create({
'name': "Voiture",
'list_price': 121,
'taxes_id': [(6, 0, [tax_include_src.id])]
})
- product_product = product_tmpl.product_variant_id
+ product_tmpl_b = self.product_tmpl_model.create({
+ 'name': "Voiture",
+ 'list_price': 100,
+ 'taxes_id': [(6, 0, [tax_exclude_src.id])]
+ })
- fpos = self.fiscal_position_model.create({
- 'name': "fiscal position",
+ fpos_incl_incl = self.fiscal_position_model.create({
+ 'name': "incl -> incl",
'sequence': 1
})
- fpos_tax = self.fiscal_position_tax_model.create({
- 'position_id' :fpos.id,
+ self.fiscal_position_tax_model.create({
+ 'position_id' :fpos_incl_incl.id,
'tax_src_id': tax_include_src.id,
'tax_dest_id': tax_include_dst.id
})
+ fpos_excl_incl = self.fiscal_position_model.create({
+ 'name': "excl -> incl",
+ 'sequence': 2,
+ })
+
+ self.fiscal_position_tax_model.create({
+ 'position_id' :fpos_excl_incl.id,
+ 'tax_src_id': tax_exclude_src.id,
+ 'tax_dest_id': tax_include_dst.id
+ })
+
+ fpos_incl_excl = self.fiscal_position_model.create({
+ 'name': "incl -> excl",
+ 'sequence': 3,
+ })
+
+ self.fiscal_position_tax_model.create({
+ 'position_id' :fpos_incl_excl.id,
+ 'tax_src_id': tax_include_src.id,
+ 'tax_dest_id': tax_exclude_dst.id
+ })
+
+ fpos_excl_excl = self.fiscal_position_model.create({
+ 'name': "excl -> excp",
+ 'sequence': 4,
+ })
+
+ self.fiscal_position_tax_model.create({
+ 'position_id' :fpos_excl_excl.id,
+ 'tax_src_id': tax_exclude_src.id,
+ 'tax_dest_id': tax_exclude_dst.id
+ })
+
# Create the SO with one SO line and apply a pricelist and fiscal position on it
+ # Then check if price unit and price subtotal matches the expected values
+
+ # Test Mapping included to included
order_form = Form(self.env['sale.order'].with_context(tracking_disable=True))
order_form.partner_id = partner
order_form.pricelist_id = pricelist
- order_form.fiscal_position_id = fpos
+ order_form.fiscal_position_id = fpos_incl_incl
with order_form.order_line.new() as line:
- line.name = product_product.name
- line.product_id = product_product
+ line.name = product_tmpl_a.product_variant_id.name
+ line.product_id = product_tmpl_a.product_variant_id
line.product_uom_qty = 1.0
line.product_uom = uom
sale_order = order_form.save()
+ self.assertRecordValues(sale_order.order_line, [{'price_unit': 106, 'price_subtotal': 100}])
- # Check the unit price of SO line
- self.assertRecordValues(sale_order.order_line, [{'price_unit': 106}])
+ # Test Mapping excluded to included
+ order_form = Form(self.env['sale.order'].with_context(tracking_disable=True))
+ order_form.partner_id = partner
+ order_form.pricelist_id = pricelist
+ order_form.fiscal_position_id = fpos_excl_incl
+ with order_form.order_line.new() as line:
+ line.name = product_tmpl_b.product_variant_id.name
+ line.product_id = product_tmpl_b.product_variant_id
+ line.product_uom_qty = 1.0
+ line.product_uom = uom
+ sale_order = order_form.save()
+ self.assertRecordValues(sale_order.order_line, [{'price_unit': 100, 'price_subtotal': 94.34}])
+
+ # Test Mapping included to excluded
+ order_form = Form(self.env['sale.order'].with_context(tracking_disable=True))
+ order_form.partner_id = partner
+ order_form.pricelist_id = pricelist
+ order_form.fiscal_position_id = fpos_incl_excl
+ with order_form.order_line.new() as line:
+ line.name = product_tmpl_a.product_variant_id.name
+ line.product_id = product_tmpl_a.product_variant_id
+ line.product_uom_qty = 1.0
+ line.product_uom = uom
+ sale_order = order_form.save()
+ self.assertRecordValues(sale_order.order_line, [{'price_unit': 100, 'price_subtotal': 100}])
+
+ # Test Mapping excluded to excluded
+ order_form = Form(self.env['sale.order'].with_context(tracking_disable=True))
+ order_form.partner_id = partner
+ order_form.pricelist_id = pricelist
+ order_form.fiscal_position_id = fpos_excl_excl
+ with order_form.order_line.new() as line:
+ line.name = product_tmpl_b.product_variant_id.name
+ line.product_id = product_tmpl_b.product_variant_id
+ line.product_uom_qty = 1.0
+ line.product_uom = uom
+ sale_order = order_form.save()
+ self.assertRecordValues(sale_order.order_line, [{'price_unit': 100, 'price_subtotal': 100}])
def test_pricelist_application(self):
""" Test different prices are correctly applied based on dates """
diff --git a/addons/sale_mrp/tests/test_sale_mrp_kit_bom.py b/addons/sale_mrp/tests/test_sale_mrp_kit_bom.py
index 67c3e9adfa..68e1e0b571 100644
--- a/addons/sale_mrp/tests/test_sale_mrp_kit_bom.py
+++ b/addons/sale_mrp/tests/test_sale_mrp_kit_bom.py
@@ -409,3 +409,92 @@ class TestSaleMrpKitBom(TransactionCase):
# Checks the delivery amount (must be 1).
self.assertEqual(so.order_line.qty_delivered, 1)
+
+ def test_qty_delivered_with_bom_using_kit2(self):
+ """Create 2 kits products that have common components and activate 2 steps delivery
+ Then create a sale order with these 2 products, and put everything in a pack in
+ the first step of the delivery. After the shipping is done, check the done quantity
+ is correct for each products.
+ """
+
+ wh = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1)
+ wh.write({'delivery_steps': 'pick_ship'})
+
+ kitAB = self._create_product('Kit AB', 'product', 0.00)
+ kitABC = self._create_product('Kit ABC', 'product', 0.00)
+ compA = self._create_product('ComponentA', 'product', 0.00)
+ compB = self._create_product('ComponentB', 'product', 0.00)
+ compC = self._create_product('ComponentC', 'product', 0.00)
+
+ # Create BoM for KitB
+ bom_product_formA = Form(self.env['mrp.bom'])
+ bom_product_formA.product_id = kitAB
+ bom_product_formA.product_tmpl_id = kitAB.product_tmpl_id
+ bom_product_formA.product_qty = 1.0
+ bom_product_formA.type = 'phantom'
+ with bom_product_formA.bom_line_ids.new() as bom_line:
+ bom_line.product_id = compA
+ bom_line.product_qty = 1
+ with bom_product_formA.bom_line_ids.new() as bom_line:
+ bom_line.product_id = compB
+ bom_line.product_qty = 1
+ bom_product_formA.save()
+
+ # Create BoM for KitA
+ bom_product_formB = Form(self.env['mrp.bom'])
+ bom_product_formB.product_id = kitABC
+ bom_product_formB.product_tmpl_id = kitABC.product_tmpl_id
+ bom_product_formB.product_qty = 1.0
+ bom_product_formB.type = 'phantom'
+ with bom_product_formB.bom_line_ids.new() as bom_line:
+ bom_line.product_id = compA
+ bom_line.product_qty = 1
+ with bom_product_formB.bom_line_ids.new() as bom_line:
+ bom_line.product_id = compB
+ bom_line.product_qty = 1
+ with bom_product_formB.bom_line_ids.new() as bom_line:
+ bom_line.product_id = compC
+ bom_line.product_qty = 1
+ bom_product_formB.save()
+
+ customer = self.env['res.partner'].create({
+ 'name': 'customer',
+ })
+
+ so = self.env['sale.order'].create({
+ 'partner_id': customer.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': kitAB.name,
+ 'product_id': kitAB.id,
+ 'product_uom_qty': 1.0,
+ 'product_uom': kitAB.uom_id.id,
+ 'price_unit': 1,
+ 'tax_id': False,
+ }),
+ (0, 0, {
+ 'name': kitABC.name,
+ 'product_id': kitABC.id,
+ 'product_uom_qty': 1.0,
+ 'product_uom': kitABC.uom_id.id,
+ 'price_unit': 1,
+ 'tax_id': False,
+ })],
+ })
+ so.action_confirm()
+
+ pick = so.picking_ids[0]
+ ship = so.picking_ids[1]
+
+ pick.move_lines[0].quantity_done = 2
+ pick.move_lines[1].quantity_done = 2
+ pick.move_lines[2].quantity_done = 1
+
+ pick.action_put_in_pack()
+ pick.button_validate()
+
+ ship.package_level_ids.write({'is_done': True})
+ ship.package_level_ids._set_is_done()
+
+ for move in ship.move_line_ids:
+ self.assertEqual(move.product_uom_qty, move.qty_done, "Quantity done should be equal to the quantity reserved in the move line")
diff --git a/addons/stock/models/stock_package_level.py b/addons/stock/models/stock_package_level.py
index dbbba458bb..0783c4198d 100644
--- a/addons/stock/models/stock_package_level.py
+++ b/addons/stock/models/stock_package_level.py
@@ -6,6 +6,7 @@ from operator import itemgetter
from collections import defaultdict
from flectra import _, api, fields, models
+from flectra.tools.float_utils import float_is_zero
class StockPackageLevel(models.Model):
@@ -54,9 +55,15 @@ class StockPackageLevel(models.Model):
if not package_level.is_fresh_package:
ml_update_dict = defaultdict(float)
for quant in package_level.package_id.quant_ids:
- corresponding_ml = package_level.move_line_ids.filtered(lambda ml: ml.product_id == quant.product_id and ml.lot_id == quant.lot_id)
- if corresponding_ml:
- ml_update_dict[corresponding_ml[0]] += quant.quantity
+ corresponding_mls = package_level.move_line_ids.filtered(lambda ml: ml.product_id == quant.product_id and ml.lot_id == quant.lot_id)
+ to_dispatch = quant.quantity
+ if corresponding_mls:
+ for ml in corresponding_mls:
+ qty = min(to_dispatch, ml.product_qty) if len(corresponding_mls) > 1 else to_dispatch
+ to_dispatch = to_dispatch - qty
+ ml_update_dict[ml] += qty
+ if float_is_zero(to_dispatch, precision_rounding=ml.product_uom_id.rounding):
+ break
else:
corresponding_move = package_level.move_ids.filtered(lambda m: m.product_id == quant.product_id)[:1]
self.env['stock.move.line'].create({
diff --git a/addons/web/static/src/scss/modal.scss b/addons/web/static/src/scss/modal.scss
index d5faa77177..7518fe24ba 100644
--- a/addons/web/static/src/scss/modal.scss
+++ b/addons/web/static/src/scss/modal.scss
@@ -8,7 +8,9 @@
}
.modal-header .modal-title {
- word-break: break-word;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
}
.modal-body {
diff --git a/addons/web/views/report_templates.xml b/addons/web/views/report_templates.xml
index b88979d2a3..02b8a09cd3 100644
--- a/addons/web/views/report_templates.xml
+++ b/addons/web/views/report_templates.xml
@@ -308,7 +308,7 @@
-
+