Compare commits
10
Commits
5d0c50da3f
...
6b5469a4f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b5469a4f0 | ||
|
|
e8f8c9b428 | ||
|
|
35017adaa9 | ||
|
|
1bc016fa0f | ||
|
|
fc16ecced2 | ||
|
|
1ecd0b2160 | ||
|
|
14836d4084 | ||
|
|
69b2c89165 | ||
|
|
ccddb8a36f | ||
|
|
7a124de438 |
@@ -881,9 +881,13 @@ class ResPartner(models.Model):
|
||||
return self.env['res.partner'].search(domain + extra_domain, limit=1)
|
||||
|
||||
for search_method in (search_with_vat, search_with_domain, search_with_phone_mail, search_with_name):
|
||||
partner = search_method(self.env['res.partner']._check_company_domain(company or self.env.company))
|
||||
if partner and len(partner) == 1:
|
||||
return partner
|
||||
for extra_domain in (
|
||||
[*self.env['res.partner']._check_company_domain(company or self.env.company), ('company_id', '!=', False)],
|
||||
[('company_id', '=', False)],
|
||||
):
|
||||
partner = search_method(extra_domain)
|
||||
if partner and len(partner) == 1:
|
||||
return partner
|
||||
return self.env['res.partner']
|
||||
|
||||
def _merge_method(self, destination, source):
|
||||
|
||||
@@ -834,6 +834,7 @@ class WebsocketConnectionHandler:
|
||||
try:
|
||||
response = cls._get_handshake_response(request.httprequest.headers)
|
||||
socket = request.httprequest._HTTPRequest__environ['socket']
|
||||
# socket = request.httprequest._HTTPRequest__environ['werkzeug.socket']
|
||||
session, db, httprequest = request.session, request.db, request.httprequest
|
||||
response.call_on_close(lambda: cls._serve_forever(
|
||||
Websocket(socket, session),
|
||||
|
||||
@@ -80,7 +80,7 @@ class HrEmployeePrivate(models.Model):
|
||||
birthday = fields.Date('Date of Birth', groups="hr.group_hr_user", tracking=True)
|
||||
ssnid = fields.Char('SSN No', help='Social Security Number', groups="hr.group_hr_user", tracking=True)
|
||||
sinid = fields.Char('SIN No', help='Social Insurance Number', groups="hr.group_hr_user", tracking=True)
|
||||
identification_id = fields.Char(string='Identification No', groups="hr.group_hr_user", tracking=True)
|
||||
identification_id = fields.Char(string='Identification No', tracking=True)
|
||||
passport_id = fields.Char('Passport No', groups="hr.group_hr_user", tracking=True)
|
||||
bank_account_id = fields.Many2one(
|
||||
'res.partner.bank', 'Bank Account Number',
|
||||
|
||||
@@ -32,7 +32,7 @@ class HrAttendance(models.Model):
|
||||
employee_id = fields.Many2one('hr.employee', string="Employee", default=_default_employee, required=True, ondelete='cascade', index=True)
|
||||
department_id = fields.Many2one('hr.department', string="Department", related="employee_id.department_id",
|
||||
readonly=True)
|
||||
check_in = fields.Datetime(string="Check In", default=fields.Datetime.now, required=True, tracking=True)
|
||||
check_in = fields.Datetime(string="Check In", tracking=True)
|
||||
check_out = fields.Datetime(string="Check Out", tracking=True)
|
||||
worked_hours = fields.Float(string='Worked Hours', compute='_compute_worked_hours', store=True, readonly=True)
|
||||
color = fields.Integer(compute='_compute_color')
|
||||
|
||||
@@ -98,13 +98,13 @@ class HrEmployee(models.Model):
|
||||
end_tz = now_tz
|
||||
end_naive = end_tz.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
|
||||
hours = sum(
|
||||
att.worked_hours or 0
|
||||
for att in employee.attendance_ids.filtered(
|
||||
lambda att: att.check_in >= start_naive and att.check_out and att.check_out <= end_naive
|
||||
)
|
||||
)
|
||||
|
||||
# hours = sum(
|
||||
# att.worked_hours or 0
|
||||
# for att in employee.attendance_ids.filtered(
|
||||
# lambda att: att.check_in >= start_naive and att.check_out and att.check_out <= end_naive
|
||||
# )
|
||||
# )
|
||||
hours = 0
|
||||
employee.hours_last_month = round(hours, 2)
|
||||
employee.hours_last_month_display = "%g" % employee.hours_last_month
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ msgstr "日历不匹配: 员工日历与合约日历不匹配。可能会导
|
||||
#: model:ir.model.fields.selection,name:hr_contract.selection__hr_contract__state__cancel
|
||||
#: model:ir.model.fields.selection,name:hr_contract.selection__hr_contract_history__state__cancel
|
||||
msgid "Cancelled"
|
||||
msgstr "已取消"
|
||||
msgstr "已中止"
|
||||
|
||||
#. module: hr_contract
|
||||
#: model:ir.model,name:hr_contract.model_res_company
|
||||
|
||||
@@ -920,14 +920,9 @@ class HrExpense(models.Model):
|
||||
@api.model
|
||||
def message_new(self, msg_dict, custom_values=None):
|
||||
email_address = email_split(msg_dict.get('email_from', False))[0]
|
||||
employee = self._get_employee_from_email(email_address)
|
||||
|
||||
employee = self.env['hr.employee'].search([
|
||||
'|',
|
||||
('work_email', 'ilike', email_address),
|
||||
('user_id.email', 'ilike', email_address)
|
||||
]).filtered(lambda e: e.company_id == e.user_id.company_id)
|
||||
|
||||
if len(employee) != 1:
|
||||
if not employee:
|
||||
return super().message_new(msg_dict, custom_values=custom_values)
|
||||
|
||||
expense_description = msg_dict.get('subject', '')
|
||||
@@ -967,6 +962,29 @@ class HrExpense(models.Model):
|
||||
self._send_expense_success_mail(msg_dict, expense)
|
||||
return expense
|
||||
|
||||
@api.model
|
||||
def _get_employee_from_email(self, email_address):
|
||||
employee = self.env['hr.employee'].search([
|
||||
('user_id', '!=', False),
|
||||
'|',
|
||||
('work_email', 'ilike', email_address),
|
||||
('user_id.email', 'ilike', email_address),
|
||||
])
|
||||
|
||||
if len(employee) > 1:
|
||||
# Several employees can be linked to the same user.
|
||||
# In that case, we only keep the employee that matched the user's company.
|
||||
return employee.filtered(lambda e: e.company_id == e.user_id.company_id)
|
||||
|
||||
if not employee:
|
||||
# An employee does not always have a user.
|
||||
return self.env['hr.employee'].search([
|
||||
('user_id', '=', False),
|
||||
('work_email', 'ilike', email_address),
|
||||
], limit=1)
|
||||
|
||||
return employee
|
||||
|
||||
@api.model
|
||||
def _parse_product(self, expense_description):
|
||||
"""
|
||||
|
||||
@@ -67,6 +67,43 @@ class TestExpensesMailImport(TestExpenseCommon):
|
||||
'employee_id': company_2_employee.id,
|
||||
}])
|
||||
|
||||
def test_import_expense_from_email_employee_without_user(self):
|
||||
"""When an employee is not linked to a user, he has to be able to create expenses from email"""
|
||||
employee = self.expense_employee
|
||||
employee.user_id = False
|
||||
|
||||
message_parsed = {
|
||||
'message_id': "the-world-is-a-ghetto",
|
||||
'subject': 'New expense',
|
||||
'email_from': employee.work_email,
|
||||
'to': 'catchall@yourcompany.com',
|
||||
'body': "Don't you know, that for me, and for you",
|
||||
'attachments': [],
|
||||
}
|
||||
|
||||
expense = self.env['hr.expense'].message_new(message_parsed)
|
||||
self.assertRecordValues(expense, [{
|
||||
'employee_id': employee.id,
|
||||
}])
|
||||
|
||||
def test_import_expense_from_email_no_product(self):
|
||||
message_parsed = {
|
||||
'message_id': "the-world-is-a-ghetto",
|
||||
'subject': 'no product code 800',
|
||||
'email_from': self.expense_user_employee.email,
|
||||
'to': 'catchall@yourcompany.com',
|
||||
'body': "Don't you know, that for me, and for you",
|
||||
'attachments': [],
|
||||
}
|
||||
|
||||
expense = self.env['hr.expense'].message_new(message_parsed)
|
||||
|
||||
self.assertRecordValues(expense, [{
|
||||
'product_id': False,
|
||||
'total_amount': 800.0,
|
||||
'employee_id': self.expense_employee.id,
|
||||
}])
|
||||
|
||||
def test_import_expense_from_mail_parsing_subjects(self):
|
||||
def assertParsedValues(subject, currencies, exp_description, exp_amount, exp_product, exp_currency):
|
||||
product, amount, currency_id, description = self.env['hr.expense'] \
|
||||
|
||||
@@ -4422,7 +4422,7 @@ class MailThread(models.AbstractModel):
|
||||
|
||||
payload = self._notify_by_web_push_prepare_payload(message, msg_vals=msg_vals)
|
||||
payload = self._truncate_payload(payload)
|
||||
if len(devices) < MAX_DIRECT_PUSH:
|
||||
if False:
|
||||
session = Session()
|
||||
devices_to_unlink = set()
|
||||
for device in devices:
|
||||
|
||||
@@ -176,13 +176,15 @@ class BaseModel(models.AbstractModel):
|
||||
def _mail_track_get_field_sequence(self, fname):
|
||||
""" Find tracking sequence of a given field, given their name. Current
|
||||
parameter 'tracking' should be an integer, but attributes with True
|
||||
are still supported; old naming 'track_sequence' also. """
|
||||
sequence = getattr(
|
||||
self._fields[fname], 'tracking',
|
||||
getattr(self._fields[fname], 'track_sequence', 100)
|
||||
)
|
||||
if sequence is True:
|
||||
sequence = 100
|
||||
are still supported; old naming 'track_sequence' also.
|
||||
此文件因物业水电表日期字段报keyerror做屏蔽暂时解决
|
||||
"""
|
||||
# sequence = getattr(
|
||||
# self._fields[fname], 'tracking',
|
||||
# getattr(self._fields[fname], 'track_sequence', 100)
|
||||
# )
|
||||
# if sequence is True:
|
||||
sequence = 100
|
||||
return sequence
|
||||
|
||||
def _message_get_default_recipients(self):
|
||||
|
||||
@@ -569,8 +569,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search">
|
||||
<field string="Equipment" name="name" filter_domain="[
|
||||
'|', '|', '|',
|
||||
('name', 'ilike', self), ('model', 'ilike', self), ('serial_no', 'ilike', self), ('partner_ref', 'ilike', self)]"/>
|
||||
'|', '|', '|', ('name', 'ilike', self), ('model', 'ilike', self), ('serial_no', 'ilike', self), ('partner_ref', 'ilike', self)]"/>
|
||||
<field string="Category" name="category_id"/>
|
||||
<field name="owner_user_id"/>
|
||||
<filter string="My Equipment" name="my" domain="[('owner_user_id', '=', uid)]"/>
|
||||
@@ -599,9 +598,9 @@
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="hr_equipment_action" model="ir.actions.act_window">
|
||||
<field name="name">Equipment</field>
|
||||
<field name="name">固定资产</field>
|
||||
<field name="res_model">maintenance.equipment</field>
|
||||
<field name="view_mode">kanban,tree,form</field>
|
||||
<field name="view_id" ref="hr_equipment_view_kanban"/>
|
||||
|
||||
@@ -1192,7 +1192,6 @@ class MrpProduction(models.Model):
|
||||
'raw_material_production_id': self.id,
|
||||
'company_id': self.company_id.id,
|
||||
'operation_id': operation_id,
|
||||
'price_unit': product_id.standard_price,
|
||||
'procure_method': 'make_to_stock',
|
||||
'origin': self._get_origin(),
|
||||
'state': 'draft',
|
||||
|
||||
@@ -309,7 +309,6 @@ class StockMove(models.Model):
|
||||
product = product.browse(values['product_id'])
|
||||
product_id_to_product[values['product_id']] = product
|
||||
values['location_dest_id'] = mo.production_location_id.id
|
||||
values['price_unit'] = product.standard_price
|
||||
if not values.get('location_id'):
|
||||
values['location_id'] = mo.location_src_id.id
|
||||
continue
|
||||
|
||||
@@ -237,3 +237,37 @@ class TestManualConsumption(TestMrpCommon):
|
||||
mo = self.env[action['res_model']].browse(action['res_id'])
|
||||
self.assertTrue(mo.move_raw_ids.filtered(lambda m: m.product_id == p1).manual_consumption)
|
||||
self.assertFalse(mo.move_raw_ids.filtered(lambda m: m.product_id == p2).manual_consumption)
|
||||
|
||||
def test_manual_consumption_with_different_component_price(self):
|
||||
"""
|
||||
Test that the moves are merged correctly, even if the products have been used with different prices:
|
||||
- Create a product with a price of $10 and use it in a BoM with 1 unit.
|
||||
- Create a MO with this BoM and confirm it.
|
||||
- Update the price of the component to $20 and adjust the consumed quantity to 2.
|
||||
- Mark the MO as done.
|
||||
- Another move should be created and merged with the first move.
|
||||
|
||||
"""
|
||||
self.bom_4.consumption = 'warning'
|
||||
component = self.bom_4.bom_line_ids.product_id
|
||||
component.write({
|
||||
'type': 'product',
|
||||
'standard_price': 10,
|
||||
})
|
||||
self.env['stock.quant']._update_available_quantity(component, self.stock_location, 2)
|
||||
mo = self.env['mrp.production'].create({
|
||||
'product_qty': 1,
|
||||
'bom_id': self.bom_4.id,
|
||||
})
|
||||
mo.action_confirm()
|
||||
self.assertEqual(mo.state, 'confirmed')
|
||||
component.standard_price = 20
|
||||
mo.move_raw_ids.quantity = 2.0
|
||||
mo.move_raw_ids.picked = True
|
||||
mo.move_raw_ids.manual_consumption = True
|
||||
self.assertEqual(mo.state, 'progress')
|
||||
action = mo.button_mark_done()
|
||||
consumption_warning = Form(self.env['mrp.consumption.warning'].with_context(**action['context']))
|
||||
action = consumption_warning.save().action_confirm()
|
||||
self.assertEqual(len(mo.move_raw_ids), 1)
|
||||
self.assertEqual(mo.move_raw_ids.quantity, 2)
|
||||
|
||||
@@ -23,7 +23,7 @@ class StockMove(models.Model):
|
||||
# In case of standard price, the component cost is the cost of the product
|
||||
# the subcontracting service cost may not represent the real cost of the subcontracting service
|
||||
# the difference should be posted in price difference account in the end
|
||||
component_cost = currency.round(sum(m.price_unit * m.product_uom_qty for m in subcontract_production.move_raw_ids))
|
||||
component_cost = abs(currency.round(sum(subcontract_production.move_raw_ids.stock_valuation_layer_ids.mapped('value'))))
|
||||
subcontract_service_cost = credit_value - component_cost
|
||||
else:
|
||||
subcontract_service_cost = currency.round(subcontract_production.extra_cost * qty)
|
||||
|
||||
@@ -862,10 +862,9 @@ class PosOrder(models.Model):
|
||||
if len(self.company_id) > 1:
|
||||
raise UserError(_("You cannot invoice orders belonging to different companies."))
|
||||
self.write({'to_invoice': True})
|
||||
res = self._generate_pos_order_invoice()
|
||||
if self.company_id.anglo_saxon_accounting and self.session_id.update_stock_at_closing and self.session_id.state != 'closed':
|
||||
self._create_order_picking()
|
||||
return res
|
||||
return self._generate_pos_order_invoice()
|
||||
|
||||
def _generate_pos_order_invoice(self):
|
||||
moves = self.env['account.move']
|
||||
|
||||
@@ -309,3 +309,43 @@ class TestAngloSaxonFlow(TestAngloSaxonCommon):
|
||||
self.assertEqual(aml_output[0].credit, 0.0, "Cost of Good Sold entry missing or mismatching")
|
||||
self.assertEqual(aml_expense[0].credit, 0.0, "Cost of Good Sold entry missing or mismatching")
|
||||
self.assertEqual(aml_expense[0].debit, 0.0, "Cost of Good Sold entry missing or mismatching")
|
||||
|
||||
def test_action_pos_order_invoice(self):
|
||||
self.company.point_of_sale_update_stock_quantities = 'closing'
|
||||
|
||||
# Setup a running session, with a paid pos order that is not invoiced
|
||||
self.pos_config.open_ui()
|
||||
current_session = self.pos_config.current_session_id
|
||||
self.pos_order_pos0 = self.PosOrder.create({
|
||||
'company_id': self.company.id,
|
||||
'partner_id': self.partner.id,
|
||||
'session_id': self.pos_config.current_session_id.id,
|
||||
'lines': [(0, 0, {
|
||||
'product_id': self.product.id,
|
||||
'price_unit': 450,
|
||||
'qty': 1.0,
|
||||
'price_subtotal': 450,
|
||||
'price_subtotal_incl': 450,
|
||||
})],
|
||||
'amount_total': 450,
|
||||
'amount_tax': 0,
|
||||
'amount_paid': 0,
|
||||
'amount_return': 0,
|
||||
})
|
||||
context_make_payment = {"active_ids": [self.pos_order_pos0.id], "active_id": self.pos_order_pos0.id}
|
||||
self.pos_make_payment_0 = self.PosMakePayment.with_context(context_make_payment).create({
|
||||
'amount': 450.0,
|
||||
'payment_method_id': self.cash_payment_method.id,
|
||||
})
|
||||
context_payment = {'active_id': self.pos_order_pos0.id}
|
||||
self.pos_make_payment_0.with_context(context_payment).check()
|
||||
|
||||
# Invoice the pos order afterward (session still running)
|
||||
self.pos_order_pos0.action_pos_order_invoice()
|
||||
|
||||
# Check that the stock output journal item from the invoice is reconciled (with its counterpart from the valuation entry)
|
||||
stock_output_account = self.category.property_stock_account_output_categ_id
|
||||
related_amls = current_session._get_related_account_moves().line_ids
|
||||
stock_output_amls = related_amls.filtered_domain([('account_id', '=', stock_output_account.id)])
|
||||
|
||||
self.assertTrue(all(stock_output_amls.mapped('reconciled')))
|
||||
|
||||
@@ -143,7 +143,7 @@ class Project(models.Model):
|
||||
('state', 'in', ['purchase', 'done']),
|
||||
'|',
|
||||
('qty_invoiced', '>', 0),
|
||||
'|', ('qty_to_invoice', '>', 0), ('product_uom_qty', '>', 0),
|
||||
'|', ('qty_to_invoice', '>', 0), ('product_qty', '>', 0),
|
||||
], order=self.env['purchase.order.line']._order)
|
||||
query.add_where(
|
||||
SQL(
|
||||
@@ -152,7 +152,7 @@ class Project(models.Model):
|
||||
self.env['purchase.order.line']._query_analytic_accounts(),
|
||||
)
|
||||
)
|
||||
query_string, query_param = query.select('"purchase_order_line".id', 'qty_invoiced', 'qty_to_invoice', 'product_uom_qty', 'price_unit', 'purchase_order_line.currency_id', '"purchase_order_line".analytic_distribution')
|
||||
query_string, query_param = query.select('"purchase_order_line".id', 'qty_invoiced', 'qty_to_invoice', 'product_qty', 'price_subtotal', 'purchase_order_line.currency_id', '"purchase_order_line".analytic_distribution')
|
||||
self._cr.execute(query_string, query_param)
|
||||
purchase_order_line_read = [{
|
||||
**pol,
|
||||
@@ -168,17 +168,18 @@ class Project(models.Model):
|
||||
for pol_read in purchase_order_line_read:
|
||||
purchase_order_line_invoice_line_ids.extend(pol_read['invoice_lines'].ids)
|
||||
currency = self.env['res.currency'].browse(pol_read['currency_id']).with_prefetch(currency_ids)
|
||||
price_unit = currency._convert(pol_read['price_unit'], self.currency_id, self.company_id)
|
||||
price_subtotal = currency._convert(pol_read['price_subtotal'], self.currency_id, self.company_id)
|
||||
price_subtotal_unit = price_subtotal / pol_read['product_qty'] if pol_read['product_qty'] else 0.0
|
||||
# an analytic account can appear several time in an analytic distribution with different repartition percentage
|
||||
analytic_contribution = sum(
|
||||
percentage for ids, percentage in pol_read['analytic_distribution'].items()
|
||||
if str(self.analytic_account_id.id) in ids.split(',')
|
||||
) / 100.
|
||||
amount_invoiced -= price_unit * pol_read['qty_invoiced'] * analytic_contribution if pol_read['qty_invoiced'] > 0 else 0.0
|
||||
amount_invoiced -= price_subtotal_unit * pol_read['qty_invoiced'] * analytic_contribution if pol_read['qty_invoiced'] > 0 else 0.0
|
||||
if pol_read['qty_to_invoice'] > 0:
|
||||
amount_to_invoice -= price_unit * pol_read['qty_to_invoice'] * analytic_contribution
|
||||
amount_to_invoice -= price_subtotal_unit * pol_read['qty_to_invoice'] * analytic_contribution
|
||||
else:
|
||||
amount_to_invoice -= price_unit * (pol_read['product_uom_qty'] - pol_read['qty_invoiced']) * analytic_contribution
|
||||
amount_to_invoice -= price_subtotal_unit * (pol_read['product_qty'] - pol_read['qty_invoiced']) * analytic_contribution
|
||||
purchase_order_line_ids.append(pol_read['id'])
|
||||
costs = profitability_items['costs']
|
||||
section_id = 'purchase_order'
|
||||
|
||||
@@ -169,6 +169,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
})],
|
||||
})
|
||||
purchase_order.button_confirm()
|
||||
purchase_order.order_line.flush_recordset()
|
||||
# we should have a new section "purchase_order", the total should be updated,
|
||||
# but the "other_purchase_costs" shouldn't change, as we don't take into
|
||||
# account bills from purchase orders, as those are already taken into calculations
|
||||
@@ -267,6 +268,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
})],
|
||||
})
|
||||
purchase_order.button_confirm()
|
||||
purchase_order.order_line.flush_recordset()
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
@@ -424,6 +426,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
})],
|
||||
})
|
||||
purchase_order_foreign.button_confirm()
|
||||
purchase_order_foreign.order_line.flush_recordset()
|
||||
|
||||
# We should have a new section "purchase_order", the total should be updated,
|
||||
# but the "other_purchase_costs" shouldn't change, as we don't take into
|
||||
@@ -460,6 +463,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
})],
|
||||
})
|
||||
purchase_order.button_confirm()
|
||||
purchase_order.order_line.flush_recordset()
|
||||
# The 'to bill' section should be updated in the 'total' and 'purchase orders' sections.
|
||||
items = project._get_profitability_items(with_action=False)['costs']
|
||||
self.assertEqual('purchase_order', items['data'][0]['id'])
|
||||
@@ -508,6 +512,103 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
purchase_bill.invoice_date = datetime.today()
|
||||
purchase_bill.action_post()
|
||||
|
||||
def test_analytic_distribution_with_included_tax(self):
|
||||
"""When calculating the profitability of a project, included taxes should not be calculated"""
|
||||
included_tax = self.env['account.tax'].create({
|
||||
'name': 'included tax',
|
||||
'amount': '15.0',
|
||||
'amount_type': 'percent',
|
||||
'type_tax_use': 'purchase',
|
||||
'price_include': True
|
||||
})
|
||||
|
||||
# create a purchase.order with the project account in analytic_distribution
|
||||
purchase_order = self.env['purchase.order'].create({
|
||||
'name': "A purchase order",
|
||||
'partner_id': self.partner_a.id,
|
||||
'order_line': [Command.create({
|
||||
'analytic_distribution': {self.analytic_account.id: 100},
|
||||
'product_id': self.product_order.id,
|
||||
'product_qty': 2, # plural value to check if the price is multiplied more than once
|
||||
'taxes_id': [included_tax.id], # set the included tax
|
||||
'price_unit': self.product_order.standard_price,
|
||||
'currency_id': self.env.company.currency_id.id,
|
||||
})],
|
||||
})
|
||||
purchase_order.button_confirm()
|
||||
purchase_order.order_line.flush_recordset()
|
||||
# the profitability should not take taxes into account
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'purchase_order',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
|
||||
'to_bill': -(purchase_order.amount_untaxed),
|
||||
'billed': 0.0,
|
||||
}],
|
||||
'total': {
|
||||
'to_bill': -(purchase_order.amount_untaxed),
|
||||
'billed': 0.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
purchase_order.action_create_invoice()
|
||||
purchase_bill = purchase_order.invoice_ids # get the bill from the purchase
|
||||
purchase_bill.invoice_date = datetime.today()
|
||||
purchase_bill.action_post()
|
||||
# same here, taxes should not be calculated in the profitability
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'purchase_order',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -(purchase_bill.amount_untaxed),
|
||||
}],
|
||||
'total': {
|
||||
'to_bill': 0.0,
|
||||
'billed': -(purchase_bill.amount_untaxed),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_analytic_distribution_with_mismatched_uom(self):
|
||||
"""When changing the unit of measure, the profitability should still match the price_subtotal of the order line"""
|
||||
# create a purchase.order with the project account in analytic_distribution
|
||||
purchase_order = self.env['purchase.order'].create({
|
||||
'name': "A purchase order",
|
||||
'partner_id': self.partner_a.id,
|
||||
'order_line': [Command.create({
|
||||
'analytic_distribution': {self.analytic_account.id: 100},
|
||||
'product_id': self.product_order.id,
|
||||
'product_qty': 1,
|
||||
'price_unit': self.product_order.standard_price,
|
||||
'currency_id': self.env.company.currency_id.id,
|
||||
})],
|
||||
})
|
||||
purchase_order.button_confirm()
|
||||
# changing the uom to a higher number
|
||||
purchase_order.order_line.product_uom = self.env.ref("uom.product_uom_dozen")
|
||||
purchase_order.order_line.flush_recordset()
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'purchase_order',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
|
||||
'to_bill': -(purchase_order.amount_untaxed),
|
||||
'billed': 0.0,
|
||||
}],
|
||||
'total': {
|
||||
'to_bill': -(purchase_order.amount_untaxed),
|
||||
'billed': 0.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_cross_analytics_contribution(self):
|
||||
cross_plan = self.env['account.analytic.plan'].create({'name': 'Cross Plan'})
|
||||
cross_account = self.env['account.analytic.account'].create({
|
||||
@@ -535,8 +636,9 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
})
|
||||
|
||||
cross_order.button_confirm()
|
||||
items = self.project._get_profitability_items()
|
||||
cross_order.order_line.flush_recordset()
|
||||
items = self.project._get_profitability_items(with_action=False)['costs']
|
||||
self.assertEqual(
|
||||
items['costs']['data'][0]['to_bill'],
|
||||
self.product_order.standard_price *-cross_distribution/100
|
||||
items['data'][0]['to_bill'],
|
||||
-(self.product_order.standard_price * cross_distribution / 100)
|
||||
)
|
||||
|
||||
@@ -25,9 +25,6 @@ class ProjectTask(models.Model):
|
||||
res = super().write(vals)
|
||||
|
||||
if 'stage_id' in vals:
|
||||
if self.env.user.has_group('base.group_portal') and not self.env.su:
|
||||
# sudo as sms template model is protected
|
||||
self.sudo()._send_sms()
|
||||
else:
|
||||
self._send_sms()
|
||||
# sudo as sms template model is protected
|
||||
self.sudo()._send_sms()
|
||||
return res
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from odoo import Command
|
||||
from odoo.addons.project.tests.test_project_sharing import TestProjectSharingCommon
|
||||
from odoo.addons.sms.tests.common import SMSCommon
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
class TestProjectSharingWithSms(TestProjectSharingCommon, SMSCommon):
|
||||
@@ -63,3 +64,42 @@ class TestProjectSharingWithSms(TestProjectSharingCommon, SMSCommon):
|
||||
})
|
||||
self.assertEqual(self.project_portal.stage_id, self.project_stage_with_sms)
|
||||
self.assertSMSIapSent([self.project_portal.partner_id.mobile])
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPostInstallProjectSharingWithSms(TestProjectSharingWithSms):
|
||||
|
||||
def test_project_user_can_change_stage_with_sms_template(self):
|
||||
""" Test that users with the rights to change the stage of a task can perform this action
|
||||
when the stage has an sms template.
|
||||
|
||||
The sms template should be sent and the stage should be changed on the task.
|
||||
"""
|
||||
project_user_group = self.env.ref('project.group_project_user')
|
||||
sale_manager_group = self.env.ref('sales_team.group_sale_manager', False)
|
||||
if not sale_manager_group:
|
||||
self.skipTest('`sale_sms` not installed')
|
||||
self.user_projectuser.write({
|
||||
'groups_id': [
|
||||
Command.link(project_user_group.id),
|
||||
Command.link(sale_manager_group.id),
|
||||
]
|
||||
})
|
||||
self.assertTrue(self.task_cow.with_user(self.user_projectuser).check_access_rights('write'))
|
||||
with self.mockSMSGateway():
|
||||
self.task_cow.with_user(self.user_projectuser).write({
|
||||
'stage_id': self.task_stage_with_sms.id,
|
||||
})
|
||||
self.assertEqual(self.task_cow.stage_id, self.task_stage_with_sms)
|
||||
self.assertSMSIapSent([]) # no sms sent since the author is the recipient
|
||||
|
||||
self.task_cow.write({
|
||||
'partner_id': self.user_portal.partner_id.id,
|
||||
'stage_id': self.project_cows.type_ids[0].id,
|
||||
})
|
||||
with self.mockSMSGateway():
|
||||
self.task_cow.with_user(self.user_projectuser).write({
|
||||
'stage_id': self.task_stage_with_sms.id,
|
||||
})
|
||||
self.assertEqual(self.task_cow.stage_id, self.task_stage_with_sms)
|
||||
self.assertSMSIapSent([self.user_portal.partner_id.mobile])
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
'depends': ['stock_account', 'purchase'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/purchase_stock_data.xml',
|
||||
# 'data/purchase_stock_data.xml',
|
||||
'data/mail_templates.xml',
|
||||
'report/vendor_delay_report.xml',
|
||||
'views/purchase_views.xml',
|
||||
|
||||
@@ -7,10 +7,7 @@ import { useChildRef } from "@web/core/utils/hooks";
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export const deleteConfirmationMessage = _t(
|
||||
`Ready to make your record disappear into thin air? Are you sure?
|
||||
It will be gone forever!
|
||||
|
||||
Think twice before you click that 'Delete' button!`
|
||||
`你确定要删除吗?`
|
||||
);
|
||||
|
||||
export class ConfirmationDialog extends Component {
|
||||
|
||||
@@ -139,7 +139,7 @@ export const useNestedSortable = makeDraggableHook({
|
||||
ctx.prevNestX = ctx.pointer.x;
|
||||
}
|
||||
ctx.current.placeHolder = ctx.current.element.cloneNode(false);
|
||||
ctx.current.placeHolder.classList.add("w-100", "d-block", "py-0");
|
||||
ctx.current.placeHolder.classList.add("w-100", "d-block");
|
||||
if (ctx.useElementSize) {
|
||||
ctx.current.placeHolder.style.height = getComputedStyle(ctx.current.element).height;
|
||||
ctx.current.placeHolder.classList.add("o_nested_sortable_placeholder_realsize");
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
background-clip: content-box;
|
||||
background-color: deepskyblue;
|
||||
height: 5px;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
.o_nested_sortable_placeholder_realsize {
|
||||
outline: 1px dashed $o-gray-600;
|
||||
|
||||
@@ -323,7 +323,7 @@ export class CalendarController extends Component {
|
||||
}
|
||||
deleteRecord(record) {
|
||||
this.displayDialog(ConfirmationDialog, {
|
||||
title: _t("Bye-bye, record!"),
|
||||
title: _t(" "),
|
||||
body: deleteConfirmationMessage,
|
||||
confirm: () => {
|
||||
this.model.unlinkRecord(record.id);
|
||||
|
||||
@@ -10,20 +10,22 @@
|
||||
data-hotkey="s"
|
||||
t-on-click.stop="save"
|
||||
data-tooltip="Save manually"
|
||||
aria-label="Save manually"
|
||||
style="background-color: blue !important;color: white !important;"
|
||||
aria-label="Save manually"
|
||||
t-ref="save">
|
||||
<i class="fa fa-cloud-upload fa-fw" />
|
||||
<i class="fa fa-cloud-upload fa-fw"/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="o_form_button_cancel btn btn-light px-1 py-0 lh-sm"
|
||||
data-hotkey="j"
|
||||
t-on-click.stop="discard"
|
||||
data-tooltip="Discard changes"
|
||||
data-tooltip="Discard changes"
|
||||
style="background-color: red !important;color: white !important;"
|
||||
aria-label="Discard changes">
|
||||
<i class="fa fa-undo fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
<i class="fa fa-undo fa-fw"/>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
t-if="!props.model.root.isNew and indicatorMode === 'invalid'"
|
||||
class="text-danger small ms-2"
|
||||
|
||||
@@ -536,7 +536,7 @@ export class ListController extends Component {
|
||||
body = _t("Are you sure you want to delete these records?");
|
||||
}
|
||||
return {
|
||||
title: _t("Bye-bye, record!"),
|
||||
title: _t(" "),
|
||||
body,
|
||||
confirmLabel: _t("Delete"),
|
||||
confirm: () => this.model.root.deleteRecords(),
|
||||
|
||||
@@ -983,7 +983,7 @@ export class ListRenderer extends Component {
|
||||
}
|
||||
|
||||
get getEmptyRowIds() {
|
||||
let nbEmptyRow = Math.max(0, 4 - this.props.list.records.length);
|
||||
let nbEmptyRow = Math.max(0, 1 - this.props.list.records.length);
|
||||
if (nbEmptyRow > 0 && this.displayRowCreates) {
|
||||
nbEmptyRow -= 1;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export class SelectCreateDialog extends Component {
|
||||
const translatedText = _t("No records found!");
|
||||
this.busy = false; // flag used to ensure we only call once the onSelected/onUnselect props
|
||||
this.baseViewProps = {
|
||||
display: { searchPanel: false },
|
||||
display: { searchPanel: true },
|
||||
editable: false, // readonly
|
||||
noBreadcrumbs: true,
|
||||
noContentHelp: markup(`<p>${escape(translatedText)}</p>`),
|
||||
|
||||
@@ -939,7 +939,7 @@ QUnit.module("Views", ({ beforeEach }) => {
|
||||
await click(target, ".o_cw_popover_delete");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".modal-title").textContent,
|
||||
"Bye-bye, record!",
|
||||
" ",
|
||||
"should display the confirm message"
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import base64
|
||||
from lxml import etree
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tools.mimetypes import guess_mimetype
|
||||
|
||||
class TestIrQweb(TransactionCase):
|
||||
def test_image_field(self):
|
||||
@@ -28,3 +31,44 @@ class TestIrQweb(TransactionCase):
|
||||
self.assertTrue(img.get("src").startswith("/web/image"))
|
||||
self.assertEqual(img.get("class"), "img img-fluid")
|
||||
self.assertEqual(img.get("alt"), "test image partner")
|
||||
|
||||
def test_image_field_webp(self):
|
||||
webp = "UklGRsCpAQBXRUJQVlA4WAoAAAAQAAAAGAQA/wMAQUxQSMywAAAdNANp22T779/0RUREkvqLOTPesG1T21jatpLTSbpXQzTMEw3zWMM81jCPnWG2fTM7vpndvpkd38y2758Y+6a/Ld/Mt3zzT/XwzCKlV0Ooo61UpZIsKLjKc98R"
|
||||
webp_decoded = base64.b64decode(webp)
|
||||
self.assertEqual(guess_mimetype(webp_decoded), "image/webp")
|
||||
|
||||
view = self.env["ir.ui.view"].create({
|
||||
"key": "web.test_qweb",
|
||||
"type": "qweb",
|
||||
"arch": """<t t-name="test_qweb">
|
||||
<span t-field="record.flag_image" t-options-widget="'image'" t-options-qweb_img_raw_data="is_raw_image" />
|
||||
</t>"""
|
||||
})
|
||||
lang_record = self.env["res.lang"].create({
|
||||
"name": "test lang",
|
||||
"flag_image": webp,
|
||||
"code": "TEST"
|
||||
})
|
||||
attachment = self.env["ir.attachment"].search([
|
||||
("res_model", "=", "res.lang"),
|
||||
("res_id", '=', lang_record.id),
|
||||
("res_field", "=", "flag_image")
|
||||
])
|
||||
|
||||
jpeg_attach = self.env["ir.attachment"].create({
|
||||
"name": "webpcopy.jpg",
|
||||
"res_model": "ir.attachment",
|
||||
"res_id": attachment.id,
|
||||
"datas": "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVR4nGJxKFrEwMDAxAAGgAAAAP//D+IBWx9K7TUAAAAASUVORK5CYII="
|
||||
})
|
||||
jpeg_datas = jpeg_attach.datas
|
||||
|
||||
html = view.with_context(webp_as_jpg=False)._render_template(view.id, {"is_raw_image": True, "record": lang_record})
|
||||
tree = etree.fromstring(html)
|
||||
img = tree.find("img")
|
||||
self.assertEqual(img.get("src"), "data:image/webp;base64,%s" % webp)
|
||||
|
||||
html = view.with_context(webp_as_jpg=True)._render_template(view.id, {"is_raw_image": True, "record": lang_record})
|
||||
tree = etree.fromstring(html)
|
||||
img = tree.find("img")
|
||||
self.assertEqual(img.get("src"), "data:image/png;base64,%s" % jpeg_datas.decode())
|
||||
|
||||
@@ -1751,13 +1751,9 @@ class IrModelConstraint(models.Model):
|
||||
'Constraints with the same name are unique per module.'),
|
||||
]
|
||||
|
||||
def _module_data_uninstall(self):
|
||||
"""
|
||||
Delete PostgreSQL foreign keys and constraints tracked by this model.
|
||||
"""
|
||||
if not self.env.is_system():
|
||||
raise AccessError(_('Administrator access is required to uninstall a module'))
|
||||
|
||||
def unlink(self):
|
||||
self.check_access_rights('unlink')
|
||||
self.check_access_rule('unlink')
|
||||
ids_set = set(self.ids)
|
||||
for data in self.sorted(key='id', reverse=True):
|
||||
name = tools.ustr(data.name)
|
||||
@@ -1802,7 +1798,7 @@ class IrModelConstraint(models.Model):
|
||||
sql.Identifier(table), sql.Identifier(hname)))
|
||||
_logger.info('Dropped CONSTRAINT %s@%s', name, data.model.model)
|
||||
|
||||
self.unlink()
|
||||
return super().unlink()
|
||||
|
||||
def copy(self, default=None):
|
||||
default = dict(default or {})
|
||||
@@ -2449,8 +2445,6 @@ class IrModelData(models.Model):
|
||||
modules._remove_copied_views()
|
||||
|
||||
# remove constraints
|
||||
constraints = self.env['ir.model.constraint'].search([('module', 'in', modules.ids)])
|
||||
constraints._module_data_uninstall()
|
||||
delete(self.env['ir.model.constraint'].browse(unique(constraint_ids)))
|
||||
|
||||
# If we delete a selection field, and some of its values have ondelete='cascade',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import base64
|
||||
import binascii
|
||||
from datetime import time
|
||||
import logging
|
||||
import re
|
||||
@@ -15,6 +16,7 @@ from odoo import api, fields, models, _, _lt, tools
|
||||
from odoo.tools import posix_to_ldml, float_utils, format_date, format_duration, pycompat
|
||||
from odoo.tools.mail import safe_attrs
|
||||
from odoo.tools.misc import get_lang, babel_locale_parse
|
||||
from odoo.tools.mimetypes import guess_mimetype
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -390,13 +392,21 @@ class ImageConverter(models.AbstractModel):
|
||||
|
||||
@api.model
|
||||
def _get_src_data_b64(self, value, options):
|
||||
try: # FIXME: maaaaaybe it could also take raw bytes?
|
||||
image = Image.open(BytesIO(base64.b64decode(value)))
|
||||
try:
|
||||
img_b64 = base64.b64decode(value)
|
||||
except binascii.Error:
|
||||
raise ValueError("Invalid image content") from None
|
||||
|
||||
if img_b64 and guess_mimetype(img_b64, '') == 'image/webp':
|
||||
return self.env["ir.qweb"]._get_converted_image_data_uri(value)
|
||||
|
||||
try:
|
||||
image = Image.open(BytesIO(img_b64))
|
||||
image.verify()
|
||||
except IOError:
|
||||
raise ValueError("Non-image binary fields can not be converted to HTML")
|
||||
raise ValueError("Non-image binary fields can not be converted to HTML") from None
|
||||
except: # image.verify() throws "suitable exceptions", I have no idea what they are
|
||||
raise ValueError("Invalid image content")
|
||||
raise ValueError("Invalid image content") from None
|
||||
|
||||
return "data:%s;base64,%s" % (Image.MIME[image.format], value.decode('ascii'))
|
||||
|
||||
|
||||
@@ -212,12 +212,10 @@ class IrRule(models.Model):
|
||||
'unlink': _("unlink"),
|
||||
}
|
||||
user_description = f"{self.env.user.name} (id={self.env.user.id})"
|
||||
operation_error = _("Uh-oh! Looks like you have stumbled upon some top-secret records.\n\n" \
|
||||
"Sorry, %s doesn't have '%s' access to:", user_description, operations[operation])
|
||||
operation_error = "对不起,你没有访问的权限: %s", user_description
|
||||
failing_model = _("- %s (%s)", description, model)
|
||||
|
||||
resolution_info = _("If you really, really need access, perhaps you can win over your friendly administrator with a batch of freshly baked cookies.")
|
||||
|
||||
resolution_info = ""
|
||||
if not self.user_has_groups('base.group_no_one') or not self.env.user.has_group('base.group_user'):
|
||||
records.invalidate_recordset()
|
||||
return AccessError(f"{operation_error}\n{failing_model}\n\n{resolution_info}")
|
||||
|
||||
Reference in New Issue
Block a user