diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..dc5e6b6 --- /dev/null +++ b/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..9f0ebff --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +{ + 'name': 'Mrp Repairs', + 'version': '1.0', + 'category': 'Inventory/Inventory', + 'depends': ['repair', 'mrp'], + 'installable': True, + 'auto_install': True, + 'license': 'LGPL-3', +} diff --git a/i18n/mrp_repair.pot b/i18n/mrp_repair.pot new file mode 100644 index 0000000..23bddd9 --- /dev/null +++ b/i18n/mrp_repair.pot @@ -0,0 +1,26 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mrp_repair +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-26 21:55+0000\n" +"PO-Revision-Date: 2023-10-26 21:55+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: mrp_repair +#: model:ir.model,name:mrp_repair.model_repair_order +msgid "Repair Order" +msgstr "" + +#. module: mrp_repair +#: model:ir.model,name:mrp_repair.model_stock_move +msgid "Stock Move" +msgstr "" diff --git a/i18n/ru.po b/i18n/ru.po new file mode 100644 index 0000000..f6a42de --- /dev/null +++ b/i18n/ru.po @@ -0,0 +1,28 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mrp_repair +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-26 21:55+0000\n" +"PO-Revision-Date: 2024-01-30 15:14+0400\n" +"Last-Translator: \n" +"Language-Team: Russian (https://app.transifex.com/odoo/teams/41243/ru/)\n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n" + +#. module: mrp_repair +#: model:ir.model,name:mrp_repair.model_repair_order +msgid "Repair Order" +msgstr "Заказ на ремонт" + +#. module: mrp_repair +#: model:ir.model,name:mrp_repair.model_stock_move +msgid "Stock Move" +msgstr "Движение запасов" diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..f371b6a --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import repair diff --git a/models/repair.py b/models/repair.py new file mode 100644 index 0000000..d4ef95e --- /dev/null +++ b/models/repair.py @@ -0,0 +1,55 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class Repair(models.Model): + _inherit = 'repair.order' + + @api.model_create_multi + def create(self, vals_list): + orders = super().create(vals_list) + orders.action_explode() + return orders + + def write(self, vals): + res = super().write(vals) + self.action_explode() + return res + + def action_explode(self): + lines_to_unlink_ids = set() + line_vals_list = [] + for op in self.move_ids: + bom = self.env['mrp.bom'].sudo()._bom_find(op.product_id, company_id=op.company_id.id, bom_type='phantom')[op.product_id] + if not bom: + continue + factor = op.product_uom._compute_quantity(op.product_uom_qty, bom.product_uom_id) / bom.product_qty + _boms, lines = bom.sudo().explode(op.product_id, factor, picking_type=bom.picking_type_id) + for bom_line, line_data in lines: + if bom_line.product_id.type != 'service': + line_vals_list.append(op._prepare_phantom_line_vals(bom_line, line_data['qty'])) + lines_to_unlink_ids.add(op.id) + + self.env['stock.move'].browse(lines_to_unlink_ids).sudo().unlink() + if line_vals_list: + self.env['stock.move'].create(line_vals_list) + + +class StockMove(models.Model): + _inherit = 'stock.move' + + def _prepare_phantom_line_vals(self, bom_line, qty): + self.ensure_one() + product = bom_line.product_id + return { + 'name': self.name, + 'repair_id': self.repair_id.id, + 'repair_line_type': self.repair_line_type, + 'product_id': product.id, + 'price_unit': self.price_unit, + 'product_uom_qty': qty, + 'location_id': self.location_id.id, + 'location_dest_id': self.location_dest_id.id, + 'state': 'draft', + } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..bf7b8cd --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_tracability diff --git a/tests/test_tracability.py b/tests/test_tracability.py new file mode 100644 index 0000000..fd83cc4 --- /dev/null +++ b/tests/test_tracability.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import Form, tagged +from odoo.addons.mrp.tests.common import TestMrpCommon + +@tagged('post_install', '-at_install') +class TestRepairTraceability(TestMrpCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.ref('base.group_user').write({'implied_ids': [(4, cls.env.ref('stock.group_production_lot').id)]}) + + def test_tracking_repair_production(self): + """ + Test that removing a tracked component with a repair does not block the flow of using that component in another + bom + """ + picking_type = self.env['stock.picking.type'].search([('code', '=', 'mrp_operation')])[0] + picking_type.use_auto_consume_components_lots = True + product_to_repair = self.env['product.product'].create({ + 'name': 'product first serial to act repair', + 'tracking': 'serial', + }) + ptrepair_lot = self.env['stock.lot'].create({ + 'name': 'A1', + 'product_id': product_to_repair.id, + 'company_id': self.env.user.company_id.id + }) + product_to_remove = self.env['product.product'].create({ + 'name': 'other first serial to remove with repair', + 'tracking': 'serial', + }) + ptremove_lot = self.env['stock.lot'].create({ + 'name': 'B2', + 'product_id': product_to_remove.id, + 'company_id': self.env.user.company_id.id + }) + # Create a manufacturing order with product (with SN A1) + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product_to_repair + with mo_form.move_raw_ids.new() as move: + move.product_id = product_to_remove + move.product_uom_qty = 1 + mo = mo_form.save() + mo.action_confirm() + # Set serial to A1 + mo.lot_producing_id = ptrepair_lot + # Set component serial to B2 + mo.move_raw_ids.move_line_ids.lot_id = ptremove_lot + mo.move_raw_ids.picked = True + mo.button_mark_done() + + with Form(self.env['repair.order']) as ro_form: + ro_form.product_id = product_to_repair + ro_form.lot_id = ptrepair_lot # Repair product Serial A1 + with ro_form.move_ids.new() as operation: + operation.repair_line_type = 'remove' + operation.product_id = product_to_remove + ro = ro_form.save() + ro.action_validate() + ro.move_ids[0].lot_ids = ptremove_lot # Remove product Serial B2 from the product. + ro.action_repair_start() + ro.move_ids.picked = True + ro.action_repair_end() + + # Create a manufacturing order with product (with SN A2) + mo2_form = Form(self.env['mrp.production']) + mo2_form.product_id = product_to_repair + with mo2_form.move_raw_ids.new() as move: + move.product_id = product_to_remove + move.product_uom_qty = 1 + mo2 = mo2_form.save() + mo2.action_confirm() + # Set serial to A2 + mo2.lot_producing_id = self.env['stock.lot'].create({ + 'name': 'A2', + 'product_id': product_to_repair.id, + 'company_id': self.env.user.company_id.id + }) + # Set component serial to B2 again, it is possible + mo2.move_raw_ids.move_line_ids.lot_id = ptremove_lot + mo2.move_raw_ids.picked = True + # We are not forbidden to use that serial number, so nothing raised here + mo2.button_mark_done() + + def test_mo_with_used_sn_component(self): + """ + Suppose a tracked-by-usn component has been used to produce a product. Then, using a repair order, + this component is removed from the product and returned as available stock. The user should be able to + use the component in a new MO + """ + def produce_one(product, component): + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product + with mo_form.move_raw_ids.new() as raw_line: + raw_line.product_id = component + raw_line.product_uom_qty = 1 + mo = mo_form.save() + mo.action_confirm() + mo.action_assign() + mo.move_raw_ids.picked = True + mo.button_mark_done() + return mo + + picking_type = self.env['stock.picking.type'].search([('code', '=', 'mrp_operation')])[0] + picking_type.use_auto_consume_components_lots = True + + stock_location = self.env.ref('stock.stock_location_stock') + + finished, component = self.env['product.product'].create([{ + 'name': 'Finished Product', + 'type': 'product', + }, { + 'name': 'SN Componentt', + 'type': 'product', + 'tracking': 'serial', + }]) + + sn_lot = self.env['stock.lot'].create({ + 'product_id': component.id, + 'name': 'USN01', + 'company_id': self.env.company.id, + }) + self.env['stock.quant']._update_available_quantity(component, stock_location, 1, lot_id=sn_lot) + + mo = produce_one(finished, component) + self.assertEqual(mo.state, 'done') + self.assertEqual(mo.move_raw_ids.lot_ids, sn_lot) + ro_form = Form(self.env['repair.order']) + ro_form.product_id = finished + with ro_form.move_ids.new() as ro_line: + ro_line.repair_line_type = 'recycle' + ro_line.product_id = component + ro = ro_form.save() + ro.action_validate() + ro.move_ids[0].lot_ids = sn_lot + ro.action_repair_start() + ro.move_ids.picked = True + ro.action_repair_end() + mo = produce_one(finished, component) + self.assertEqual(mo.state, 'done') + self.assertEqual(mo.move_raw_ids.lot_ids, sn_lot)