diff --git a/addons/mrp_repair/models/repair.py b/addons/mrp_repair/models/repair.py index 1c39c0edc3b..d4ef95efd01 100644 --- a/addons/mrp_repair/models/repair.py +++ b/addons/mrp_repair/models/repair.py @@ -20,7 +20,7 @@ class Repair(models.Model): def action_explode(self): lines_to_unlink_ids = set() line_vals_list = [] - for op in self.operations: + 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 @@ -31,35 +31,24 @@ class Repair(models.Model): line_vals_list.append(op._prepare_phantom_line_vals(bom_line, line_data['qty'])) lines_to_unlink_ids.add(op.id) - self.env['repair.line'].browse(lines_to_unlink_ids).sudo().unlink() + self.env['stock.move'].browse(lines_to_unlink_ids).sudo().unlink() if line_vals_list: - self.env['repair.line'].create(line_vals_list) + self.env['stock.move'].create(line_vals_list) -class RepairLine(models.Model): - _inherit = 'repair.line' +class StockMove(models.Model): + _inherit = 'stock.move' def _prepare_phantom_line_vals(self, bom_line, qty): self.ensure_one() product = bom_line.product_id - uom = bom_line.product_uom_id - partner = self.repair_id.partner_id - price = self.repair_id.pricelist_id._get_product_price(product, qty, uom=uom) - tax = self.env['account.tax'] - if partner: - partner_invoice = self.repair_id.partner_invoice_id or partner - fpos = self.env['account.fiscal.position']._get_fiscal_position(partner_invoice, delivery=self.repair_id.address_id) - taxes = self.product_id.taxes_id.filtered(lambda x: x.company_id == self.repair_id.company_id) - tax = fpos.map_tax(taxes) return { 'name': self.name, 'repair_id': self.repair_id.id, - 'type': self.type, + 'repair_line_type': self.repair_line_type, 'product_id': product.id, - 'price_unit': price, - 'tax_id': [(4, t.id) for t in tax], + 'price_unit': self.price_unit, 'product_uom_qty': qty, - 'product_uom': uom.id, 'location_id': self.location_id.id, 'location_dest_id': self.location_dest_id.id, 'state': 'draft', diff --git a/addons/mrp_repair/tests/test_tracability.py b/addons/mrp_repair/tests/test_tracability.py index bdc93f4e227..272d325de4d 100644 --- a/addons/mrp_repair/tests/test_tracability.py +++ b/addons/mrp_repair/tests/test_tracability.py @@ -49,12 +49,12 @@ class TestRepairTraceability(TestMrpCommon): 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.operations.new() as operation: - operation.type = 'remove' + with ro_form.move_ids.new() as operation: + operation.repair_line_type = 'remove' operation.product_id = product_to_remove - operation.lot_id = ptremove_lot # Remove product Serial B2 from the product 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.action_repair_end() @@ -119,16 +119,14 @@ class TestRepairTraceability(TestMrpCommon): 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.operations.new() as ro_line: - ro_line.type = 'remove' + with ro_form.move_ids.new() as ro_line: + ro_line.repair_line_type = 'recycle' ro_line.product_id = component - ro_line.lot_id = sn_lot - ro_line.location_dest_id = stock_location ro = ro_form.save() ro.action_validate() + ro.move_ids[0].lot_ids = sn_lot ro.action_repair_start() ro.action_repair_end() diff --git a/addons/mrp_subcontracting_repair/security/ir.model.access.csv b/addons/mrp_subcontracting_repair/security/ir.model.access.csv index 103b9fc4cbf..8c4c99c03f2 100644 --- a/addons/mrp_subcontracting_repair/security/ir.model.access.csv +++ b/addons/mrp_subcontracting_repair/security/ir.model.access.csv @@ -1,2 +1,2 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_subcontracting_portal_repair_line,subcontracting.portal.repair_line,repair.model_repair_line,base.group_portal,1,0,0,0 +access_subcontracting_portal_repair_move,subcontracting.portal.repair_move,repair.model_stock_move,base.group_portal,1,0,0,0 diff --git a/addons/mrp_subcontracting_repair/security/mrp_subcontracting_repair_security.xml b/addons/mrp_subcontracting_repair/security/mrp_subcontracting_repair_security.xml index 43e3fd59ab5..bc3b6dbf09d 100644 --- a/addons/mrp_subcontracting_repair/security/mrp_subcontracting_repair_security.xml +++ b/addons/mrp_subcontracting_repair/security/mrp_subcontracting_repair_security.xml @@ -3,7 +3,7 @@ Repair Line Subcontractor - + [ '|', '|', diff --git a/addons/repair/__init__.py b/addons/repair/__init__.py index 2ae6446f9dc..27457bee6bd 100644 --- a/addons/repair/__init__.py +++ b/addons/repair/__init__.py @@ -3,3 +3,16 @@ from . import models from . import wizard +from . import report + +from odoo import api, SUPERUSER_ID + +def _create_warehouse_data(env): + """ This hook is used to add default repair picking types on every warehouse. + It is necessary if the repair module is installed after some warehouses were already created. + """ + warehouses = env['stock.warehouse'].search([('repair_type_id', '=', False)]) + for warehouse in warehouses: + picking_type_vals = warehouse._create_or_update_sequences_and_picking_types() + if picking_type_vals: + warehouse.write(picking_type_vals) diff --git a/addons/repair/__manifest__.py b/addons/repair/__manifest__.py index 724a2a1f27d..9ce2837c30a 100644 --- a/addons/repair/__manifest__.py +++ b/addons/repair/__manifest__.py @@ -16,27 +16,29 @@ The following topics are covered by this module: ------------------------------------------------------ * Add/remove products in the reparation * Impact for stocks - * Invoicing (products and/or services) * Warranty concept * Repair quotation report * Notes for the technician and for the final customer """, - 'depends': ['stock', 'sale_management', 'account'], + 'depends': ['stock', 'sale_management'], 'data': [ 'security/ir.model.access.csv', 'security/repair_security.xml', - 'wizard/repair_make_invoice_views.xml', 'wizard/stock_warn_insufficient_qty_views.xml', + 'wizard/repair_warn_uncomplete_move.xml', + 'views/product_views.xml', 'views/stock_move_views.xml', 'views/repair_views.xml', + 'views/sale_order_views.xml', 'views/stock_lot_views.xml', 'views/stock_picking_views.xml', + 'views/stock_warehouse_views.xml', 'report/repair_reports.xml', 'report/repair_templates_repair_order.xml', - 'data/ir_sequence_data.xml', - 'data/mail_template_data.xml', + 'data/repair_data.xml', ], 'demo': ['data/repair_demo.xml'], + 'post_init_hook': '_create_warehouse_data', 'installable': True, 'application': True, 'license': 'LGPL-3', diff --git a/addons/repair/data/ir_sequence_data.xml b/addons/repair/data/ir_sequence_data.xml deleted file mode 100644 index 86c21acd619..00000000000 --- a/addons/repair/data/ir_sequence_data.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - Repair Order - repair.order - RO/ - 5 - - - - diff --git a/addons/repair/data/mail_template_data.xml b/addons/repair/data/mail_template_data.xml deleted file mode 100644 index c7a70cd4742..00000000000 --- a/addons/repair/data/mail_template_data.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - Repair: Quotation - - {{ object.partner_id.name }} Repair Orders (Ref {{ object.name or 'n/a' }}) - {{ (object.create_uid.email_formatted or user.email_formatted) }} - {{ object.partner_id.id }} - Sent manually when clicking on "Send Quotation" on a repair order - -
-

- Hello Brandon Freeman,
- Here is your repair order RO/00004 - - amounting in $ 100.00.
-
- - .
-
- You can reply to this email if you have any questions. -

- Thank you, - -
- --
Mitchell Admin
-
-

-
- - {{ object.partner_id.lang }} - -
-
-
diff --git a/addons/repair/data/repair_data.xml b/addons/repair/data/repair_data.xml new file mode 100644 index 00000000000..ffa99682992 --- /dev/null +++ b/addons/repair/data/repair_data.xml @@ -0,0 +1,24 @@ + + + + + + Repairs + repair_operation + + + + + + RO + + + + + + + diff --git a/addons/repair/data/repair_demo.xml b/addons/repair/data/repair_demo.xml index 9afff766c5f..b4bfa1530f7 100644 --- a/addons/repair/data/repair_demo.xml +++ b/addons/repair/data/repair_demo.xml @@ -6,38 +6,26 @@ 20.5 30.75 service + True
- - - none - - - + - @@ -45,30 +33,17 @@ - - - after_repair - - - + - @@ -79,30 +54,17 @@ - - - b4repair - - - + - diff --git a/addons/repair/models/__init__.py b/addons/repair/models/__init__.py index 43a7bd98095..0dbaf3a3c69 100644 --- a/addons/repair/models/__init__.py +++ b/addons/repair/models/__init__.py @@ -2,9 +2,10 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import repair +from . import stock_move from . import stock_picking from . import stock_traceability from . import stock_lot -from . import account_move from . import product -from . import mail_compose_message +from . import sale_order +from . import stock_warehouse diff --git a/addons/repair/models/account_move.py b/addons/repair/models/account_move.py deleted file mode 100644 index 6ee2b2e6b6f..00000000000 --- a/addons/repair/models/account_move.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- - -from odoo import models, fields - - -class AccountMove(models.Model): - _inherit = 'account.move' - - repair_ids = fields.One2many('repair.order', 'invoice_id', readonly=True, copy=False) - - def unlink(self): - repairs = self.sudo().repair_ids.filtered(lambda repair: repair.state != 'cancel') - if repairs: - repairs.sudo(False).state = '2binvoiced' - return super().unlink() - - -class AccountMoveLine(models.Model): - _inherit = 'account.move.line' - - repair_line_ids = fields.One2many('repair.line', 'invoice_line_id', readonly=True, copy=False) - repair_fee_ids = fields.One2many('repair.fee', 'invoice_line_id', readonly=True, copy=False) diff --git a/addons/repair/models/product.py b/addons/repair/models/product.py index 3dcdf492202..9582bbba255 100644 --- a/addons/repair/models/product.py +++ b/addons/repair/models/product.py @@ -1,18 +1,24 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import models +from odoo import fields, models class Product(models.Model): _inherit = "product.product" def _count_returned_sn_products(self, sn_lot): - res = self.env['repair.line'].search_count([ - ('type', '=', 'remove'), + res = self.env['stock.move'].search_count([ + ('repair_line_type', '=', 'remove'), ('product_uom_qty', '=', 1), - ('lot_id', '=', sn_lot.id), + ('move_line_ids.lot_id', 'in', sn_lot.id), ('state', '=', 'done'), - ('location_dest_id.usage', '=', 'internal'), + ('location_dest_usage', '=', 'internal'), ]) return super()._count_returned_sn_products(sn_lot) + res + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + create_repair = fields.Boolean('Create Repair', help="Create a linked Repair Order on Sale Order confirmation of this product.", groups='stock.group_stock_user') diff --git a/addons/repair/models/repair.py b/addons/repair/models/repair.py index 5c2dab407b5..4facb47b95a 100644 --- a/addons/repair/models/repair.py +++ b/addons/repair/models/repair.py @@ -1,125 +1,157 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. -from collections import defaultdict from random import randint -from markupsafe import Markup - -from odoo import api, fields, models, _ +from odoo import api, Command, fields, models, _ from odoo.exceptions import UserError, ValidationError -from odoo.tools import float_compare, is_html_empty +from odoo.tools import float_compare, float_is_zero +from odoo.tools.misc import format_date, groupby - -class StockMove(models.Model): - _inherit = 'stock.move' - - repair_id = fields.Many2one('repair.order', check_company=True) +MAP_REPAIR_TO_PICKING_LOCATIONS = { + 'location_id': 'default_location_src_id', + 'location_dest_id': 'default_location_dest_id', + 'parts_location_id': 'default_remove_location_dest_id', + 'recycle_location_id': 'default_recycle_location_dest_id', +} class Repair(models.Model): + """ Repair Orders """ _name = 'repair.order' _description = 'Repair Order' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'priority desc, create_date desc' _check_company_auto = True + @api.model + def _default_picking_type_id(self): + return self._get_picking_type().get((self.env.company, self.env.user)) + + # Common Fields name = fields.Char( 'Repair Reference', default='New', index='trigram', copy=False, required=True, readonly=True) - description = fields.Char('Repair Description') - product_id = fields.Many2one( - 'product.product', string='Product to Repair', - check_company=True, - domain="[('type', 'in', ['product', 'consu'])]", - readonly=True, required=True, states={'draft': [('readonly', False)]}) - product_qty = fields.Float( - 'Product Quantity', - default=1.0, digits='Product Unit of Measure', - readonly=True, required=True, states={'draft': [('readonly', False)]}) - product_uom = fields.Many2one( - 'uom.uom', 'Product Unit of Measure', - compute='_compute_product_uom', store=True, precompute=True, - readonly=True, required=True, states={'draft': [('readonly', False)]}, domain="[('category_id', '=', product_uom_category_id)]") - product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id') - partner_id = fields.Many2one( - 'res.partner', 'Customer', - index=True, states={'confirmed': [('readonly', True)]}, check_company=True, change_default=True, - help='Choose partner for whom the order will be invoiced and delivered. You can find a partner by its Name, TIN, Email or Internal Reference.') - address_id = fields.Many2one( - 'res.partner', 'Delivery Address', - domain="[('parent_id','=',partner_id)]", check_company=True, - states={'confirmed': [('readonly', True)]}) - default_address_id = fields.Many2one('res.partner', compute='_compute_default_address_id') - state = fields.Selection([ - ('draft', 'Quotation'), - ('confirmed', 'Confirmed'), - ('ready', 'Ready to Repair'), - ('under_repair', 'Under Repair'), - ('2binvoiced', 'To be Invoiced'), - ('done', 'Repaired'), - ('cancel', 'Cancelled')], string='Status', - copy=False, default='draft', readonly=True, tracking=True, - help="* The \'Draft\' status is used when a user is encoding a new and unconfirmed repair order.\n" - "* The \'Confirmed\' status is used when a user confirms the repair order.\n" - "* The \'Ready to Repair\' status is used to start to repairing, user can start repairing only after repair order is confirmed.\n" - "* The \'Under Repair\' status is used when the repair is ongoing.\n" - "* The \'To be Invoiced\' status is used to generate the invoice before or after repairing done.\n" - "* The \'Done\' status is set when repairing is completed.\n" - "* The \'Cancelled\' status is used when user cancel repair order.") - schedule_date = fields.Date("Scheduled Date") - location_id = fields.Many2one( - 'stock.location', 'Location', - compute="_compute_location_id", store=True, precompute=True, - index=True, readonly=True, required=True, check_company=True, - help="This is the location where the product to repair is located.", - states={'draft': [('readonly', False)], 'confirmed': [('readonly', True)]}) - lot_id = fields.Many2one( - 'stock.lot', 'Lot/Serial', - domain="[('product_id','=', product_id), ('company_id', '=', company_id)]", check_company=True, - help="Products repaired are all belonging to this lot") - guarantee_limit = fields.Date('Warranty Expiration', states={'confirmed': [('readonly', True)]}) - operations = fields.One2many( - 'repair.line', 'repair_id', 'Parts', - copy=True) - pricelist_id = fields.Many2one( - 'product.pricelist', 'Pricelist', - help='Pricelist of the selected partner.', check_company=True) - currency_id = fields.Many2one( - comodel_name='res.currency', - compute='_compute_currency_id', - store=True, - precompute=True, - ondelete='restrict') - partner_invoice_id = fields.Many2one('res.partner', 'Invoicing Address', check_company=True) - invoice_method = fields.Selection([ - ("none", "No Invoice"), - ("b4repair", "Before Repair"), - ("after_repair", "After Repair")], string="Invoice Method", - default='none', index=True, readonly=True, required=True, - states={'draft': [('readonly', False)]}, - help='Selecting \'Before Repair\' or \'After Repair\' will allow you to generate invoice before or after the repair is done respectively. \'No invoice\' means you don\'t want to generate invoice for this repair order.') - invoice_id = fields.Many2one( - 'account.move', 'Invoice', - copy=False, readonly=True, tracking=True, - domain=[('move_type', '=', 'out_invoice')]) - move_id = fields.Many2one( - 'stock.move', 'Inventory Move', - copy=False, readonly=True, tracking=True, check_company=True) - fees_lines = fields.One2many( - 'repair.fee', 'repair_id', 'Operations', - copy=True, readonly=False) - internal_notes = fields.Html('Internal Notes') - quotation_notes = fields.Html('Quotation Notes') - user_id = fields.Many2one('res.users', string="Responsible", default=lambda self: self.env.user, check_company=True) company_id = fields.Many2one( 'res.company', 'Company', readonly=True, required=True, index=True, default=lambda self: self.env.company) + state = fields.Selection([ + ('draft', 'New'), + ('confirmed', 'Confirmed'), + ('under_repair', 'Under Repair'), + ('done', 'Repaired'), + ('cancel', 'Cancelled')], string='Status', + copy=False, default='draft', readonly=True, tracking=True, index=True, + help="* The \'New\' status is used when a user is encoding a new and unconfirmed repair order.\n" + "* The \'Confirmed\' status is used when a user confirms the repair order.\n" + "* The \'Under Repair\' status is used when the repair is ongoing.\n" + "* The \'Repaired\' status is set when repairing is completed.\n" + "* The \'Cancelled\' status is used when user cancel repair order.") + priority = fields.Selection([('0', 'Normal'), ('1', 'Urgent')], default='0', string="Priority") + partner_id = fields.Many2one( + 'res.partner', 'Customer', + index=True, check_company=True, change_default=True, + help='Choose partner for whom the order will be invoiced and delivered. You can find a partner by its Name, TIN, Email or Internal Reference.') + user_id = fields.Many2one('res.users', string="Responsible", default=lambda self: self.env.user, check_company=True) + + # Specific Fields + internal_notes = fields.Html('Internal Notes') + tag_ids = fields.Many2many('repair.tags', string="Tags") + under_warranty = fields.Boolean( + 'Under Warranty', + help='If ticked, the sales price will be set to 0 for all products transferred from the repair order.', + states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) + schedule_date = fields.Datetime("Scheduled Date", default=fields.Datetime.now, index=True, required=True, copy=False) + + # Product To Repair + move_id = fields.Many2one( # Generated in 'action_repair_done', needed for traceability + 'stock.move', 'Inventory Move', + copy=False, readonly=True, tracking=True, check_company=True) + product_id = fields.Many2one( + 'product.product', string='Product to Repair', + domain="[('type', 'in', ['product', 'consu']), '|', ('company_id', '=', company_id), ('company_id', '=', False)]", + states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, check_company=True) + product_qty = fields.Float( + 'Product Quantity', + default=1.0, digits='Product Unit of Measure', + readonly=True, states={'draft': [('readonly', False)]}) + product_uom = fields.Many2one( + 'uom.uom', 'Product Unit of Measure', + compute='compute_product_uom', store=True, precompute=True, + readonly=True, states={'draft': [('readonly', False)]}, domain="[('category_id', '=', product_uom_category_id)]") + product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id') + lot_id = fields.Many2one( + 'stock.lot', 'Lot/Serial', + default=False, + compute="compute_lot_id", store=True, + domain="[('product_id','=', product_id), ('company_id', '=', company_id)]", check_company=True, + help="Products repaired are all belonging to this lot") + tracking = fields.Selection(string='Product Tracking', related="product_id.tracking", readonly=False) + + # Picking & Locations + picking_type_id = fields.Many2one( + 'stock.picking.type', 'Operation Type', copy=True, readonly=False, + compute='_compute_picking_type_id', store=True, + default=_default_picking_type_id, + domain="[('code', '=', 'repair_operation'), ('company_id', '=', company_id)]", + required=True, check_company=True, index=True) + procurement_group_id = fields.Many2one( + 'procurement.group', 'Procurement Group', + copy=False) + location_id = fields.Many2one( + 'stock.location', 'Product to Repair Source & Destination Location', + compute="_compute_location_ids", store=True, + index=True, readonly=False, check_company=True, required=True, + help="This is the location where the product to repair is located.") + location_dest_id = fields.Many2one( + 'stock.location', 'Added Parts Destination Location', + compute="_compute_location_ids", store=True, + index=True, readonly=True, check_company=True, required=True, + help="This is the location where the repaired product is located.") + parts_location_id = fields.Many2one( + 'stock.location', 'Removed Parts Destination Location', + compute="_compute_location_ids", store=True, + index=True, readonly=True, check_company=True, required=True, + help="This is the location where the repair parts are located.") + recycle_location_id = fields.Many2one( + 'stock.location', 'Recycled Parts Destination Location', + compute="_compute_location_ids", store=True, + index=True, readonly=False, check_company=True, required=True, + help="This is the location where the repair parts are located.") + + # Parts + move_ids = fields.One2many( + 'stock.move', 'repair_id', "Parts", check_company=True, copy=True, + domain=[('repair_line_type', '!=', False)]) # Once RO switch to state done, a binded move is created for the "Product to repair" (move_id), this move appears in 'move_ids' if not filtered + parts_availability = fields.Char( + string="Component Status", compute='_compute_parts_availability', + help="Latest parts availability status for this RO. If green, then the RO's readiness status is ready.") + parts_availability_state = fields.Selection([ + ('available', 'Available'), + ('expected', 'Expected'), + ('late', 'Late')], compute='_compute_parts_availability') + is_parts_available = fields.Boolean( + 'All Parts are available', + default=False, store=True, compute='_compute_availability_boolean') + is_parts_late = fields.Boolean( + 'Any Part is late', + default=False, store=True, compute='_compute_availability_boolean') + + # Sale Order Binding sale_order_id = fields.Many2one( - 'sale.order', 'Sale Order', check_company=True, - copy=False, help="Sale Order from which the product to be repaired comes from.") + 'sale.order', 'Sale Order', check_company=True, readonly=True, + copy=False, help="Sale Order from which the Repair Order comes from.") + sale_order_line_id = fields.Many2one( + 'sale.order.line', check_company=True, readonly=True, + copy=False, help="Sale Order Line from which the Repair Order comes from.") + repair_request = fields.Char( + related='sale_order_line_id.product_id.display_name', + string='Repair Request', + help="Product from which the Repair Order was created.") + + # Return Binding picking_id = fields.Many2one( 'stock.picking', 'Return', check_company=True, copy=False, help="Return Order from which the product to be repaired comes from.") @@ -127,80 +159,122 @@ class Repair(models.Model): is_returned = fields.Boolean( "Returned", compute='_compute_is_returned', help="True if this repair is linked to a Return Order and the order is 'Done'. False otherwise.") - tag_ids = fields.Many2many('repair.tags', string="Tags") - invoiced = fields.Boolean('Invoiced', copy=False, readonly=True) - repaired = fields.Boolean('Repaired', copy=False, readonly=True) - amount_untaxed = fields.Float('Untaxed Amount', compute='_amount_untaxed', store=True) - amount_tax = fields.Float('Taxes', compute='_amount_tax', store=True) - amount_total = fields.Float('Total', compute='_amount_total', store=True) - tracking = fields.Selection(string='Product Tracking', related="product_id.tracking", readonly=False) - invoice_state = fields.Selection(string='Invoice State', related='invoice_id.state') - priority = fields.Selection([('0', 'Normal'), ('1', 'Urgent')], default='0', string="Priority") - tax_calculation_rounding_method = fields.Selection( - related='company_id.tax_calculation_rounding_method', - string='Tax calculation rounding method', readonly=True) + + # UI Fields + show_set_qty_button = fields.Boolean(compute='_compute_show_qty_button') + show_clear_qty_button = fields.Boolean(compute='_compute_show_qty_button') + unreserve_visible = fields.Boolean( + 'Allowed to Unreserve Production', compute='_compute_unreserve_visible', + help='Technical field to check when we can unreserve') + reserve_visible = fields.Boolean( + 'Allowed to Reserve Production', compute='_compute_unreserve_visible', + help='Technical field to check when we can reserve quantities') + + @api.depends('product_id', 'product_id.uom_id.category_id', 'product_uom.category_id') + def compute_product_uom(self): + for repair in self: + if not repair.product_id: + repair.product_uom = False + elif not repair.product_uom or repair.product_uom.category_id != repair.product_id.uom_id.category_id: + repair.product_uom = repair.product_id.uom_id + + @api.depends('product_id', 'lot_id', 'lot_id.product_id') + def compute_lot_id(self): + for repair in self: + if (repair.product_id and repair.lot_id and repair.lot_id.product_id != repair.product_id) or not repair.product_id: + repair.lot_id = False + + @api.depends('user_id', 'company_id') + def _compute_picking_type_id(self): + picking_type_by_company = self._get_picking_type() + for ro in self: + ro.picking_type_id = picking_type_by_company.get((ro.company_id, ro.user_id)) or\ + picking_type_by_company.get((ro.company_id, False)) + + @api.depends('picking_type_id') + def _compute_location_ids(self): + for repair_location_field, picking_location_field in MAP_REPAIR_TO_PICKING_LOCATIONS.items(): + self[repair_location_field] = self.picking_type_id[picking_location_field] + + @api.depends('state', 'schedule_date', 'move_ids', 'move_ids.forecast_availability', 'move_ids.forecast_expected_date') + def _compute_parts_availability(self): + repairs = self.filtered(lambda ro: ro.state in ('confirmed', 'under_repair')) + repairs.parts_availability_state = 'available' + repairs.parts_availability = _('Available') + + other_repairs = self - repairs + other_repairs.parts_availability = False + other_repairs.parts_availability_state = False + + all_moves = repairs.move_ids + # Force to prefetch more than 1000 by 1000 + all_moves._fields['forecast_availability'].compute_value(all_moves) + for repair in repairs: + if any(float_compare(move.forecast_availability, move.product_qty, precision_rounding=move.product_id.uom_id.rounding) < 0 for move in repair.move_ids): + repair.parts_availability = _('Not Available') + repair.parts_availability_state = 'late' + continue + forecast_date = max(repair.move_ids.filtered('forecast_expected_date').mapped('forecast_expected_date'), default=False) + if not forecast_date: + continue + repair.parts_availability = _('Exp %s', format_date(self.env, forecast_date)) + if repair.schedule_date: + repair.parts_availability_state = 'late' if forecast_date > repair.schedule_date else 'expected' + + @api.depends('parts_availability_state') + def _compute_availability_boolean(self): + self.is_parts_available, self.is_parts_late = False, False + for repair in self: + if not repair.parts_availability_state: + continue + if repair.parts_availability_state == 'available': + repair.is_parts_available = True + elif repair.parts_availability_state == 'late': + repair.is_parts_late = True @api.depends('product_id') def _compute_allowed_picking_type_ids(self): - ''' - computes the ids of return picking types - ''' - out_picking_types = self.env['stock.picking.type'].search_read(domain=[('code', '=', 'outgoing')], - fields=['return_picking_type_id'], load='') + """Computes the ids of return picking types + """ + out_picking_types = self.env['stock.picking.type'].search_read( + domain=[('code', '=', 'outgoing')], fields=['return_picking_type_id'], load='') self.allowed_picking_type_ids = [ pt['return_picking_type_id'] for pt in out_picking_types if pt['return_picking_type_id']] - @api.depends('partner_id') - def _compute_default_address_id(self): - for order in self: - if order.partner_id: - order.default_address_id = order.partner_id.address_get(['contact'])['contact'] - - @api.depends('pricelist_id', 'company_id') - def _compute_currency_id(self): - for order in self: - order.currency_id = order.pricelist_id.currency_id.id or order.company_id.currency_id.id - @api.depends('picking_id', 'picking_id.state') def _compute_is_returned(self): self.is_returned = False returned = self.filtered(lambda r: r.picking_id and r.picking_id.state == 'done') returned.is_returned = True - @api.depends('operations.price_subtotal', 'invoice_method', 'fees_lines.price_subtotal', 'currency_id') - def _amount_untaxed(self): - for order in self: - total = sum(operation.price_subtotal for operation in order.operations) - total += sum(fee.price_subtotal for fee in order.fees_lines) - order.amount_untaxed = order.currency_id.round(total) + @api.depends('state', + 'move_ids.reserved_availability', + 'move_ids.quantity_done') + def _compute_show_qty_button(self): + self.show_set_qty_button = False + self.show_clear_qty_button = False + for repair in self.filtered(lambda r: r.state not in ['cancel', 'done']): + if any(float_is_zero(m.quantity_done, precision_rounding=m.product_uom.rounding) and not float_is_zero(m.reserved_availability, precision_rounding=m.product_uom.rounding) for m in repair.move_ids): + repair.show_set_qty_button = True + elif any(not float_is_zero(m.quantity_done, precision_rounding=m.product_uom.rounding) and float_compare(m.quantity_done, m.reserved_availability, precision_rounding=m.product_uom.rounding) == 0 for m in repair.move_ids): + repair.show_clear_qty_button = True - @api.depends('operations.price_unit', 'operations.product_uom_qty', 'operations.product_id', - 'fees_lines.price_unit', 'fees_lines.product_uom_qty', 'fees_lines.product_id', - 'currency_id', 'partner_id') - def _amount_tax(self): - for order in self: - val = 0.0 - currency = order.currency_id - for operation in order.operations: - if operation.tax_id: - tax_calculate = operation.tax_id.compute_all(operation.price_unit, currency, operation.product_uom_qty, operation.product_id, order.partner_id) - for c in tax_calculate['taxes']: - val += c['amount'] - for fee in order.fees_lines: - if fee.tax_id: - tax_calculate = fee.tax_id.compute_all(fee.price_unit, currency, fee.product_uom_qty, fee.product_id, order.partner_id) - for c in tax_calculate['taxes']: - val += c['amount'] - order.amount_tax = val + @api.depends('move_ids', 'state', 'move_ids.product_uom_qty') + def _compute_unreserve_visible(self): + for repair in self: + already_reserved = repair.state not in ('done', 'cancel') and any(repair.mapped('move_ids.move_line_ids.reserved_qty')) - @api.depends('amount_untaxed', 'amount_tax') - def _amount_total(self): - for order in self: - order.amount_total = order.currency_id.round(order.amount_untaxed + order.amount_tax) + repair.unreserve_visible = already_reserved + repair.reserve_visible = repair.state in ('confirmed', 'under_repair') and any(move.product_uom_qty and move.state in ['confirmed', 'partially_available'] for move in repair.move_ids) - _sql_constraints = [ - ('name', 'unique (name)', 'The name of the Repair Order must be unique!'), - ] + @api.onchange('product_uom') + def onchange_product_uom(self): + res = {} + if not self.product_id or not self.product_uom: + return res + if self.product_uom.category_id != self.product_id.uom_id.category_id: + res['warning'] = {'title': _('Warning'), 'message': _('The product unit of measure you chose has a different category than the product unit of measure.')} + return res @api.onchange('location_id', 'picking_id') def _onchange_location_picking(self): @@ -211,452 +285,254 @@ class Repair(models.Model): 'warning': {'title': _("Warning"), 'message': _("Note that the warehouses of the return and repair locations don't match!")}, } - @api.depends('product_id') - def _compute_product_uom(self): - for repair in self: - if repair.product_id: - repair.product_uom = repair.product_id.uom_id - - @api.onchange('product_id') - def onchange_product_id(self): - self.guarantee_limit = False - if (self.product_id and self.lot_id and self.lot_id.product_id != self.product_id) or not self.product_id: - self.lot_id = False - - @api.onchange('product_uom') - def onchange_product_uom(self): - res = {} - if not self.product_id or not self.product_uom: - return res - if self.product_uom.category_id != self.product_id.uom_id.category_id: - res['warning'] = {'title': _('Warning'), 'message': _('The product unit of measure you chose has a different category than the product unit of measure.')} - self.product_uom = self.product_id.uom_id.id - return res - - @api.onchange('partner_id') - def onchange_partner_id(self): - self = self.with_company(self.company_id) - if not self.partner_id: - self.address_id = False - self.partner_invoice_id = False - self.pricelist_id = self.env['product.pricelist'].search([ - ('company_id', 'in', [self.env.company.id, False]), - ], limit=1) - else: - addresses = self.partner_id.address_get(['delivery', 'invoice', 'contact']) - self.address_id = addresses['delivery'] or addresses['contact'] - self.partner_invoice_id = addresses['invoice'] - self.pricelist_id = self.partner_id.property_product_pricelist.id - - @api.depends('company_id') - def _compute_location_id(self): - for order in self: - if order.company_id: - if order.location_id.company_id != order.company_id: - warehouse = self.env['stock.warehouse'].search([('company_id', '=', order.company_id.id)], limit=1) - order.location_id = warehouse.lot_stock_id - else: - order.location_id = False - - @api.ondelete(at_uninstall=False) - def _unlink_except_confirmed(self): - for order in self: - if order.invoice_id and order.invoice_id.posted_before: - raise UserError(_('You can not delete a repair order which is linked to an invoice which has been posted once.')) - if order.state == 'done': - raise UserError(_('You cannot delete a completed repair order.')) - if order.state not in ('draft', 'cancel'): - raise UserError(_('You can not delete a repair order once it has been confirmed. You must first cancel it.')) - @api.model_create_multi def create(self, vals_list): # We generate a standard reference for vals in vals_list: - vals['name'] = self.env['ir.sequence'].next_by_code('repair.order') or '/' + picking_type = self.env['stock.picking.type'].browse(vals.get('picking_type_id', self.default_get('picking_type_id'))) + if 'picking_type_id' not in vals: + vals['picking_type_id'] = picking_type.id + if not vals.get('name', False) or vals['name'] == _('New'): + vals['name'] = picking_type.sequence_id.next_by_id() + if not vals.get('procurement_group_id'): + vals['procurement_group_id'] = self.env["procurement.group"].create({'name': vals['name']}).id + for location_field, picking_field in MAP_REPAIR_TO_PICKING_LOCATIONS.items(): + if location_field not in vals: + vals[location_field] = picking_type[picking_field].id return super().create(vals_list) - def button_dummy(self): - # TDE FIXME: this button is very interesting - return True + def write(self, vals): + res = super().write(vals) + if 'product_id' in vals and self.tracking == 'serial': + self.write({'product_qty': 1.0}) - def action_repair_cancel_draft(self): - if self.filtered(lambda repair: repair.state != 'cancel'): - raise UserError(_("Repair must be canceled in order to reset it to draft.")) - self.mapped('operations').write({'state': 'draft'}) - return self.write({'state': 'draft', 'invoice_id': False}) + for repair in self: + has_modified_location = any(key in vals for key in MAP_REPAIR_TO_PICKING_LOCATIONS) + if has_modified_location: + repair.move_ids._set_repair_locations() + if 'schedule_date' in vals: + (repair.move_id + repair.move_ids).filtered(lambda m: m.state not in ('done', 'cancel')).write({'date': repair.schedule_date}) + if 'under_warranty' in vals: + repair._update_sale_order_line_price() + return res - def action_validate(self): - self.ensure_one() - if self.filtered(lambda repair: any(op.product_uom_qty < 0 for op in repair.operations)): - raise UserError(_("You can not enter negative quantities.")) - if self.product_id.type == 'consu': - return self.action_repair_confirm() - precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') - available_qty_owner = self.env['stock.quant']._get_available_quantity(self.product_id, self.location_id, self.lot_id, owner_id=self.partner_id, strict=True) - available_qty_noown = self.env['stock.quant']._get_available_quantity(self.product_id, self.location_id, self.lot_id, strict=True) - repair_qty = self.product_uom._compute_quantity(self.product_qty, self.product_id.uom_id) - for available_qty in [available_qty_owner, available_qty_noown]: - if float_compare(available_qty, repair_qty, precision_digits=precision) >= 0: - return self.action_repair_confirm() - else: - return { - 'name': self.product_id.display_name + _(': Insufficient Quantity To Repair'), - 'view_mode': 'form', - 'res_model': 'stock.warn.insufficient.qty.repair', - 'view_id': self.env.ref('repair.stock_warn_insufficient_qty_repair_form_view').id, - 'type': 'ir.actions.act_window', - 'context': { - 'default_product_id': self.product_id.id, - 'default_location_id': self.location_id.id, - 'default_repair_id': self.id, - 'default_quantity': repair_qty, - 'default_product_uom_name': self.product_id.uom_name - }, - 'target': 'new' - } + @api.ondelete(at_uninstall=False) + def _unlink_except_confirmed(self): + repairs_to_cancel = self.filtered(lambda ro: ro.state not in ('draft', 'cancel')) + repairs_to_cancel.action_repair_cancel() - def action_repair_confirm(self): - """ Repair order state is set to 'To be invoiced' when invoice method - is 'Before repair' else state becomes 'Confirmed'. - @param *arg: Arguments - @return: True - """ - if self.filtered(lambda repair: repair.state != 'draft'): - raise UserError(_("Only draft repairs can be confirmed.")) - self._check_company() - self.operations._check_company() - self.fees_lines._check_company() - before_repair = self.filtered(lambda repair: repair.invoice_method == 'b4repair') - before_repair.write({'state': '2binvoiced'}) - to_confirm = self - before_repair - to_confirm_operations = to_confirm.mapped('operations') - to_confirm_operations.write({'state': 'confirmed'}) - to_confirm.write({'state': 'confirmed'}) - return True + def action_assign(self): + return self.move_ids._action_assign() + + def action_clear_quantities_to_zero(self): + return self.move_ids.filtered(lambda m: float_compare(m.quantity_done, m.reserved_availability, precision_rounding=m.product_uom.rounding) == 0)._clear_quantities_to_zero() + + def action_create_sale_order(self): + if any(repair.sale_order_id for repair in self): + concerned_ro = self.filtered('sale_order_id') + ref_str = "\n".join(ro.name for ro in concerned_ro) + raise UserError(_("You cannot create a quotation for a repair order that is already linked to an existing sale order.\nConcerned repair order(s) :\n") + ref_str) + if any(not repair.partner_id for repair in self): + concerned_ro = self.filtered(lambda ro: not ro.partner_id) + ref_str = "\n".join(ro.name for ro in concerned_ro) + raise UserError(_("You need to define a customer for a repair order in order to create an associated quotation.\nConcerned repair order(s) :\n") + ref_str) + sale_order_values_list = [] + for repair in self: + sale_order_values_list.append({ + "company_id": self.company_id.id, + "partner_id": self.partner_id.id, + "warehouse_id": self.picking_type_id.warehouse_id.id, + "repair_order_ids": [Command.link(repair.id)], + }) + self.env['sale.order'].create(sale_order_values_list) + # Add Sale Order Lines for 'add' move_ids + self.move_ids._create_repair_sale_order_line() + return self.action_view_sale_order() def action_repair_cancel(self): if any(repair.state == 'done' for repair in self): - raise UserError(_("You cannot cancel a completed repair order.")) - invoice_to_cancel = self.filtered(lambda repair: repair.invoice_id.state == 'draft').invoice_id - if invoice_to_cancel: - invoice_to_cancel.button_cancel() - self.mapped('operations').write({'state': 'cancel'}) + raise UserError(_("You cannot cancel a Repair Order that's already been completed")) + for repair in self: + if repair.sale_order_id: + repair.sale_order_line_id.write({'product_uom_qty': 0.0}) # Quantity of the product that generated the RO is set to 0 + self.move_ids._action_cancel() # Quantity of parts added from the RO to the SO is set to 0 return self.write({'state': 'cancel'}) - def action_send_mail(self): - self.ensure_one() - template_id = self.env.ref('repair.mail_template_repair_quotation').id - ctx = { - 'default_model': 'repair.order', - 'default_res_ids': self.ids, - 'default_template_id': template_id, - 'default_composition_mode': 'comment', - 'default_email_layout_xmlid': 'mail.mail_notification_light', - } - return { - 'type': 'ir.actions.act_window', - 'view_mode': 'form', - 'res_model': 'mail.compose.message', - 'target': 'new', - 'context': ctx, - } - - def print_repair_order(self): - return self.env.ref('repair.action_report_repair_order').report_action(self) - - def action_repair_invoice_create(self): - for repair in self: - repair._create_invoices() - if repair.invoice_method == 'b4repair': - repair.action_repair_ready() - elif repair.invoice_method == 'after_repair': - repair.write({'state': 'done'}) - return True - - def _create_invoices(self, group=False): - """ Creates invoice(s) for repair order. - @param group: It is set to true when group invoice is to be generated. - @return: Invoice Ids. - """ - grouped_invoices_vals = {} - repairs = self.filtered(lambda repair: repair.state not in ('draft', 'cancel') - and not repair.invoice_id - and repair.invoice_method != 'none') - for repair in repairs: - repair = repair.with_company(repair.company_id) - partner_invoice = repair.partner_invoice_id or repair.partner_id - if not partner_invoice: - raise UserError(_('You have to select an invoice address in the repair form.')) - - narration = repair.quotation_notes - currency = repair.currency_id - company = repair.env.company - - if (partner_invoice.id, currency.id, company.id) not in grouped_invoices_vals: - grouped_invoices_vals[(partner_invoice.id, currency.id, company.id)] = [] - current_invoices_list = grouped_invoices_vals[(partner_invoice.id, currency.id, company.id)] - - if not group or len(current_invoices_list) == 0: - fpos = self.env['account.fiscal.position']._get_fiscal_position(partner_invoice, delivery=repair.address_id) - invoice_vals = { - 'move_type': 'out_invoice', - 'partner_id': partner_invoice.id, - 'partner_shipping_id': repair.address_id.id, - 'currency_id': currency.id, - 'narration': narration if not is_html_empty(narration) else '', - 'invoice_origin': repair.name, - 'repair_ids': [(4, repair.id)], - 'invoice_line_ids': [], - 'fiscal_position_id': fpos.id - } - if partner_invoice.property_payment_term_id: - invoice_vals['invoice_payment_term_id'] = partner_invoice.property_payment_term_id.id - current_invoices_list.append(invoice_vals) - else: - # if group == True: concatenate invoices by partner and currency - invoice_vals = current_invoices_list[0] - invoice_vals['invoice_origin'] += ', ' + repair.name - invoice_vals['repair_ids'].append((4, repair.id)) - if not is_html_empty(narration): - if is_html_empty(invoice_vals['narration']): - invoice_vals['narration'] = narration - else: - invoice_vals['narration'] += Markup('
') + narration - - # Create invoice lines from operations. - for operation in repair.operations.filtered(lambda op: op.type == 'add'): - if group: - name = repair.name + '-' + operation.name - else: - name = operation.name - - account = operation.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=fpos)['income'] - if not account: - raise UserError(_('No account defined for product "%s".', operation.product_id.name)) - - invoice_line_vals = { - 'name': name, - 'account_id': account.id, - 'quantity': operation.product_uom_qty, - 'tax_ids': [(6, 0, operation.tax_id.ids)], - 'product_uom_id': operation.product_uom.id, - 'price_unit': operation.price_unit, - 'product_id': operation.product_id.id, - 'repair_line_ids': [(4, operation.id)], - } - - if currency == company.currency_id: - balance = -(operation.product_uom_qty * operation.price_unit) - invoice_line_vals.update({ - 'debit': balance > 0.0 and balance or 0.0, - 'credit': balance < 0.0 and -balance or 0.0, - }) - else: - amount_currency = -(operation.product_uom_qty * operation.price_unit) - balance = currency._convert(amount_currency, company.currency_id, company, fields.Date.today()) - invoice_line_vals.update({ - 'amount_currency': amount_currency, - 'debit': balance > 0.0 and balance or 0.0, - 'credit': balance < 0.0 and -balance or 0.0, - 'currency_id': currency.id, - }) - invoice_vals['invoice_line_ids'].append((0, 0, invoice_line_vals)) - - # Create invoice lines from fees. - for fee in repair.fees_lines: - if group: - name = repair.name + '-' + fee.name - else: - name = fee.name - - if not fee.product_id: - raise UserError(_('No product defined on fees.')) - - account = fee.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=fpos)['income'] - if not account: - raise UserError(_('No account defined for product "%s".', fee.product_id.name)) - - invoice_line_vals = { - 'name': name, - 'account_id': account.id, - 'quantity': fee.product_uom_qty, - 'tax_ids': [(6, 0, fee.tax_id.ids)], - 'product_uom_id': fee.product_uom.id, - 'price_unit': fee.price_unit, - 'product_id': fee.product_id.id, - 'repair_fee_ids': [(4, fee.id)], - } - - if currency == company.currency_id: - balance = -(fee.product_uom_qty * fee.price_unit) - invoice_line_vals.update({ - 'debit': balance > 0.0 and balance or 0.0, - 'credit': balance < 0.0 and -balance or 0.0, - }) - else: - amount_currency = -(fee.product_uom_qty * fee.price_unit) - balance = currency._convert(amount_currency, company.currency_id, company, - fields.Date.today()) - invoice_line_vals.update({ - 'amount_currency': amount_currency, - 'debit': balance > 0.0 and balance or 0.0, - 'credit': balance < 0.0 and -balance or 0.0, - 'currency_id': currency.id, - }) - invoice_vals['invoice_line_ids'].append((0, 0, invoice_line_vals)) - - # Create invoices. - invoices_vals_list_per_company = defaultdict(list) - for (partner_invoice_id, currency_id, company_id), invoices in grouped_invoices_vals.items(): - for invoice in invoices: - invoices_vals_list_per_company[company_id].append(invoice) - - for company_id, invoices_vals_list in invoices_vals_list_per_company.items(): - # VFE TODO remove the default_company_id ctxt key ? - # Account fallbacks on self.env.company, which is correct with with_company - self.env['account.move'].with_company(company_id).with_context(default_company_id=company_id, default_move_type='out_invoice').create(invoices_vals_list) - - repairs.write({'invoiced': True}) - repairs.mapped('operations').filtered(lambda op: op.type == 'add').write({'invoiced': True}) - repairs.mapped('fees_lines').write({'invoiced': True}) - - return dict((repair.id, repair.invoice_id.id) for repair in repairs) - - def action_created_invoice(self): - self.ensure_one() - return { - 'name': _('Invoice created'), - 'type': 'ir.actions.act_window', - 'view_mode': 'form', - 'res_model': 'account.move', - 'view_id': self.env.ref('account.view_move_form').id, - 'target': 'current', - 'res_id': self.invoice_id.id, - } - - def action_repair_ready(self): - self.mapped('operations').write({'state': 'confirmed'}) - return self.write({'state': 'ready'}) - - def action_repair_start(self): - """ Writes repair order state to 'Under Repair' - @return: True - """ - if self.filtered(lambda repair: repair.state not in ['confirmed', 'ready']): - raise UserError(_("Repair must be confirmed before starting reparation.")) - self.mapped('operations').write({'state': 'confirmed'}) - return self.write({'state': 'under_repair'}) - - def action_repair_end(self): - """ Writes repair order state to 'To be invoiced' if invoice method is - After repair else state is set to 'Ready'. - @return: True - """ - if self.filtered(lambda repair: repair.state != 'under_repair'): - raise UserError(_("Repair must be under repair in order to end reparation.")) - self._check_product_tracking() - for repair in self: - repair.write({'repaired': True}) - vals = {'state': 'done'} - vals['move_id'] = repair.action_repair_done().get(repair.id) - if not repair.invoice_id and repair.invoice_method == 'after_repair': - vals['state'] = '2binvoiced' - repair.write(vals) + def action_repair_cancel_draft(self): + if self.filtered(lambda repair: repair.state != 'cancel'): + self.action_repair_cancel() + sale_line_to_update = self.move_ids.sale_line_id.filtered(lambda l: l.order_id.state != 'cancel' and float_is_zero(l.product_uom_qty, precision_rounding=l.product_uom.rounding)) + sale_line_to_update.move_ids._update_repair_sale_order_line() + self.move_ids.state = 'draft' + self.state = 'draft' return True def action_repair_done(self): - """ Creates stock move for operation and stock move for final product of repair order. - @return: Move ids of final products - + """ Creates stock move for final product of repair order. + Writes move_id and move_ids state to 'done'. + Writes repair order state to 'Repaired'. + @return: True """ - if self.filtered(lambda repair: not repair.repaired): - raise UserError(_("Repair must be repaired in order to make the product moves.")) - self._check_company() - self.operations._check_company() - self.fees_lines._check_company() - res = {} + self._check_product_tracking() + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') - Move = self.env['stock.move'] + product_move_vals = [] + + # Cancel moves with 0 quantity_done + self.move_ids.filtered(lambda m: float_is_zero(m.quantity_done, precision_rounding=m.product_uom.rounding))._action_cancel() + + #SOL qty delivered = repair.move_ids.quantity_done for repair in self: + if repair.sale_order_line_id: + ro_origin_product = repair.sale_order_line_id.product_template_id + if ro_origin_product.detailed_type == 'service': + repair.sale_order_line_id.qty_delivered = repair.sale_order_line_id.product_uom_qty + if not repair.product_id: + continue + if repair.product_id.product_tmpl_id.tracking != 'none' and not repair.lot_id: + raise ValidationError(_( + "Serial number is required for product to repair : %s", + repair.product_id.display_name + )) + # Try to create move with the appropriate owner owner_id = False available_qty_owner = self.env['stock.quant']._get_available_quantity(repair.product_id, repair.location_id, repair.lot_id, owner_id=repair.partner_id, strict=True) if float_compare(available_qty_owner, repair.product_qty, precision_digits=precision) >= 0: owner_id = repair.partner_id.id - moves = self.env['stock.move'] - for operation in repair.operations: - move = Move.create({ - 'name': repair.name, - 'product_id': operation.product_id.id, - 'product_uom_qty': operation.product_uom_qty, - 'product_uom': operation.product_uom.id, - 'partner_id': repair.address_id.id, - 'location_id': operation.location_id.id, - 'location_dest_id': operation.location_dest_id.id, - 'repair_id': repair.id, - 'origin': repair.name, - 'company_id': repair.company_id.id, - }) - - # Best effort to reserve the product in a (sub)-location where it is available - product_qty = move.product_uom._compute_quantity( - operation.product_uom_qty, move.product_id.uom_id, rounding_method='HALF-UP') - available_quantity = self.env['stock.quant']._get_available_quantity( - move.product_id, - move.location_id, - lot_id=operation.lot_id, - strict=False, - ) - move._update_reserved_quantity( - product_qty, - available_quantity, - move.location_id, - lot_id=operation.lot_id, - strict=False, - ) - # Then, set the quantity done. If the required quantity was not reserved, negative - # quant is created in operation.location_id. - move._set_quantity_done(operation.product_uom_qty) - - if operation.lot_id: - move.move_line_ids.lot_id = operation.lot_id - - moves |= move - operation.write({'move_id': move.id, 'state': 'done'}) - move = Move.create({ + product_move_vals.append({ 'name': repair.name, 'product_id': repair.product_id.id, 'product_uom': repair.product_uom.id or repair.product_id.uom_id.id, 'product_uom_qty': repair.product_qty, - 'partner_id': repair.address_id.id, + 'partner_id': repair.partner_id.id, 'location_id': repair.location_id.id, 'location_dest_id': repair.location_id.id, - 'move_line_ids': [(0, 0, {'product_id': repair.product_id.id, - 'lot_id': repair.lot_id.id, - 'reserved_uom_qty': 0, # bypass reservation here - 'product_uom_id': repair.product_uom.id or repair.product_id.uom_id.id, - 'qty_done': repair.product_qty, - 'package_id': False, - 'result_package_id': False, - 'owner_id': owner_id, - 'location_id': repair.location_id.id, #TODO: owner stuff - 'company_id': repair.company_id.id, - 'location_dest_id': repair.location_id.id,})], + 'move_line_ids': [(0, 0, { + 'product_id': repair.product_id.id, + 'lot_id': repair.lot_id.id, + 'reserved_uom_qty': 0, # bypass reservation here + 'product_uom_id': repair.product_uom.id or repair.product_id.uom_id.id, + 'qty_done': repair.product_qty, + 'package_id': False, + 'result_package_id': False, + 'owner_id': owner_id, + 'location_id': repair.location_id.id, + 'company_id': repair.company_id.id, + 'location_dest_id': repair.location_id.id, + 'consume_line_ids': [(6, 0, repair.move_ids.move_line_ids.ids)] + })], 'repair_id': repair.id, 'origin': repair.name, 'company_id': repair.company_id.id, }) - consumed_lines = moves.mapped('move_line_ids') - produced_lines = move.move_line_ids - moves |= move - moves._action_done() - produced_lines.write({'consume_line_ids': [(6, 0, consumed_lines.ids)]}) - res[repair.id] = move.id - return res + + product_moves = self.env['stock.move'].create(product_move_vals) + self.move_id = product_moves.id + self.sale_order_line_id.move_ids._set_quantities_to_reservation() + + all_moves = self.move_ids + product_moves + self.sale_order_line_id.move_ids + all_moves._action_done() + + for sale_line in self.move_ids.sale_line_id: + price_unit = sale_line.price_unit + sale_line.write({'product_uom_qty': sale_line.qty_delivered, 'price_unit': price_unit}) + + self.state = 'done' + return True + + def action_repair_end(self): + """ Checks before action_repair_done. + @return: True + """ + if self.filtered(lambda repair: repair.state != 'under_repair'): + raise UserError(_("Repair must be under repair in order to end reparation.")) + if any(float_compare(move.quantity_done, move.product_uom_qty, precision_rounding=move.product_uom.rounding) < 0 for move in self.move_ids): + ctx = dict(self.env.context or {}) + ctx['default_repair_ids'] = self.ids + return { + 'name': _('Uncomplete Move(s)'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'views': [(False, 'form')], + 'res_model': 'repair.warn.uncomplete.move', + 'target': 'new', + 'context': ctx, + } + + return self.action_repair_done() + + def action_repair_start(self): + """ Writes repair order state to 'Under Repair' + """ + if self.filtered(lambda repair: repair.state != 'confirmed'): + self._action_repair_confirm() + return self.write({'state': 'under_repair'}) + + def action_set_quantities_to_reservation(self): + return self.move_ids.filtered(lambda m: float_is_zero(m.quantity_done, precision_rounding=m.product_uom.rounding))._set_quantities_to_reservation() + + def action_unreserve(self): + return self.move_ids.filtered(lambda m: m.state in ('assigned', 'partially_available'))._do_unreserve() + + def action_validate(self): + self.ensure_one() + if self.filtered(lambda repair: any(m.product_uom_qty < 0 for m in repair.move_ids)): + raise UserError(_("You can not enter negative quantities.")) + if not self.product_id or self.product_id.type == 'consu': + return self._action_repair_confirm() + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + available_qty_owner = self.env['stock.quant']._get_available_quantity(self.product_id, self.location_id, self.lot_id, owner_id=self.partner_id, strict=True) + available_qty_noown = self.env['stock.quant']._get_available_quantity(self.product_id, self.location_id, self.lot_id, strict=True) + repair_qty = self.product_uom._compute_quantity(self.product_qty, self.product_id.uom_id) + for available_qty in [available_qty_owner, available_qty_noown]: + if float_compare(available_qty, repair_qty, precision_digits=precision) >= 0: + return self._action_repair_confirm() + + return { + 'name': self.product_id.display_name + _(': Insufficient Quantity To Repair'), + 'view_mode': 'form', + 'res_model': 'stock.warn.insufficient.qty.repair', + 'view_id': self.env.ref('repair.stock_warn_insufficient_qty_repair_form_view').id, + 'type': 'ir.actions.act_window', + 'context': { + 'default_product_id': self.product_id.id, + 'default_location_id': self.location_id.id, + 'default_repair_id': self.id, + 'default_quantity': repair_qty, + 'default_product_uom_name': self.product_id.uom_name + }, + 'target': 'new' + } + + def action_view_sale_order(self): + return { + "type": "ir.actions.act_window", + "res_model": "sale.order", + "views": [[False, "form"]], + "res_id": self.sale_order_id.id, + } + + def print_repair_order(self): + return self.env.ref('repair.action_report_repair_order').report_action(self) + + def _action_repair_confirm(self): + """ Repair order state is set to 'Confirmed'. + @param *arg: Arguments + @return: True + """ + repairs_to_confirm = self.filtered(lambda repair: repair.state == 'draft') + repairs_to_confirm._check_company() + repairs_to_confirm.move_ids._check_company() + repairs_to_confirm.move_ids._adjust_procure_method() + repairs_to_confirm.move_ids._action_confirm() + repairs_to_confirm.move_ids._trigger_scheduler() + repairs_to_confirm.write({'state': 'confirmed'}) + return True def _check_product_tracking(self): - invalid_lines = self.operations.filtered(lambda x: x.tracking != 'none' and not x.lot_id) + invalid_lines = self.move_ids.filtered(lambda x: x.has_tracking != 'none' and not x.lot_ids) if invalid_lines: products = invalid_lines.product_id raise ValidationError(_( @@ -664,225 +540,47 @@ class Repair(models.Model): ", ".join(products.mapped('display_name')), )) + def _get_location(self, field): + return self.picking_type_id[MAP_REPAIR_TO_PICKING_LOCATIONS[field]] -class RepairLine(models.Model): - _name = 'repair.line' - _description = 'Repair Line (parts)' - _check_company_auto = True + def _get_picking_type(self): + companies = self.company_id or self.env.company + if not self: + # default case + default_warehouse = self.env.user.with_company(companies.id)._get_default_warehouse_id() + if default_warehouse and default_warehouse.repair_type_id: + return {(companies, self.env.user): default_warehouse.repair_type_id} - name = fields.Text('Description', required=True) - repair_id = fields.Many2one( - 'repair.order', 'Repair Order Reference', required=True, - index=True, ondelete='cascade', check_company=True) - company_id = fields.Many2one( - related='repair_id.company_id', store=True, index=True) - currency_id = fields.Many2one( - related='repair_id.currency_id') - type = fields.Selection([ - ('add', 'Add'), - ('remove', 'Remove')], 'Type', default='add', required=True) - product_id = fields.Many2one( - 'product.product', 'Product', required=True, check_company=True, - domain="[('type', 'in', ['product', 'consu'])]") - invoiced = fields.Boolean('Invoiced', copy=False, readonly=True) - price_unit = fields.Float('Unit Price', required=True, digits='Product Price') - price_subtotal = fields.Float('Subtotal', compute='_compute_price_total_and_subtotal', store=True, digits=0) - price_total = fields.Float('Total', compute='_compute_price_total_and_subtotal', store=True, digits=0) - tax_id = fields.Many2many( - 'account.tax', 'repair_operation_line_tax', 'repair_operation_line_id', 'tax_id', 'Taxes', - domain="[('type_tax_use','=','sale')]", check_company=True) - product_uom_qty = fields.Float( - 'Quantity', default=1.0, - digits='Product Unit of Measure', required=True) - product_uom = fields.Many2one( - 'uom.uom', 'Product Unit of Measure', - compute='_compute_product_uom', store=True, readonly=False, precompute=True, - required=True, domain="[('category_id', '=', product_uom_category_id)]") - product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id') - invoice_line_id = fields.Many2one( - 'account.move.line', 'Invoice Line', - copy=False, readonly=True, check_company=True) - location_id = fields.Many2one( - 'stock.location', 'Source Location', - compute='_compute_location_id', store=True, readonly=False, precompute=True, - index=True, required=True, check_company=True) - location_dest_id = fields.Many2one( - 'stock.location', 'Dest. Location', - compute='_compute_location_id', store=True, readonly=False, precompute=True, - index=True, required=True, check_company=True) - move_id = fields.Many2one( - 'stock.move', 'Inventory Move', - copy=False, readonly=True) - lot_id = fields.Many2one( - 'stock.lot', 'Lot/Serial', - domain="[('product_id','=', product_id)]", check_company=True) - state = fields.Selection([ - ('draft', 'Draft'), - ('confirmed', 'Confirmed'), - ('done', 'Done'), - ('cancel', 'Cancelled')], 'Status', default='draft', - copy=False, readonly=True, required=True, - help='The status of a repair line is set automatically to the one of the linked repair order.') - tracking = fields.Selection(string='Product Tracking', related="product_id.tracking") - tax_calculation_rounding_method = fields.Selection( - related='company_id.tax_calculation_rounding_method', - string='Tax calculation rounding method', readonly=True) - - @api.depends('price_unit', 'repair_id', 'product_uom_qty', 'product_id', 'tax_id', 'repair_id.invoice_method') - def _compute_price_total_and_subtotal(self): - for line in self: - taxes = line.tax_id.compute_all(line.price_unit, line.repair_id.currency_id, line.product_uom_qty, line.product_id, line.repair_id.partner_id) - line.price_subtotal = taxes['total_excluded'] - line.price_total = taxes['total_included'] - - @api.depends('product_id') - def _compute_product_uom(self): - for line in self: - line.product_uom = line.product_id.uom_id.id - - @api.depends('type') - def _compute_location_id(self): - for line in self: - if not line.type: - line.location_id = False - line.location_dest_id = False - elif line.type == 'add': - args = line.repair_id.company_id and [('company_id', '=', line.repair_id.company_id.id)] or [] - warehouse = line.env['stock.warehouse'].search(args, limit=1) - line.location_id = warehouse.lot_stock_id - line.location_dest_id = line.env['stock.location'].search([('usage', '=', 'production'), ('company_id', '=', line.repair_id.company_id.id)], limit=1) + picking_type_by_company_user = {} + without_default_warehouse_companies = set() + for (company, user), dummy in groupby(self, lambda r: (r.company_id, r.user_id)): + default_warehouse = user.with_company(company.id)._get_default_warehouse_id() + if default_warehouse and default_warehouse.repair_type_id: + picking_type_by_company_user[(company, user)] = default_warehouse.repair_type_id else: - line.location_id = line.env['stock.location'].search([('usage', '=', 'production'), ('company_id', '=', line.repair_id.company_id.id)], limit=1).id - line.location_dest_id = line.env['stock.location'].search([('scrap_location', '=', True), ('company_id', 'in', [line.repair_id.company_id.id, False])], limit=1).id + without_default_warehouse_companies.add(company.id) - @api.onchange('type') - def onchange_operation_type(self): - """ On change of operation type it sets source location, destination location - and to invoice field. - @param product: Changed operation type. - @param guarantee_limit: Guarantee limit of current record. - @return: Dictionary of values. - """ - if not self.type: - pass - elif self.type == 'add': - self.onchange_product_id() - else: - self.price_unit = 0.0 - self.tax_id = False + if not without_default_warehouse_companies: + return picking_type_by_company_user - @api.onchange('repair_id', 'product_id', 'product_uom_qty') - def onchange_product_id(self): - """ On change of product it sets product quantity, tax account, name, - uom of product, unit price and price subtotal. """ - if not self.product_id or not self.product_uom_qty: - return - self = self.with_company(self.company_id) - partner = self.repair_id.partner_id - partner_invoice = self.repair_id.partner_invoice_id or partner - if partner: - self = self.with_context(lang=partner.lang) - product = self.product_id - self.name = product.display_name - if product.description_sale: - if partner: - self.name += '\n' + self.product_id.with_context(lang=partner.lang).description_sale + domain = [ + ('code', '=', 'repair_operation'), + ('warehouse_id.company_id', 'in', list(without_default_warehouse_companies)), + ] + + picking_types = self.env['stock.picking.type'].search_read(domain, ['company_id'], load=False) + for picking_type in picking_types: + if (picking_type.company_id, False) not in picking_type_by_company_user: + picking_type_by_company_user[(picking_type.company_id, False)] = picking_type + return picking_type_by_company_user + + def _update_sale_order_line_price(self): + for repair in self: + add_moves = repair.move_ids.filtered(lambda m: m.repair_line_type == 'add' and m.sale_line_id) + if repair.under_warranty: + add_moves.sale_line_id.write({'price_unit': 0.0}) else: - self.name += '\n' + self.product_id.description_sale - if self.type != 'remove': - if partner: - fpos = self.env['account.fiscal.position']._get_fiscal_position(partner_invoice, delivery=self.repair_id.address_id) - taxes = self.product_id.taxes_id.filtered(lambda x: x.company_id == self.repair_id.company_id) - self.tax_id = fpos.map_tax(taxes) - self._onchange_product_uom() - - @api.onchange('product_uom') - def _onchange_product_uom(self): - if self.product_id and self.type != 'remove': - price = self.repair_id.pricelist_id._get_product_price( - self.product_id, self.product_uom_qty, uom=self.product_uom) - self.price_unit = price - - -class RepairFee(models.Model): - _name = 'repair.fee' - _description = 'Repair Fees' - _check_company_auto = True - - repair_id = fields.Many2one( - 'repair.order', 'Repair Order Reference', - index=True, ondelete='cascade', required=True) - company_id = fields.Many2one( - related="repair_id.company_id", index=True, store=True) - currency_id = fields.Many2one( - related="repair_id.currency_id") - name = fields.Text('Description', index=True, required=True) - product_id = fields.Many2one( - 'product.product', 'Product', check_company=True, - domain="[('type', '=', 'service')]") - product_uom_qty = fields.Float('Quantity', digits='Product Unit of Measure', required=True, default=1.0) - price_unit = fields.Float('Unit Price', required=True, digits='Product Price') - product_uom = fields.Many2one( - 'uom.uom', 'Product Unit of Measure', - compute='_compute_product_uom', store=True, readonly=False, precompute=True, - required=True, domain="[('category_id', '=', product_uom_category_id)]") - product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id') - price_subtotal = fields.Float('Subtotal', compute='_compute_price_total_and_subtotal', store=True, digits=0) - price_total = fields.Float('Total', compute='_compute_price_total_and_subtotal', store=True, digits=0) - tax_id = fields.Many2many( - 'account.tax', 'repair_fee_line_tax', 'repair_fee_line_id', 'tax_id', 'Taxes', - domain="[('type_tax_use','=','sale')]", check_company=True) - invoice_line_id = fields.Many2one('account.move.line', 'Invoice Line', copy=False, readonly=True, check_company=True) - invoiced = fields.Boolean('Invoiced', copy=False, readonly=True) - - @api.depends('price_unit', 'repair_id', 'product_uom_qty', 'product_id', 'tax_id') - def _compute_price_total_and_subtotal(self): - for fee in self: - taxes = fee.tax_id.compute_all(fee.price_unit, fee.repair_id.currency_id, fee.product_uom_qty, fee.product_id, fee.repair_id.partner_id) - fee.price_subtotal = taxes['total_excluded'] - fee.price_total = taxes['total_included'] - - @api.depends('product_id') - def _compute_product_uom(self): - for fee in self: - fee.product_uom = fee.product_id.uom_id - - @api.onchange('repair_id', 'product_id', 'product_uom_qty') - def onchange_product_id(self): - """ On change of product it sets product quantity, tax account, name, - uom of product, unit price and price subtotal. """ - if not self.product_id: - return - - self = self.with_company(self.company_id) - - partner = self.repair_id.partner_id - partner_invoice = self.repair_id.partner_invoice_id or partner - - if partner and self.product_id: - fpos = self.env['account.fiscal.position']._get_fiscal_position(partner_invoice, delivery=self.repair_id.address_id) - taxes = self.product_id.taxes_id.filtered(lambda x: x.company_id == self.repair_id.company_id) - self.tax_id = fpos.map_tax(taxes) - if partner: - self.name = self.product_id.with_context(lang=partner.lang).display_name - else: - self.name = self.product_id.display_name - self.product_uom = self.product_id.uom_id.id - if self.product_id.description_sale: - if partner: - self.name += '\n' + self.product_id.with_context(lang=partner.lang).description_sale - else: - self.name += '\n' + self.product_id.description_sale - - self._onchange_product_uom() - - @api.onchange('product_uom') - def _onchange_product_uom(self): - if self.product_id: - price = self.repair_id.pricelist_id._get_product_price( - self.product_id, self.product_uom_qty, uom=self.product_uom) - self.price_unit = price - + add_moves.sale_line_id._compute_price_unit() class RepairTags(models.Model): """ Tags of Repair's tasks """ diff --git a/addons/repair/models/sale_order.py b/addons/repair/models/sale_order.py new file mode 100644 index 00000000000..5ac8d62c7ae --- /dev/null +++ b/addons/repair/models/sale_order.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.tools import float_compare + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + repair_order_ids = fields.One2many( + comodel_name='repair.order', inverse_name='sale_order_id', + string='Repair Order', groups='stock.group_stock_user') + repair_count = fields.Integer( + "Repair Order(s)", compute='_compute_repair_count', groups='stock.group_stock_user') + + @api.depends('repair_order_ids') + def _compute_repair_count(self): + for order in self: + order.repair_count = len(order.repair_order_ids) + + def _action_cancel(self): + res = super()._action_cancel() + self.order_line._cancel_repair_order() + return res + + def _action_confirm(self): + res = super()._action_confirm() + self.order_line._create_repair_order() + return res + + def action_show_repair(self): + self.ensure_one() + if self.repair_count == 1: + return { + "type": "ir.actions.act_window", + "res_model": "repair.order", + "views": [[False, "form"]], + "res_id": self.repair_order_ids.id, + } + elif self.repair_count > 1: + return { + "name": _("Repair Orders"), + "type": "ir.actions.act_window", + "res_model": "repair.order", + "view_mode": "tree,form", + "domain": [('sale_order_id', '=', self.id)], + } + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + def _compute_qty_delivered(self): + remaining_so_lines = self + for so_line in self: + move = so_line.move_ids.sudo().filtered(lambda m: m.repair_id and m.state == 'done') + if len(move) != 1: + continue + remaining_so_lines -= so_line + so_line.qty_delivered = move.quantity_done + return super(SaleOrderLine, remaining_so_lines)._compute_qty_delivered() + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + res.filtered(lambda line: line.state in ('sale', 'done'))._create_repair_order() + return res + + def write(self, vals_list): + old_product_uom_qty = {line.id: line.product_uom_qty for line in self} + res = super().write(vals_list) + for line in self: + if line.state in ('sale', 'done'): + if float_compare(old_product_uom_qty[line.id], 0, precision_rounding=line.product_uom.rounding) <= 0 and float_compare(line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) > 0: + self._create_repair_order() + if float_compare(old_product_uom_qty[line.id], 0, precision_rounding=line.product_uom.rounding) > 0 and float_compare(line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) <= 0: + self._cancel_repair_order() + return res + + def _action_launch_stock_rule(self, previous_product_uom_qty=False): + # Picking must be generated for products created from the SO but not for parts added from the RO, as they're already handled there + lines_without_repair_move = self.filtered(lambda line: not line.move_ids.sudo().repair_id) + return super(SaleOrderLine, lines_without_repair_move)._action_launch_stock_rule(previous_product_uom_qty) + + def _create_repair_order(self): + new_repair_vals = [] + for line in self: + # One RO for each line with at least a quantity of 1, quantities > 1 don't create multiple ROs + if any(line.id == ro.sale_order_line_id.id for ro in line.order_id.sudo().repair_order_ids) and float_compare(line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) > 0: + binded_ro_ids = line.order_id.sudo().repair_order_ids.filtered(lambda ro: ro.sale_order_line_id.id == line.id and ro.state == 'cancel') + binded_ro_ids.action_repair_cancel_draft() + binded_ro_ids._action_repair_confirm() + continue + if not line.product_template_id.sudo().create_repair or line.move_ids.sudo().repair_id or float_compare(line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) <= 0: + continue + order = line.order_id + new_repair_vals.append({ + 'state': 'confirmed', + 'partner_id': order.partner_id.id, + 'sale_order_id': order.id, + 'sale_order_line_id': line.id, + 'picking_type_id': order.warehouse_id.repair_type_id.id, + }) + if new_repair_vals: + self.env['repair.order'].sudo().create(new_repair_vals) + + def _cancel_repair_order(self): + # Each RO binded to a SO line with Qty set to 0 or cancelled is set to 'Cancelled' + binded_ro_ids = self.env['repair.order'] + for line in self: + binded_ro_ids |= line.order_id.sudo().repair_order_ids.filtered(lambda ro: ro.sale_order_line_id.id == line.id and ro.state != 'done') + binded_ro_ids.action_repair_cancel() diff --git a/addons/repair/models/stock_lot.py b/addons/repair/models/stock_lot.py index 0d05c69ce0e..0255d909f50 100644 --- a/addons/repair/models/stock_lot.py +++ b/addons/repair/models/stock_lot.py @@ -3,6 +3,7 @@ from collections import defaultdict from odoo import api, fields, models, _ + class StockLot(models.Model): _inherit = 'stock.lot' @@ -13,8 +14,9 @@ class StockLot(models.Model): @api.depends('name') def _compute_repair_order_ids(self): repair_orders = defaultdict(lambda: self.env['repair.order']) - for repair_line in self.env['repair.line'].search([('lot_id', 'in', self.ids), ('state', '=', 'done')]): - repair_orders[repair_line.lot_id.id] |= repair_line.repair_id + for repair_line in self.env['stock.move'].search([('repair_id', '!=', False), ('move_line_ids.lot_id', 'in', self.ids), ('state', '=', 'done')]): + for rl_id in repair_line.lot_ids.ids: + repair_orders[rl_id] |= repair_line.repair_id for lot in self: lot.repair_order_ids = repair_orders[lot.id] lot.repair_order_count = len(lot.repair_order_ids) diff --git a/addons/repair/models/stock_move.py b/addons/repair/models/stock_move.py new file mode 100644 index 00000000000..daa6a181aeb --- /dev/null +++ b/addons/repair/models/stock_move.py @@ -0,0 +1,190 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, Command, fields, models +from odoo.tools.misc import groupby + +MAP_REPAIR_LINE_TYPE_TO_MOVE_LOCATIONS_FROM_REPAIR = { + 'add': {'location_id': 'location_id', 'location_dest_id': 'location_dest_id'}, + 'remove': {'location_id': 'location_dest_id', 'location_dest_id': 'parts_location_id'}, + 'recycle': {'location_id': 'location_dest_id', 'location_dest_id': 'recycle_location_id'}, +} + + +class StockMove(models.Model): + _inherit = 'stock.move' + + repair_id = fields.Many2one('repair.order', check_company=True) + repair_line_type = fields.Selection([ + ('add', 'Add'), + ('remove', 'Remove'), + ('recycle', 'Recycle') + ], 'Type', store=True, index=True) + + @api.depends('repair_line_type') + def _compute_forecast_information(self): + moves_to_compute = self.filtered(lambda move: not move.repair_line_type or move.repair_line_type == 'add') + for move in (self - moves_to_compute): + move.forecast_availability = move.product_qty + move.forecast_expected_date = False + return super(StockMove, moves_to_compute)._compute_forecast_information() + + @api.depends('repair_id.picking_type_id') + def _compute_picking_type_id(self): + remaining_moves = self + for move in self: + if move.repair_id: + move.picking_type_id = move.repair_id.picking_type_id + remaining_moves -= move + return super(StockMove, remaining_moves)._compute_picking_type_id() + + def copy_data(self, default=None): + default = dict(default or {}) + if 'repair_id' in default or self.repair_id: + default['sale_line_id'] = False + return super().copy_data(default) + + @api.ondelete(at_uninstall=False) + def _unlink_if_draft_or_cancel(self): + self.filtered('repair_id')._action_cancel() + return super()._unlink_if_draft_or_cancel() + + def unlink(self): + self._clean_repair_sale_order_line() + return super().unlink() + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get('repair_id') or 'repair_line_type' not in vals: + continue + repair_id = self.env['repair.order'].browse([vals['repair_id']]) + vals['name'] = repair_id.name + src_location, dest_location = self._get_repair_locations(vals['repair_line_type'], repair_id) + if not vals.get('location_id'): + vals['location_id'] = src_location.id + if not vals.get('location_dest_id'): + vals['location_dest_id'] = dest_location.id + moves = super().create(vals_list) + repair_moves = self.env['stock.move'] + for move in moves: + if not move.repair_id: + continue + move.group_id = move.repair_id.procurement_group_id.id + move.origin = move.name + move.picking_type_id = move.repair_id.picking_type_id.id + repair_moves |= move + + if move.state == 'draft' and move.repair_id.state in ('confirmed', 'under_repair'): + move._check_company() + move._adjust_procure_method() + move._action_confirm() + move._trigger_scheduler() + repair_moves._create_repair_sale_order_line() + return moves + + def write(self, vals): + res = super().write(vals) + repair_moves = self.env['stock.move'] + moves_to_create_so_line = self.env['stock.move'] + for move in self: + if not move.repair_id: + continue + # checks vals update + if 'repair_line_type' in vals or 'picking_type_id' in vals and move.product_id != move.repair_id.product_id: + move.location_id, move.location_dest_id = move._get_repair_locations(move.repair_line_type) + if not move.sale_line_id and 'sale_line_id' not in vals and move.repair_line_type == 'add': + moves_to_create_so_line |= move + if move.sale_line_id and ('repair_line_type' in vals or 'product_uom_qty' in vals): + repair_moves |= move + + repair_moves._update_repair_sale_order_line() + moves_to_create_so_line._create_repair_sale_order_line() + return res + + # Needed to also cancel the lastly added part + def _action_cancel(self): + self._clean_repair_sale_order_line() + return super()._action_cancel() + + def _create_repair_sale_order_line(self): + if not self: + return + so_line_vals = [] + for move in self: + if move.sale_line_id or move.repair_line_type != 'add' or not move.repair_id.sale_order_id: + continue + product_qty = move.product_uom_qty if move.repair_id.state != 'done' else move.quantity_done + so_line_vals.append({ + 'order_id': move.repair_id.sale_order_id.id, + 'product_id': move.product_id.id, + 'product_uom_qty': product_qty, # When relying only on so_line compute method, the sol quantity is only updated on next sol creation + 'move_ids': [Command.link(move.id)], + }) + if move.repair_id.under_warranty: + so_line_vals[-1]['price_unit'] = 0.0 + elif move.price_unit: + so_line_vals[-1]['price_unit'] = move.price_unit + + self.env['sale.order.line'].create(so_line_vals) + + def _clean_repair_sale_order_line(self): + self.filtered( + lambda m: m.repair_id and m.sale_line_id + ).mapped('sale_line_id').write({'product_uom_qty': 0.0}) + + def _update_repair_sale_order_line(self): + if not self: + return + moves_to_clean = self.env['stock.move'] + moves_to_update = self.env['stock.move'] + for move in self: + if not move.repair_id: + continue + if move.sale_line_id and move.repair_line_type != 'add': + moves_to_clean |= move + if move.sale_line_id and move.repair_line_type == 'add': + moves_to_update |= move + moves_to_clean._clean_repair_sale_order_line() + for sale_line, _ in groupby(moves_to_update, lambda m: m.sale_line_id): + sale_line.product_uom_qty = sum(sale_line.move_ids.mapped('product_uom_qty')) + + def _is_consuming(self): + return super()._is_consuming() or (self.repair_id and self.repair_line_type == 'add') + + def _get_repair_locations(self, repair_line_type, repair_id=False): + location_map = MAP_REPAIR_LINE_TYPE_TO_MOVE_LOCATIONS_FROM_REPAIR.get(repair_line_type) + if location_map: + if not repair_id: + self.repair_id.ensure_one() + repair_id = self.repair_id + location_id, location_dest_id = [repair_id[field] for field in location_map.values()] + else: + location_id, location_dest_id = False, False + return location_id, location_dest_id + + def _get_source_document(self): + return self.repair_id or super()._get_source_document() + + def _set_repair_locations(self): + moves_per_repair = self.filtered(lambda m: (m.repair_id and m.repair_line_type) is not False).grouped('repair_id') + if not moves_per_repair: + return + for moves in moves_per_repair.values(): + grouped_moves = moves.grouped('repair_line_type') + for line_type, m in grouped_moves.items(): + m.location_id, m.location_dest_id = m._get_repair_locations(line_type) + + def _should_be_assigned(self): + res = super(StockMove, self)._should_be_assigned() + return bool(res and not (self.repair_id and self.repair_line_type)) + + def _create_extra_move(self): + if self.repair_id: + return self + return super(StockMove, self)._create_extra_move() + + def _split(self, qty, restrict_partner_id=False): + # When setting the Repair Order as done with partially done moves, do not split these moves + if self.repair_id: + return [] + return super(StockMove, self)._split(qty, restrict_partner_id) diff --git a/addons/repair/models/stock_picking.py b/addons/repair/models/stock_picking.py index b5947d57e17..9ab65278513 100644 --- a/addons/repair/models/stock_picking.py +++ b/addons/repair/models/stock_picking.py @@ -1,24 +1,90 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import time + from odoo import _, api, fields, models +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT class PickingType(models.Model): _inherit = 'stock.picking.type' + code = fields.Selection(selection_add=[ + ('repair_operation', 'Repair') + ], ondelete={'repair_operation': 'cascade'}) + + count_repair_ready = fields.Integer( + string="Number of Repair Orders to Process", compute='_compute_count_repair') + count_repair_waiting = fields.Integer( + string="Number of Repair Orders Waiting", compute='_compute_count_repair') + count_repair_late = fields.Integer( + string="Number of Repair Orders Late", compute='_compute_count_repair') + + default_location_dest_id = fields.Many2one( + readonly={"code", "=", "repair_operation"}) + default_remove_location_dest_id = fields.Many2one( + 'stock.location', 'Default Remove Destination Location', + check_company=True, readonly=True, + help="This is the default remove destination location when you create a repair order with this operation type.") + + default_recycle_location_dest_id = fields.Many2one( + 'stock.location', 'Default Recycle Destination Location', + check_company=True, + help="This is the default recycle destination location when you create a repair order with this operation type.") + is_repairable = fields.Boolean( 'Create Repair Orders from Returns', compute='_compute_is_repairable', store=True, readonly=False, help="If ticked, you will be able to directly create repair orders from a return.") return_type_of_ids = fields.One2many('stock.picking.type', 'return_picking_type_id') + def _compute_count_repair(self): + repair_picking_types = self.filtered(lambda picking: picking.code == 'repair_operation') + + # By default, set count_repair_xxx to False + self.count_repair_ready = False + self.count_repair_waiting = False + self.count_repair_late = False + + # shortcut + if not repair_picking_types: + return + + domains = { + 'count_repair_ready': [ + ('is_parts_available', '=', True) + ], + 'count_repair_waiting': [], + 'count_repair_late': [ + '|', + ('schedule_date', '<', time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)), + ('is_parts_late', '=', True) + ], + } + + for field, domain in domains.items(): + picking_types = self.env['repair.order'].read_group( + [('picking_type_id', 'in', repair_picking_types.ids), ('state', 'in', ('confirmed', 'under_repair'))] + domain, + fields=['picking_type_id'], + groupby=['picking_type_id'] + ) + counts = {pt['picking_type_id'][0]:pt['picking_type_id_count'] for pt in picking_types} + for record in repair_picking_types: + record[field] = counts.get(record.id) + @api.depends('return_type_of_ids', 'code') def _compute_is_repairable(self): for picking_type in self: if not(picking_type.code == 'incoming' and picking_type.return_type_of_ids): picking_type.is_repairable = False + def get_repair_stock_picking_action_picking_type(self): + action = self.env["ir.actions.actions"]._for_xml_id('repair.action_picking_repair') + if self: + action['display_name'] = self.display_name + return action + class Picking(models.Model): _inherit = 'stock.picking' diff --git a/addons/repair/models/stock_warehouse.py b/addons/repair/models/stock_warehouse.py new file mode 100644 index 00000000000..cca7258595b --- /dev/null +++ b/addons/repair/models/stock_warehouse.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, _ + + +class StockWarehouse(models.Model): + _inherit = 'stock.warehouse' + + repair_type_id = fields.Many2one('stock.picking.type', 'Repair Operation Type', check_company=True) + + def _get_sequence_values(self, name=False, code=False): + values = super(StockWarehouse, self)._get_sequence_values(name=name, code=code) + values.update({ + 'repair_type_id': { + 'name': self.name + ' ' + _('Sequence repair'), + 'prefix': self.code + '/RO/', + 'padding': 5, + 'company_id': self.company_id.id + }, + }) + return values + + def _get_picking_type_create_values(self, max_sequence): + data, next_sequence = super(StockWarehouse, self)._get_picking_type_create_values(max_sequence) + prod_location = self.env['stock.location'].search([('usage', '=', 'production'), ('company_id', '=', self.company_id.id)], limit=1) + scrap_location = self.env['stock.location'].search([('scrap_location', '=', True), ('company_id', 'in', [self.company_id.id, False])], limit=1) + data.update({ + 'repair_type_id': { + 'name': _('Repairs'), + 'code': 'repair_operation', + 'default_location_src_id': self.lot_stock_id.id, + 'default_location_dest_id': prod_location.id, + 'default_remove_location_dest_id':scrap_location.id, + 'default_recycle_location_dest_id': self.lot_stock_id.id, + 'sequence': next_sequence + 1, + 'sequence_code': 'RO', + 'company_id': self.company_id.id, + }, + }) + return data, max_sequence + 2 + + def _get_picking_type_update_values(self): + data = super(StockWarehouse, self)._get_picking_type_update_values() + data.update({ + 'repair_type_id': { + 'active': self.active, + 'barcode': self.code.replace(" ", "").upper() + "-RO", + }, + }) + return data diff --git a/addons/repair/report/__init__.py b/addons/repair/report/__init__.py new file mode 100644 index 00000000000..5764676afd5 --- /dev/null +++ b/addons/repair/report/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import stock_forecasted diff --git a/addons/repair/report/repair_reports.xml b/addons/repair/report/repair_reports.xml index 2994461e965..9bdc4ebf165 100644 --- a/addons/repair/report/repair_reports.xml +++ b/addons/repair/report/repair_reports.xml @@ -2,14 +2,12 @@ - Quotation / Order + Repair Order repair.order qweb-pdf repair.report_repairorder2 repair.report_repairorder - ( - object.state == 'draft' and 'Repair Quotation - %s' % (object.name) or - 'Repair Order - %s' % (object.name)) + ('Repair Order - %s' % (object.name)) report diff --git a/addons/repair/report/repair_templates_repair_order.xml b/addons/repair/report/repair_templates_repair_order.xml index e7bc3bdaf49..abda8ab2680 100644 --- a/addons/repair/report/repair_templates_repair_order.xml +++ b/addons/repair/report/repair_templates_repair_order.xml @@ -5,148 +5,65 @@ - - Invoice and shipping address: -
- Invoice address: -
-
- - Shipping address: -
- - - -
-
-
-

- Repair Order #: - Repair Quotation #: + Repair Order #:

- -
-
- Product to Repair: -

+

+
+

+ Customer: + +

+

+ Product: + +

+

+ Lot/Serial: + +

-
- Lot/Serial Number: - - - -
-
- Warranty: -

-

-
- Printing Date: -

+

+

+ Status: + +

+

+ Responsible: + +

- +

Parts

- - - - - - - - + + - - - - - - - - - - - - - - - - -
Description QuantityUnit PriceTaxPrice
Parts
-

(Add)

-

(Remove)

+

(Add)

+

(Remove)

- - - - - -
Operations
- - - - - - - - - - -
-
-
- - - - - - - - - - - - - - - -
Total Without Taxes - -
Taxes - -
Total - -
-
+
+

Repair Notes

+

- -

diff --git a/addons/repair/report/stock_forecasted.py b/addons/repair/report/stock_forecasted.py new file mode 100644 index 00000000000..1bbaaf8b829 --- /dev/null +++ b/addons/repair/report/stock_forecasted.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class StockForecasted(models.AbstractModel): + _inherit = 'stock.forecasted_product_product' + + def _get_reservation_data(self, move): + if move.repair_id and move.repair_line_type: + return False + return super()._get_reservation_data(move) + + def _product_sale_domain(self, product_template_ids, product_ids): + """ + When a product's move is bind at the same time to a Repair Order + and to a Sale Order, only take the data into account once, as a RO + """ + sol_domain = super(StockForecasted, self)._product_sale_domain(product_template_ids, product_ids) + move_domain = self._product_domain(product_template_ids, product_ids) + move_domain += [ + ('repair_id', '!=', False), + ('sale_line_id', '!=', False), + ('repair_line_type', '=', 'add') + ] + sol_ids = self.env['stock.move']._read_group(move_domain, aggregates=['sale_line_id:array_agg'])[0][0] + + sol_domain += [('id', 'not in', sol_ids)] + return sol_domain diff --git a/addons/repair/security/ir.model.access.csv b/addons/repair/security/ir.model.access.csv index 3e1d7039154..d9eab02d212 100644 --- a/addons/repair/security/ir.model.access.csv +++ b/addons/repair/security/ir.model.access.csv @@ -1,8 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_repair_fee_user,Repair Fee user,model_repair_fee,stock.group_stock_user,1,1,1,1 access_repair_user,Repair user,model_repair_order,stock.group_stock_user,1,1,1,1 access_repair_tag_user,Repair Tags user,model_repair_tags,stock.group_stock_user,1,1,1,1 -access_repair_line_user,repair.line user,model_repair_line,stock.group_stock_user,1,1,1,1 -access_account_tax_user,account.tax,account.model_account_tax,stock.group_stock_user,1,0,0,0 -access_repair_order_make_invoice,access.repair.order.make_invoice,model_repair_order_make_invoice,stock.group_stock_user,1,1,1,0 access_stock_warn_insufficient_qty_repair,access.stock.warn.insufficient.qty.repair,model_stock_warn_insufficient_qty_repair,stock.group_stock_user,1,1,1,0 +access_repair_warn_uncomplete_move,access.repair.warn.uncomplete.move,model_repair_warn_uncomplete_move,stock.group_stock_user,1,1,1,0 diff --git a/addons/repair/security/repair_security.xml b/addons/repair/security/repair_security.xml index d00a7d04c8f..674b1ab4ea5 100644 --- a/addons/repair/security/repair_security.xml +++ b/addons/repair/security/repair_security.xml @@ -7,15 +7,5 @@ [('company_id', 'in', company_ids)] - - repair line multi-company - - [('company_id', 'in', company_ids)] - - - repair fee multi-company - - [('company_id', 'in', company_ids)] - diff --git a/addons/repair/tests/test_repair.py b/addons/repair/tests/test_repair.py index ec17c181f98..6416dd943bc 100644 --- a/addons/repair/tests/test_repair.py +++ b/addons/repair/tests/test_repair.py @@ -1,15 +1,16 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.addons.account.tests.common import AccountTestInvoicingCommon -from odoo.tests import tagged, Form +from odoo.exceptions import UserError, ValidationError +from odoo.tests import tagged, common, Form +from odoo.tools import float_compare, float_is_zero @tagged('post_install', '-at_install') -class TestRepair(AccountTestInvoicingCommon): +class TestRepair(common.TransactionCase): @classmethod - def setUpClass(cls, chart_template_ref=None): - super().setUpClass(chart_template_ref=chart_template_ref) + def setUpClass(cls): + super().setUpClass() # Partners cls.res_partner_1 = cls.env['res.partner'].create({'name': 'Wood Corner'}) @@ -18,15 +19,42 @@ class TestRepair(AccountTestInvoicingCommon): # Products cls.product_product_3 = cls.env['product.product'].create({'name': 'Desk Combination'}) - cls.product_product_11 = cls.env['product.product'].create({'name': 'Conference Chair'}) + cls.product_product_11 = cls.env['product.product'].create({ + 'name': 'Conference Chair', + 'lst_price': 30.0, + }) cls.product_product_5 = cls.env['product.product'].create({'name': 'Product 5'}) cls.product_product_6 = cls.env['product.product'].create({'name': 'Large Cabinet'}) cls.product_product_12 = cls.env['product.product'].create({'name': 'Office Chair Black'}) cls.product_product_13 = cls.env['product.product'].create({'name': 'Corner Desk Left Sit'}) - cls.product_product_2 = cls.env['product.product'].create({'name': 'Virtual Home Staging'}) - cls.product_service_order_repair = cls.env['product.product'].create({ - 'name': 'Repair Services', - 'type': 'service', + + # Storable products + cls.product_storable_no = cls.env['product.product'].create({ + 'name': 'Product Storable No Tracking', + 'type': 'product', + 'tracking': 'none', + }) + cls.product_storable_serial = cls.env['product.product'].create({ + 'name': 'Product Storable Serial', + 'type': 'product', + 'tracking': 'serial', + }) + cls.product_storable_lot = cls.env['product.product'].create({ + 'name': 'Product Storable Lot', + 'type': 'product', + 'tracking': 'lot', + }) + + # 'Create Repair' Products + cls.product_consu_order_repair = cls.env['product.product'].create({ + 'name': 'Repair Consumable', + 'type': 'consu', + 'create_repair': True, + }) + cls.product_storable_order_repair = cls.env['product.product'].create({ + 'name': 'Repair Storable', + 'type': 'product', + 'create_repair': True, }) # Location @@ -38,35 +66,15 @@ class TestRepair(AccountTestInvoicingCommon): # Repair Orders cls.repair1 = cls.env['repair.order'].create({ - 'address_id': cls.res_partner_address_1.id, - 'guarantee_limit': '2019-01-01', - 'invoice_method': 'none', - 'user_id': False, 'product_id': cls.product_product_3.id, 'product_uom': cls.env.ref('uom.product_uom_unit').id, - 'partner_invoice_id': cls.res_partner_address_1.id, - 'location_id': cls.stock_warehouse.lot_stock_id.id, - 'operations': [ + 'picking_type_id': cls.stock_warehouse.repair_type_id.id, + 'move_ids': [ (0, 0, { - 'location_dest_id': cls.product_product_11.property_stock_production.id, - 'location_id': cls.stock_warehouse.lot_stock_id.id, - 'name': cls.product_product_11.get_product_multiline_description_sale(), 'product_id': cls.product_product_11.id, - 'product_uom': cls.env.ref('uom.product_uom_unit').id, 'product_uom_qty': 1.0, - 'price_unit': 50.0, 'state': 'draft', - 'type': 'add', - 'company_id': cls.env.company.id, - }) - ], - 'fees_lines': [ - (0, 0, { - 'name': cls.product_service_order_repair.get_product_multiline_description_sale(), - 'product_id': cls.product_service_order_repair.id, - 'product_uom_qty': 1.0, - 'product_uom': cls.env.ref('uom.product_uom_unit').id, - 'price_unit': 50.0, + 'repair_line_type': 'add', 'company_id': cls.env.company.id, }) ], @@ -76,33 +84,14 @@ class TestRepair(AccountTestInvoicingCommon): cls.repair0 = cls.env['repair.order'].create({ 'product_id': cls.product_product_5.id, 'product_uom': cls.env.ref('uom.product_uom_unit').id, - 'address_id': cls.res_partner_address_1.id, - 'guarantee_limit': '2019-01-01', - 'invoice_method': 'after_repair', 'user_id': False, - 'partner_invoice_id': cls.res_partner_address_1.id, - 'location_id': cls.stock_warehouse.lot_stock_id.id, - 'operations': [ + 'picking_type_id': cls.stock_warehouse.repair_type_id.id, + 'move_ids': [ (0, 0, { - 'location_dest_id': cls.product_product_12.property_stock_production.id, - 'location_id': cls.stock_warehouse.lot_stock_id.id, - 'name': cls.product_product_12.get_product_multiline_description_sale(), - 'price_unit': 50.0, 'product_id': cls.product_product_12.id, - 'product_uom': cls.env.ref('uom.product_uom_unit').id, 'product_uom_qty': 1.0, 'state': 'draft', - 'type': 'add', - 'company_id': cls.env.company.id, - }) - ], - 'fees_lines': [ - (0, 0, { - 'name': cls.product_service_order_repair.get_product_multiline_description_sale(), - 'product_id': cls.product_service_order_repair.id, - 'product_uom_qty': 1.0, - 'product_uom': cls.env.ref('uom.product_uom_unit').id, - 'price_unit': 50.0, + 'repair_line_type': 'add', 'company_id': cls.env.company.id, }) ], @@ -112,33 +101,14 @@ class TestRepair(AccountTestInvoicingCommon): cls.repair2 = cls.env['repair.order'].create({ 'product_id': cls.product_product_6.id, 'product_uom': cls.env.ref('uom.product_uom_unit').id, - 'address_id': cls.res_partner_address_1.id, - 'guarantee_limit': '2019-01-01', - 'invoice_method': 'b4repair', 'user_id': False, - 'partner_invoice_id': cls.res_partner_address_1.id, - 'location_id': cls.stock_location_14.id, - 'operations': [ + 'picking_type_id': cls.stock_warehouse.repair_type_id.id, + 'move_ids': [ (0, 0, { - 'location_dest_id': cls.product_product_13.property_stock_production.id, - 'location_id': cls.stock_warehouse.lot_stock_id.id, - 'name': cls.product_product_13.get_product_multiline_description_sale(), - 'price_unit': 50.0, 'product_id': cls.product_product_13.id, - 'product_uom': cls.env.ref('uom.product_uom_unit').id, 'product_uom_qty': 1.0, 'state': 'draft', - 'type': 'add', - 'company_id': cls.env.company.id, - }) - ], - 'fees_lines': [ - (0, 0, { - 'name': cls.product_service_order_repair.get_product_multiline_description_sale(), - 'product_id': cls.product_service_order_repair.id, - 'product_uom_qty': 1.0, - 'product_uom': cls.env.ref('uom.product_uom_unit').id, - 'price_unit': 50.0, + 'repair_line_type': 'add', 'company_id': cls.env.company.id, }) ], @@ -147,188 +117,304 @@ class TestRepair(AccountTestInvoicingCommon): cls.env.user.groups_id |= cls.env.ref('stock.group_stock_user') - def _create_simple_repair_order(self, invoice_method): + def _create_simple_repair_order(self): product_to_repair = self.product_product_5 - partner = self.res_partner_address_1 return self.env['repair.order'].create({ 'product_id': product_to_repair.id, 'product_uom': product_to_repair.uom_id.id, - 'address_id': partner.id, - 'guarantee_limit': '2019-01-01', - 'invoice_method': invoice_method, - 'partner_invoice_id': partner.id, - 'location_id': self.stock_warehouse.lot_stock_id.id, + 'picking_type_id': self.stock_warehouse.repair_type_id.id, 'partner_id': self.res_partner_12.id }) - def _create_simple_operation(self, repair_id=False, qty=0.0, price_unit=0.0): - product_to_add = self.product_product_5 - return self.env['repair.line'].create({ - 'name': 'Add The product', - 'type': 'add', - 'product_id': product_to_add.id, + def _create_simple_part_move(self, repair_id=False, qty=0.0, product=False): + if not product: + product = self.product_product_5 + return self.env['stock.move'].create({ + 'repair_line_type': 'add', + 'product_id': product.id, 'product_uom_qty': qty, - 'product_uom': product_to_add.uom_id.id, - 'price_unit': price_unit, - 'repair_id': repair_id, - 'location_id': self.stock_warehouse.lot_stock_id.id, - 'location_dest_id': product_to_add.property_stock_production.id, - 'company_id': self.env.company.id, - }) - - def _create_simple_fee(self, repair_id=False, qty=0.0, price_unit=0.0): - product_service = self.product_product_2 - return self.env['repair.fee'].create({ - 'name': 'PC Assemble + Custom (PC on Demand)', - 'product_id': product_service.id, - 'product_uom_qty': qty, - 'product_uom': product_service.uom_id.id, - 'price_unit': price_unit, 'repair_id': repair_id, 'company_id': self.env.company.id, }) - def test_00_repair_afterinv(self): - repair = self._create_simple_repair_order('after_repair') - self._create_simple_operation(repair_id=repair.id, qty=1.0, price_unit=50.0) - # I confirm Repair order taking Invoice Method 'After Repair'. - repair.action_repair_confirm() + @classmethod + def create_quant(cls, product, qty, offset=0, name="L"): + i = 1 + if product.tracking == 'serial': + i, qty = qty, 1 + if name == "L": + name = "S" + + vals = [] + for x in range(1, i + 1): + qDict = { + 'location_id': cls.stock_warehouse.lot_stock_id.id, + 'product_id': product.id, + 'inventory_quantity': qty, + } + + if product.tracking != 'none': + qDict['lot_id'] = cls.env['stock.lot'].create({ + 'name': name + str(offset + x), + 'product_id': product.id, + 'company_id': cls.env.company.id + }).id + vals.append(qDict) + + return cls.env['stock.quant'].create(vals) + + def test_01_repair_states_transition(self): + repair = self._create_simple_repair_order() + # Draft -> Confirmed -> Cancel -> Draft -> Done -> Failing Cancel + # draft -> confirmed (action_validate -> _action_repair_confirm) + # PRE + # lines' qty >= 0 !-> UserError + # product's qty IS available !-> Warning w/ choice + # POST + # state = confirmed + # move_ids in (partially reserved, fully reserved, waiting availability) + + # Line A with qty < 0 --> UserError + lineA = self._create_simple_part_move(repair.id, -1.0, self.product_storable_no) + repair.move_ids |= lineA + with self.assertRaises(UserError): + repair.action_validate() + + # Line A with qty > 0 & not available, Line B with qty >= 0 & available --> Warning (stock.warn.insufficient.qty.repair) + lineA.product_uom_qty = 2.0 + lineB = self._create_simple_part_move(repair.id, 2.0, self.product_storable_lot) + repair.move_ids |= lineB + quant = self.create_quant(self.product_storable_no, 1) + quant |= self.create_quant(self.product_storable_lot, 3) + quant.action_apply_inventory() + + lineC = self._create_simple_part_move(repair.id, 1.0, self.product_storable_order_repair) + repair.move_ids |= lineC + + repair.product_id = self.product_storable_serial + validate_action = repair.action_validate() + self.assertEqual(validate_action.get("res_model"), "stock.warn.insufficient.qty.repair") + # Warn qty Wizard only apply to "product TO repair" + warn_qty_wizard = Form( + self.env['stock.warn.insufficient.qty.repair'] + .with_context(**validate_action['context']) + ).save() + warn_qty_wizard.action_done() - # I check the state is in "Confirmed". self.assertEqual(repair.state, "confirmed", 'Repair order should be in "Confirmed" state.') - repair.action_repair_start() + self.assertEqual(lineA.state, "partially_available", 'Repair line #1 should be in "Partial Availability" state.') + self.assertEqual(lineB.state, "assigned", 'Repair line #2 should be in "Available" state.') + self.assertEqual(lineC.state, "confirmed", 'Repair line #3 should be in "Waiting Availability" state.') - # I check the state is in "Under Repair". - self.assertEqual(repair.state, "under_repair", 'Repair order should be in "Under_repair" state.') + # Create quotation + # No partner warning -> working case -> already linked warning - # Repairing process for product is in Done state and I end Repair process by clicking on "End Repair" button. - repair.action_repair_end() + # Ensure SO doesn't exist + self.assertEqual(repair.sale_order_id.__len__(), 0) + repair.partner_id = None + with self.assertRaises(UserError) as err: + repair.action_create_sale_order() + self.assertIn("You need to define a customer", err.exception.args[0]) + repair.partner_id = self.res_partner_12.id + repair.action_create_sale_order() + # Ensure SO and SOL were created + self.assertNotEqual(repair.sale_order_id.__len__(), 0) + self.assertEqual(repair.sale_order_id.order_line.__len__(), 3) + with self.assertRaises(UserError) as err: + repair.action_create_sale_order() + self.assertIn("You cannot create a quotation for a repair order that is already linked", err.exception.args[0]) - # I define Invoice Method 'After Repair' option in this Repair order.so I create invoice by clicking on "Make Invoice" wizard. - make_invoice = self.env['repair.order.make_invoice'].create({ - 'group': True}) - # I click on "Create Invoice" button of this wizard to make invoice. - context = { - "active_model": 'repair_order', - "active_ids": [repair.id], - "active_id": repair.id - } - make_invoice.with_context(context).make_invoices() + # (*) -> cancel (action_repair_cancel) + # PRE + # state != done !-> UserError (cf. end of this test) + # POST + # moves_ids state == cancelled + # 'Lines" SOL product_uom_qty == 0 + # state == cancel - # I check that invoice is created for this Repair order. - self.assertEqual(len(repair.invoice_id), 1, "No invoice exists for this repair order") - self.assertEqual(len(repair.move_id.move_line_ids[0].consume_line_ids), 1, "Consume lines should be set") - - def test_01_repair_b4inv(self): - repair = self._create_simple_repair_order('b4repair') - # I confirm Repair order for Invoice Method 'Before Repair'. - repair.action_repair_confirm() - - # I click on "Create Invoice" button of this wizard to make invoice. - repair.action_repair_invoice_create() - - # I check that invoice is created for this Repair order. - self.assertEqual(len(repair.invoice_id), 1, "No invoice exists for this repair order") - - def test_02_repair_noneinv(self): - repair = self._create_simple_repair_order('none') - - # Add a new fee line - self._create_simple_fee(repair_id=repair.id, qty=1.0, price_unit=12.0) - - self.assertEqual(repair.amount_total, 12, "Amount_total should be 12") - # Add new operation line - self._create_simple_operation(repair_id=repair.id, qty=1.0, price_unit=14.0) - - self.assertEqual(repair.amount_total, 26, "Amount_total should be 26") - - # I confirm Repair order for Invoice Method 'No Invoice'. - repair.action_repair_confirm() - - # I start the repairing process by clicking on "Start Repair" button for Invoice Method 'No Invoice'. - repair.action_repair_start() - - # I check its state which is in "Under Repair". - self.assertEqual(repair.state, "under_repair", 'Repair order should be in "Under_repair" state.') - - # Repairing process for product is in Done state and I end this process by clicking on "End Repair" button. - repair.action_repair_end() - - self.assertEqual(repair.move_id.location_id.id, self.stock_warehouse.lot_stock_id.id, - 'Repaired product was taken in the wrong location') - self.assertEqual(repair.move_id.location_dest_id.id, self.stock_warehouse.lot_stock_id.id, - 'Repaired product went to the wrong location') - self.assertEqual(repair.operations.move_id.location_id.id, self.stock_warehouse.lot_stock_id.id, - 'Consumed product was taken in the wrong location') - self.assertEqual(repair.operations.move_id.location_dest_id.id, self.product_product_5.property_stock_production.id, - 'Consumed product went to the wrong location') - - # I define Invoice Method 'No Invoice' option in this repair order. - # So, I check that Invoice has not been created for this repair order. - self.assertNotEqual(len(repair.invoice_id), 1, "Invoice should not exist for this repair order") - - def test_repair_state(self): - repair = self._create_simple_repair_order('b4repair') - repair.action_repair_confirm() - repair.action_repair_invoice_create() - repair.invoice_id.unlink() - # Repair order state should be changed to 2binvoiced so that new invoice can be created - self.assertEqual(repair.state, '2binvoiced', 'Repair order should be in 2binvoiced state, if invoice is deleted.') - repair.action_repair_invoice_create() + self.assertNotEqual(repair.state, "done") repair.action_repair_cancel() - # Repair order and linked invoice both should be cancelled. - self.assertEqual(repair.state, 'cancel', 'Repair order should be in cancel state.') - self.assertEqual(repair.invoice_id.state, 'cancel', 'Invoice should be in cancel state.') + self.assertEqual(repair.state, "cancel") + self.assertTrue(all(m.state == "cancel" for m in repair.move_ids)) + self.assertTrue(all(float_is_zero(sol.product_uom_qty, 2) for sol in repair.sale_order_id.order_line)) + + # (*)/cancel -> draft (action_repair_cancel_draft) + # PRE + # state == cancel !-> action_repair_cancel() + # state != done !~> UserError (transitive..., don't care) + # POST + # move_ids.state == draft + # state == draft + repair.action_repair_cancel_draft() - # Linked invoice should be unlinked - self.assertEqual(len(repair.invoice_id), 0, "No invoice should be exists for this repair order") + self.assertEqual(repair.state, "draft") + self.assertTrue(all(m.state == "draft" for m in repair.move_ids)) - def test_03_repair_multicompany(self): - """ This test ensures that the correct taxes are selected when the user fills in the RO form """ + # draft -> confirmed + # Enforce product_id availability to skip warning + quant = self.create_quant(self.product_storable_serial, 1) + quant.action_apply_inventory() + repair.lot_id = quant.lot_id + repair.action_validate() + self.assertEqual(repair.state, "confirmed") - company01 = self.env.company - company02 = self.env['res.company'].create({ - 'name': 'SuperCompany', - 'country_id': self.env.ref('base.us').id, - }) + # confirmed -> under_repair (action_repair_start) + # Purely informative state + repair.action_repair_start() + self.assertEqual(repair.state, "under_repair") - self.env["account.tax.group"].create([{ - "name": "default", - "company_id": company01.id - }, { - "name": "default", - "company_id": company02.id - }]) - tax01 = self.env["account.tax"].create({ - "name": "C01 Tax", - "amount": "0.00", - "company_id": company01.id - }) - tax02 = self.env["account.tax"].create({ - "name": "C02 Tax", - "amount": "0.00", - "company_id": company02.id - }) + # under_repair -> done (action_repair_end -> action_repair_done) + # PRE + # state == under_repair !-> UserError + # lines' qty_done >= lines' product_uom_qty !-> Warning + # line tracked => line has lot_ids !-> ValidationError + # POST + # lines with qty_done == 0 are cancelled (related sol product_uom_qty is consequently set to 0) + # repair.product_id => repair.move_id + # move_ids.state == (done || cancel) + # state == done + # move_ids with qty_done (LOWER or HIGHER than) product_uom_qty MUST NOT be splitted + # Any line with qty_done < product_uom_qty => Warning + end_action = repair.action_repair_end() + self.assertEqual(end_action.get("res_model"), "repair.warn.uncomplete.move") + warn_uncomplete_wizard = Form( + self.env['repair.warn.uncomplete.move'] + .with_context(**end_action['context']) + ).save() + # LineB : no serial => ValidationError + with self.assertRaises(ValidationError) as err: + warn_uncomplete_wizard.action_validate() + self.assertIn("Serial number is required for operation lines", err.exception.args[0]) - super_product = self.env['product.template'].create({ - "name": "SuperProduct", - "taxes_id": [(4, tax01.id), (4, tax02.id)], - }) - super_variant = super_product.product_variant_id - self.assertEqual(super_variant.taxes_id, tax01 | tax02) + # LineB with lots + move_line = lineB.move_line_ids[0] + move_line.qty_done = move_line.reserved_uom_qty - ro_form = Form(self.env['repair.order']) - ro_form.product_id = super_variant - ro_form.partner_id = company01.partner_id - with ro_form.operations.new() as ro_line: - ro_line.product_id = super_variant - with ro_form.fees_lines.new() as fee_line: - fee_line.product_id = super_variant - repair_order = ro_form.save() + lineA.quantity_done = 2 # qty_done = product_uom_qty + lineC.quantity_done = 2 # qty_done > product_uom_qty (No warning) + lineD = self._create_simple_part_move(repair.id, 0.0) + repair.move_ids |= lineD # product_uom_qty = 0 : state is cancelled - # tax02 should not be present since it belongs to the second company. - self.assertEqual(repair_order.operations.tax_id, tax01) - self.assertEqual(repair_order.fees_lines.tax_id, tax01) + self.assertEqual(lineD.state, 'assigned') + num_of_lines = repair.move_ids.__len__() + self.assertEqual(repair.move_id.__len__(), 0) + repair.action_repair_end() + + self.assertEqual(repair.state, "done") + done_moves = repair.move_ids - lineD + #line a,b,c are 'done', line d is 'cancel' + self.assertTrue(all(m.state == 'done' for m in done_moves)) + self.assertEqual(lineD.state, 'cancel') + self.assertEqual(repair.move_id.__len__(), 1) + self.assertEqual(repair.move_ids.__len__(), num_of_lines) # No split + + # (*) -> cancel (action_repair_cancel) + # PRE + # state != done !-> UserError + with self.assertRaises(UserError) as err: + repair.action_repair_cancel() + self.assertEqual(err.exception.args[0], "You cannot cancel a Repair Order that's already been completed") + + def test_02_repair_sale_order_binding(self): + # Binding from SO to RO(s) + # On SO Confirm + # - Create linked RO per line (only if item with "create_repair" checked) + # Create Repair SOL + # - sol qty updated to 0 -> RO canceled (Reciprocal is true too) + # - sol qty back to >0 -> RO Confirmed (Reciprocal is not true) + # RO Parts SOL + # - SOL qty change is NOT propagated to RO + # - However, these changes FROM RO are propagated to SO + #---------------------------------------------------------------------------------- + # Binding from RO to SO + so_form = Form(self.env['sale.order']) + so_form.partner_id = self.res_partner_1 + with so_form.order_line.new() as line: + line.product_id = self.product_consu_order_repair + # line.product_template_id = self.product_consu_order_repair.product_tmpl_id + line.product_uom_qty = 2.0 + sale_order = so_form.save() + order_line = sale_order.order_line[0] + self.assertEqual(len(sale_order.repair_order_ids), 0) + sale_order.action_confirm() + # Quantity set on the "create repair" product doesn't affect the number of RO created + self.assertEqual(len(sale_order.repair_order_ids), 1) + repair_order = sale_order.repair_order_ids[0] + self.assertEqual(sale_order, repair_order.sale_order_id) + self.assertEqual(repair_order.state, 'confirmed') + order_line.product_uom_qty = 0 + self.assertEqual(repair_order.state, 'cancel') + order_line.product_uom_qty = 1 + self.assertEqual(repair_order.state, 'confirmed') + repair_order.action_repair_cancel() + self.assertTrue(float_is_zero(order_line.product_uom_qty, 2)) + order_line.product_uom_qty = 3 + self.assertEqual(repair_order.state, 'confirmed') + # Add RO line + ro_form = Form(repair_order) + with ro_form.move_ids.new() as ro_line_form: + ro_line_form.repair_line_type = 'add' + ro_line_form.product_id = self.product_product_11 + ro_line_form.product_uom_qty = 1 + ro_form.save() + ro_line_0 = repair_order.move_ids[0] + sol_part_0 = ro_line_0.sale_line_id + self.assertEqual(float_compare(sol_part_0.product_uom_qty, ro_line_0.product_uom_qty, 2), 0) + # chg qty in SO -> No effect on RO + sol_part_0.product_uom_qty = 5 + self.assertNotEqual(float_compare(sol_part_0.product_uom_qty, ro_line_0.product_uom_qty, 2), 0) + # chg qty in RO -> Update qty in SO + ro_line_0.product_uom_qty = 3 + self.assertEqual(float_compare(sol_part_0.product_uom_qty, ro_line_0.product_uom_qty, 2), 0) + # with/without warranty + self.assertFalse(float_is_zero(sol_part_0.price_unit, 2)) + repair_order.under_warranty = True + self.assertTrue(float_is_zero(sol_part_0.price_unit, 2)) + repair_order.under_warranty = False + self.assertFalse(float_is_zero(sol_part_0.price_unit, 2)) + + # stock_move transitions + # add -> remove -> add -> recycle -> add transitions + ro_line_0.repair_line_type = 'remove' + self.assertTrue(float_is_zero(sol_part_0.product_uom_qty, 2)) + ro_line_0.repair_line_type = 'add' + self.assertEqual(float_compare(sol_part_0.product_uom_qty, ro_line_0.product_uom_qty, 2), 0) + ro_line_0.repair_line_type = 'recycle' + self.assertTrue(float_is_zero(sol_part_0.product_uom_qty, 2)) + ro_line_0.repair_line_type = 'add' + self.assertEqual(float_compare(sol_part_0.product_uom_qty, ro_line_0.product_uom_qty, 2), 0) + # remove and recycle line : not added to SO. + sol_count = len(sale_order.order_line) + with ro_form.move_ids.new() as ro_line_form: + ro_line_form.repair_line_type = 'remove' + ro_line_form.product_id = self.product_product_12 + ro_line_form.product_uom_qty = 1 + with ro_form.move_ids.new() as ro_line_form: + ro_line_form.repair_line_type = 'recycle' + ro_line_form.product_id = self.product_product_13 + ro_line_form.product_uom_qty = 1 + ro_form.save() + ro_line_1 = repair_order.move_ids[1] + self.assertEqual(len(sale_order.order_line), sol_count) + # remove to add -> added to SO + ro_line_1.repair_line_type = 'add' + sol_part_1 = ro_line_1.sale_line_id + self.assertNotEqual(len(sale_order.order_line), sol_count) + self.assertEqual(float_compare(sol_part_1.product_uom_qty, ro_line_1.product_uom_qty, 2), 0) + # delete 'remove to add' line in RO -> SOL qty set to 0 + repair_order.move_ids = [(2, ro_line_1.id, 0)] + self.assertTrue(float_is_zero(sol_part_1.product_uom_qty, 2)) + + # repair_order.action_repair_end() + # -> order_line.qty_delivered == order_line.product_uom_qty + # -> "RO Lines"'s SOL.qty_delivered == move.quantity_done + for line in repair_order.move_ids: + line.quantity_done = line.product_uom_qty + repair_order.action_repair_start() + repair_order.action_repair_end() + self.assertEqual(float_compare(order_line.product_uom_qty, order_line.qty_delivered, 2), 0) + self.assertEqual(float_compare(sol_part_0.product_uom_qty, ro_line_0.quantity_done, 2), 0) + self.assertTrue(float_is_zero(sol_part_1.qty_delivered, 2)) def test_repair_return(self): """Tests functionality of creating a repair directly from a return picking, @@ -357,60 +443,33 @@ class TestRepair(AccountTestInvoicingCommon): def test_repair_compute_product_uom(self): repair = self.env['repair.order'].create({ 'product_id': self.product_product_3.id, - 'operations': [ + 'picking_type_id': self.stock_warehouse.repair_type_id.id, + 'move_ids': [ (0, 0, { - 'name': 'foo', + 'repair_line_type': 'add', 'product_id': self.product_product_11.id, - 'price_unit': 50.0, }) ], }) self.assertEqual(repair.product_uom, self.product_product_3.uom_id) - self.assertEqual(repair.operations[0].product_uom, self.product_product_11.uom_id) + self.assertEqual(repair.move_ids[0].product_uom, self.product_product_11.uom_id) def test_repair_compute_location(self): repair = self.env['repair.order'].create({ 'product_id': self.product_product_3.id, - 'operations': [ + 'picking_type_id': self.stock_warehouse.repair_type_id.id, + 'move_ids': [ (0, 0, { - 'name': 'foo', + 'repair_line_type': 'add', 'product_id': self.product_product_11.id, - 'price_unit': 50.0, }) ], }) self.assertEqual(repair.location_id, self.stock_warehouse.lot_stock_id) - self.assertEqual(repair.operations[0].location_id, self.stock_warehouse.lot_stock_id) + self.assertEqual(repair.move_ids[0].location_id, self.stock_warehouse.lot_stock_id) location_dest_id = self.env['stock.location'].search([ ('usage', '=', 'production'), ('company_id', '=', repair.company_id.id), ], limit=1) - self.assertEqual(repair.operations[0].location_dest_id, location_dest_id) + self.assertEqual(repair.move_ids[0].location_dest_id, location_dest_id) - def test_repair_order_send_to_self(self): - # when sender(logged in user) is also present in recipients of the mail composer, - # user should receive mail. - product_to_repair = self.product_product_5 - partner = self.res_partner_address_1 - repair_order = self.env['repair.order'].with_user(self.env.user).create({ - 'product_id': product_to_repair.id, - 'product_uom': product_to_repair.uom_id.id, - 'address_id': partner.id, - 'guarantee_limit': '2019-01-01', - 'location_id': self.stock_warehouse.lot_stock_id.id, - 'partner_id': self.env.user.partner_id.id - }) - email_ctx = repair_order.action_send_mail().get('context', {}) - # We need to prevent auto mail deletion, and so we copy the template and send the mail with - # added configuration in copied template. It will allow us to check whether mail is being - # sent to to author or not (in case author is present in 'Recipients' of composer). - mail_template = self.env['mail.template'].browse(email_ctx.get('default_template_id')).copy({'auto_delete': False}) - # send the mail with same user as customer - repair_order.with_context(**email_ctx).with_user(self.env.user).message_post_with_source( - mail_template, - subtype_xmlid='mail.mt_comment', - ) - mail_message = repair_order.message_ids[0] - self.assertEqual(mail_message.author_id, repair_order.partner_id, 'Repair: author should be same as customer') - self.assertEqual(mail_message.author_id, mail_message.partner_ids, 'Repair: author should be in composer recipients thanks to "partner_to" field set on template') - self.assertEqual(mail_message.partner_ids, mail_message.sudo().mail_ids.recipient_ids, 'Repair: author should receive mail due to presence in composer recipients') diff --git a/addons/repair/views/product_views.xml b/addons/repair/views/product_views.xml new file mode 100644 index 00000000000..4347cb81d67 --- /dev/null +++ b/addons/repair/views/product_views.xml @@ -0,0 +1,14 @@ + + + + + product.template.form.inherit.repair + product.template + + + + + + + + diff --git a/addons/repair/views/repair_views.xml b/addons/repair/views/repair_views.xml index 51a62505f15..be95f19d136 100644 --- a/addons/repair/views/repair_views.xml +++ b/addons/repair/views/repair_views.xml @@ -1,5 +1,6 @@ + @@ -11,14 +12,18 @@ - + + - - @@ -35,38 +40,35 @@ repair.order
+ + + +
- - - +
- - + + + + - + -
- - -
- - - - - - -
- - - - - - - - - - - - - - -
-
- - - - - - - - - - - - - + + + + + + + + @@ -245,13 +186,11 @@ repair.kanban repair.order - + - - + + - - @@ -293,13 +232,12 @@ - - + + + - - - @@ -342,6 +279,13 @@ + + Repair Orders + ir.actions.act_window + repair.order + form + + Repair Orders repair.order @@ -370,6 +314,17 @@ + + Repair Orders + ir.actions.act_window + repair.order + tree,kanban,form + + + [('picking_type_id', '=', active_id)] + {'default_picking_type_id': active_id} + + repair.tag.form repair.tags diff --git a/addons/repair/views/sale_order_views.xml b/addons/repair/views/sale_order_views.xml new file mode 100644 index 00000000000..c00ce7893b6 --- /dev/null +++ b/addons/repair/views/sale_order_views.xml @@ -0,0 +1,23 @@ + + + + + sale.order.form.inherit.repair + sale.order + + + + + + + + + diff --git a/addons/repair/views/stock_picking_views.xml b/addons/repair/views/stock_picking_views.xml index bb5f5419590..24ab034dbcb 100644 --- a/addons/repair/views/stock_picking_views.xml +++ b/addons/repair/views/stock_picking_views.xml @@ -5,12 +5,26 @@ stock.picking.type + + + + + + + {'invisible': [('code', '=', 'repair_operation')]} + + + + + {'invisible': [('code', '=', 'repair_operation')]} + + @@ -33,4 +47,111 @@ + + + stock.picking.type.kanban + stock.picking.type + + + + + + + + +
+
+
+ +
+ All +
+ +
+ Waiting +
+
+ Late +
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+
+
+
+
+ + + +
+ + + +
+ +
+
+
+
+
+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
diff --git a/addons/repair/views/stock_warehouse_views.xml b/addons/repair/views/stock_warehouse_views.xml new file mode 100644 index 00000000000..b8a1960a96d --- /dev/null +++ b/addons/repair/views/stock_warehouse_views.xml @@ -0,0 +1,13 @@ + + + + Stock Warehouse Inherit Repair + stock.warehouse + + + + + + + + diff --git a/addons/repair/wizard/__init__.py b/addons/repair/wizard/__init__.py index 42410e54822..260d804168b 100644 --- a/addons/repair/wizard/__init__.py +++ b/addons/repair/wizard/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import repair_make_invoice from . import stock_warn_insufficient_qty +from . import repair_warn_uncomplete_move diff --git a/addons/repair/wizard/repair_make_invoice.py b/addons/repair/wizard/repair_make_invoice.py deleted file mode 100644 index 0556257dac7..00000000000 --- a/addons/repair/wizard/repair_make_invoice.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models - - -class MakeInvoice(models.TransientModel): - _name = 'repair.order.make_invoice' - _description = 'Create Mass Invoice (repair)' - - group = fields.Boolean('Group by partner invoice address') - - def make_invoices(self): - if not self._context.get('active_ids'): - return {'type': 'ir.actions.act_window_close'} - new_invoice = {} - for wizard in self: - repairs = self.env['repair.order'].browse(self._context['active_ids']) - new_invoice = repairs._create_invoices(group=wizard.group) - - # We have to udpate the state of the given repairs, otherwise they remain 'to be invoiced'. - # Note that this will trigger another call to the method '_create_invoices', - # but that second call will not do anything, since the repairs are already invoiced. - repairs.action_repair_invoice_create() - return { - 'domain': [('id', 'in', list(new_invoice.values()))], - 'name': 'Invoices', - 'view_mode': 'tree,form', - 'res_model': 'account.move', - 'view_id': False, - 'views': [(self.env.ref('account.view_move_tree').id, 'tree'), (self.env.ref('account.view_move_form').id, 'form')], - 'context': "{'move_type':'out_invoice'}", - 'type': 'ir.actions.act_window' - } diff --git a/addons/repair/wizard/repair_make_invoice_views.xml b/addons/repair/wizard/repair_make_invoice_views.xml deleted file mode 100644 index 322db57a06e..00000000000 --- a/addons/repair/wizard/repair_make_invoice_views.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - Make Invoice - repair.order.make_invoice - -
- - - -
-
-
-
-
- - - Create invoices - repair.order.make_invoice - form - new - - list - - -
-
diff --git a/addons/repair/wizard/repair_warn_uncomplete_move.py b/addons/repair/wizard/repair_warn_uncomplete_move.py new file mode 100644 index 00000000000..93ded3875d2 --- /dev/null +++ b/addons/repair/wizard/repair_warn_uncomplete_move.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + +class RepairkWarnUncompleteMove(models.TransientModel): + _name = 'repair.warn.uncomplete.move' + _description = 'Warn Uncomplete Move(s)' + + repair_ids = fields.Many2many('repair.order', string='Repair Orders') + + def action_validate(self): + self.ensure_one() + return self.repair_ids.action_repair_done() diff --git a/addons/repair/wizard/repair_warn_uncomplete_move.xml b/addons/repair/wizard/repair_warn_uncomplete_move.xml new file mode 100644 index 00000000000..e71edcd090e --- /dev/null +++ b/addons/repair/wizard/repair_warn_uncomplete_move.xml @@ -0,0 +1,19 @@ + + + + repair.warn.uncomplete.move.view.form + repair.warn.uncomplete.move + +
+

+ For some of the parts, there is a difference between the initial demand and the actual quantity that was used.
+ Are you sure you want to confirm ? +

+ +
+
+
+
diff --git a/addons/repair/wizard/stock_warn_insufficient_qty.py b/addons/repair/wizard/stock_warn_insufficient_qty.py index 73f63fa0540..471dff216ec 100644 --- a/addons/repair/wizard/stock_warn_insufficient_qty.py +++ b/addons/repair/wizard/stock_warn_insufficient_qty.py @@ -3,6 +3,7 @@ from odoo import fields, models + class StockWarnInsufficientQtyRepair(models.TransientModel): _name = 'stock.warn.insufficient.qty.repair' _inherit = 'stock.warn.insufficient.qty' @@ -15,4 +16,4 @@ class StockWarnInsufficientQtyRepair(models.TransientModel): def action_done(self): self.ensure_one() - return self.repair_id.action_repair_confirm() + return self.repair_id._action_repair_confirm() diff --git a/addons/sale_stock/tests/test_anglo_saxon_valuation.py b/addons/sale_stock/tests/test_anglo_saxon_valuation.py index e18dd97f176..b42fd3c9b69 100644 --- a/addons/sale_stock/tests/test_anglo_saxon_valuation.py +++ b/addons/sale_stock/tests/test_anglo_saxon_valuation.py @@ -41,6 +41,7 @@ class TestAngloSaxonValuation(ValuationReconciliationTestCommon): 'tax_id': False, # no love taxes amls })], }) + sale_order.flush_recordset() sale_order.action_confirm() return sale_order