19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:47 +01:00
parent accf5918df
commit 6e65e8c877
688 changed files with 225434 additions and 199401 deletions

View file

@ -12,3 +12,4 @@ from . import stock_quant
from . import stock_rule
from . import stock_warehouse
from . import mrp_production
from . import mrp_unbuild

View file

@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.osv.expression import AND
from odoo.fields import Domain
class MrpBom(models.Model):
_inherit = 'mrp.bom'
@ -16,7 +16,7 @@ class MrpBom(models.Model):
def _bom_subcontract_find(self, product, picking_type=None, company_id=False, bom_type='subcontract', subcontractor=False):
domain = self._bom_find_domain(product, picking_type=picking_type, company_id=company_id, bom_type=bom_type)
if subcontractor:
domain = AND([domain, [('subcontractor_ids', 'parent_of', subcontractor.ids)]])
domain &= Domain('subcontractor_ids', 'parent_of', subcontractor.ids)
return self.search(domain, order='sequence, product_id, id', limit=1)
else:
return self.env['mrp.bom']

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from collections import defaultdict
from odoo import fields, models, _, api
from odoo.exceptions import UserError, ValidationError, AccessError
@ -15,19 +16,12 @@ class MrpProduction(models.Model):
'stock.move.line', string="Detail Component", readonly=False,
inverse='_inverse_move_line_raw_ids', compute='_compute_move_line_raw_ids'
)
subcontracting_has_been_recorded = fields.Boolean("Has been recorded?", copy=False)
subcontracting_has_been_recorded = fields.Boolean("Has been recorded?", copy=False) # TODO: remove in master
subcontractor_id = fields.Many2one('res.partner', string="Subcontractor", help="Used to restrict access to the portal user through Record Rules")
bom_product_ids = fields.Many2many('product.product', compute="_compute_bom_product_ids", help="List of Products used in the BoM, used to filter the list of products in the subcontracting portal view")
incoming_picking = fields.Many2one(related='move_finished_ids.move_dest_ids.picking_id')
@api.depends('name')
def name_get(self):
return [
(record.id, "%s (%s)" % (record.incoming_picking.name, record.name)) if record.bom_id.type == 'subcontract'
else (record.id, record.name) for record in self
]
@api.depends('move_raw_ids.move_line_ids')
def _compute_move_line_raw_ids(self):
for production in self:
@ -45,142 +39,69 @@ class MrpProduction(models.Model):
for move in production.move_raw_ids:
move.move_line_ids = line_by_product.pop(move.product_id, self.env['stock.move.line'])
for product_id, lines in line_by_product.items():
qty = sum(line.product_uom_id._compute_quantity(line.qty_done, product_id.uom_id) for line in lines)
qty = sum(line.product_uom_id._compute_quantity(line.quantity, product_id.uom_id) for line in lines)
move = production._get_move_raw_values(product_id, qty, product_id.uom_id)
move['additional'] = True
production.move_raw_ids = [(0, 0, move)]
production.move_raw_ids.filtered(lambda m: m.product_id == product_id)[:1].move_line_ids = lines
def write(self, vals):
if self.env.user.has_group('base.group_portal') and not self.env.su:
if self.env.user._is_portal() and not self.env.su:
unauthorized_fields = set(vals.keys()) - set(self._get_writeable_fields_portal_user())
if unauthorized_fields:
raise AccessError(_("You cannot write on fields %s in mrp.production.", ', '.join(unauthorized_fields)))
return super().write(vals)
if 'date_start' in vals and self.env.context.get('from_subcontract'):
date_start = fields.Datetime.to_datetime(vals['date_start'])
date_start_map = {
prod: date_start - timedelta(days=prod.bom_id.produce_delay)
if prod.bom_id else date_start
for prod in self
}
res = True
for production in self:
res &= super(MrpProduction, production).write({**vals, 'date_start': date_start_map[production]})
return res
old_lots = [mo.lot_producing_ids for mo in self]
if self.env.context.get('mrp_subcontracting') and 'product_qty' in vals:
for mo in self:
self.sudo().env['change.production.qty'].with_context(skip_activity=True, mrp_subcontracting=False, no_procurement=True).create([{
'mo_id': mo.id,
'product_qty': vals['product_qty'],
}]).change_prod_qty()
mo.sudo().action_assign()
res = super().write(vals)
if self.env.context.get('mrp_subcontracting') and ('product_qty' in vals or 'lot_producing_ids' in vals):
for mo, old_lot in zip(self, old_lots):
sbc_move = mo._get_subcontract_move()
if not sbc_move:
continue
if mo.product_tracking in ('lot', 'serial'):
sbc_move_lines = sbc_move.move_line_ids.filtered(lambda m: m.lot_id == old_lot)
sbc_move_line = sbc_move_lines[0]
sbc_move_line.quantity = mo.product_qty
sbc_move_line.lot_id = mo.lot_producing_ids
sbc_move_lines[1:].unlink()
else:
sbc_move.quantity = mo.product_qty
return res
def action_merge(self):
if any(production._get_subcontract_move() for production in self):
raise ValidationError(_("Subcontracted manufacturing orders cannot be merged."))
return super().action_merge()
def subcontracting_record_component(self):
self.ensure_one()
if not self._get_subcontract_move():
raise UserError(_("This MO isn't related to a subcontracted move"))
if float_is_zero(self.qty_producing, precision_rounding=self.product_uom_id.rounding):
return {'type': 'ir.actions.act_window_close'}
if self.move_raw_ids and not any(self.move_raw_ids.mapped('quantity_done')):
raise UserError(_("You must indicate a non-zero amount consumed for at least one of your components"))
consumption_issues = self._get_consumption_issues()
if consumption_issues:
return self._action_generate_consumption_wizard(consumption_issues)
self._update_finished_move()
self.subcontracting_has_been_recorded = True
quantity_issues = self._get_quantity_produced_issues()
if quantity_issues:
backorder = self.sudo()._split_productions()[1:]
# No qty to consume to avoid propagate additional move
# TODO avoid : stock move created in backorder with 0 as qty
backorder.move_raw_ids.filtered(lambda m: m.additional).product_uom_qty = 0.0
backorder.qty_producing = backorder.product_qty
backorder._set_qty_producing()
self.product_qty = self.qty_producing
action = self._get_subcontract_move().filtered(lambda m: m.state not in ('done', 'cancel'))._action_record_components()
action['res_id'] = backorder.id
return action
return {'type': 'ir.actions.act_window_close'}
def _pre_button_mark_done(self):
def pre_button_mark_done(self):
if self._get_subcontract_move():
return True
return super()._pre_button_mark_done()
return super(MrpProduction, self.with_context(skip_consumption=True)).pre_button_mark_done()
return super().pre_button_mark_done()
def _should_postpone_date_finished(self, date_planned_finished):
return super()._should_postpone_date_finished(date_planned_finished) and not self._get_subcontract_move()
def _update_finished_move(self):
""" After producing, set the move line on the subcontract picking. """
self.ensure_one()
subcontract_move_id = self._get_subcontract_move().filtered(lambda m: m.state not in ('done', 'cancel'))
if subcontract_move_id:
quantity = self.qty_producing
if self.lot_producing_id:
move_lines = subcontract_move_id.move_line_ids.filtered(lambda ml: ml.lot_id == self.lot_producing_id or not ml.lot_id)
else:
move_lines = subcontract_move_id.move_line_ids.filtered(lambda ml: not ml.lot_id)
# Update reservation and quantity done
for ml in move_lines:
rounding = ml.product_uom_id.rounding
if float_compare(quantity, 0, precision_rounding=rounding) <= 0:
break
quantity_to_process = min(quantity, ml.reserved_uom_qty - ml.qty_done)
quantity -= quantity_to_process
new_quantity_done = (ml.qty_done + quantity_to_process)
# on which lot of finished product
if float_compare(new_quantity_done, ml.reserved_uom_qty, precision_rounding=rounding) >= 0:
ml.write({
'qty_done': new_quantity_done,
'lot_id': self.lot_producing_id and self.lot_producing_id.id,
})
else:
new_qty_reserved = ml.reserved_uom_qty - new_quantity_done
default = {
'reserved_uom_qty': new_quantity_done,
'qty_done': new_quantity_done,
'lot_id': self.lot_producing_id and self.lot_producing_id.id,
}
ml.copy(default=default)
ml.with_context(bypass_reservation_update=True).write({
'reserved_uom_qty': new_qty_reserved,
'qty_done': 0
})
if float_compare(quantity, 0, precision_rounding=self.product_uom_id.rounding) > 0:
self.env['stock.move.line'].create({
'move_id': subcontract_move_id.id,
'picking_id': subcontract_move_id.picking_id.id,
'product_id': self.product_id.id,
'location_id': subcontract_move_id.location_id.id,
'location_dest_id': subcontract_move_id.location_dest_id.id,
'reserved_uom_qty': 0,
'product_uom_id': self.product_uom_id.id,
'qty_done': quantity,
'lot_id': self.lot_producing_id and self.lot_producing_id.id,
})
if not self._get_quantity_to_backorder():
ml_reserved = subcontract_move_id.move_line_ids.filtered(lambda ml:
float_is_zero(ml.qty_done, precision_rounding=ml.product_uom_id.rounding) and
not float_is_zero(ml.reserved_uom_qty, precision_rounding=ml.product_uom_id.rounding))
ml_reserved.unlink()
for ml in subcontract_move_id.move_line_ids:
ml.reserved_uom_qty = ml.qty_done
subcontract_move_id._recompute_state()
def _subcontracting_filter_to_done(self):
""" Filter subcontracting production where composant is already recorded and should be consider to be validate """
def filter_in(mo):
if mo.state in ('done', 'cancel'):
return False
if not mo.subcontracting_has_been_recorded:
return False
return True
return self.filtered(filter_in)
def _has_been_recorded(self):
self.ensure_one()
if self.state in ('cancel', 'done'):
return True
return self.subcontracting_has_been_recorded
def _has_tracked_component(self):
return any(m.has_tracking != 'none' for m in self.move_raw_ids)
def _should_postpone_date_finished(self, date_finished):
return super()._should_postpone_date_finished(date_finished) and not self._get_subcontract_move()
def _has_workorders(self):
if self.subcontractor_id:
@ -192,13 +113,23 @@ class MrpProduction(models.Model):
return self.move_finished_ids.move_dest_ids.filtered(lambda m: m.is_subcontract)
def _get_writeable_fields_portal_user(self):
return ['move_line_raw_ids', 'lot_producing_id', 'subcontracting_has_been_recorded', 'qty_producing', 'product_qty']
return ['move_line_raw_ids', 'lot_producing_ids', 'qty_producing', 'product_qty']
def _subcontract_sanity_check(self):
for production in self:
if production.product_tracking != 'none' and not self.lot_producing_id:
raise UserError(_('You must enter a serial number for %s') % production.product_id.name)
for sml in production.move_raw_ids.move_line_ids:
if sml.tracking != 'none' and not sml.lot_id:
raise UserError(_('You must enter a serial number for each line of %s') % sml.product_id.display_name)
return True
def action_split_subcontracting(self):
self.ensure_one()
if not self.lot_producing_ids:
raise UserError(_("Please set a lot/serial for the currently opened subcontracting MO first."))
move = self._get_subcontract_move()
if not move:
return False
if move.state == 'done':
raise UserError(_("The subcontracted goods have already been received."))
if all(l.lot_id for l in move.move_line_ids):
move.move_line_ids.create({
'product_id': move.product_id.id,
'move_id': move.id,
'quantity': 1,
'picking_id': move.picking_id.id,
'lot_id': False,
})
return move.action_show_subcontract_details(lot_id=False)

View file

@ -0,0 +1,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, _
from odoo.exceptions import UserError
class MrpProduction(models.Model):
_inherit = 'mrp.production'
def button_unbuild(self):
if self.subcontractor_id:
raise UserError(_(
"You can't unbuild a subcontracted Manufacturing Order.",
))
return super().button_unbuild()

View file

@ -4,7 +4,7 @@
from odoo import api, fields, models
class SupplierInfo(models.Model):
class ProductSupplierinfo(models.Model):
_inherit = 'product.supplierinfo'
is_subcontractor = fields.Boolean('Subcontracted', compute='_compute_is_subcontractor', help="Choose a vendor of type subcontractor if you want to subcontract the product")

View file

@ -20,19 +20,16 @@ class ResCompany(models.Model):
self._create_subcontracting_location()
def _create_subcontracting_location(self):
parent_location = self.env.ref('stock.stock_location_locations', raise_if_not_found=False)
for company in self:
subcontracting_location = self.env['stock.location'].create({
'name': _('Subcontracting Location'),
'name': _('Subcontracting'),
'usage': 'internal',
'location_id': parent_location.id,
'company_id': company.id,
'is_subcontracting_location': True,
})
self.env['ir.property']._set_default(
"property_stock_subcontractor",
self.env['ir.default'].set(
"res.partner",
subcontracting_location,
company,
"property_stock_subcontractor",
subcontracting_location.id,
company_id=company.id,
)
company.subcontracting_location_id = subcontracting_location

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo import fields, models
class ResPartner(models.Model):
@ -18,48 +18,46 @@ class ResPartner(models.Model):
picking_ids = fields.Many2many('stock.picking', compute='_compute_picking_ids', string="Stock Pickings for which the Partner is the subcontractor")
def _compute_bom_ids(self):
results = self.env['mrp.bom'].read_group([('subcontractor_ids.commercial_partner_id', 'in', self.commercial_partner_id.ids)], ['ids:array_agg(id)', 'subcontractor_ids'], ['subcontractor_ids'])
results = self.env['mrp.bom']._read_group([('subcontractor_ids.commercial_partner_id', 'in', self.commercial_partner_id.ids)], ['subcontractor_ids'], ['id:array_agg'])
for partner in self:
bom_ids = []
for res in results:
if partner.id == res['subcontractor_ids'][0] or res['subcontractor_ids'][0] in partner.child_ids.ids:
bom_ids += res['ids']
for subcontractor, ids in results:
if partner.id == subcontractor.id or subcontractor.id in partner.child_ids.ids:
bom_ids += ids
partner.bom_ids = bom_ids
def _compute_production_ids(self):
results = self.env['mrp.production'].read_group([('subcontractor_id.commercial_partner_id', 'in', self.commercial_partner_id.ids)], ['ids:array_agg(id)'], ['subcontractor_id'])
results = self.env['mrp.production']._read_group([('subcontractor_id.commercial_partner_id', 'in', self.commercial_partner_id.ids)], ['subcontractor_id'], ['id:array_agg'])
for partner in self:
production_ids = []
for res in results:
if partner.id == res['subcontractor_id'][0] or res['subcontractor_id'][0] in partner.child_ids.ids:
production_ids += res['ids']
for subcontractor, ids in results:
if partner.id == subcontractor.id or subcontractor.id in partner.child_ids.ids:
production_ids += ids
partner.production_ids = production_ids
def _compute_picking_ids(self):
results = self.env['stock.picking'].read_group([('partner_id.commercial_partner_id', 'in', self.commercial_partner_id.ids)], ['ids:array_agg(id)'], ['partner_id'])
results = self.env['stock.picking']._read_group([('partner_id.commercial_partner_id', 'in', self.commercial_partner_id.ids)], ['partner_id'], ['id:array_agg'])
for partner in self:
picking_ids = []
for res in results:
if partner.id == res['partner_id'][0] or res['partner_id'][0] in partner.child_ids.ids:
picking_ids += res['ids']
for partner_rg, ids in results:
if partner_rg.id == partner.id or partner_rg.id in partner.child_ids.ids:
picking_ids += ids
partner.picking_ids = picking_ids
def _search_is_subcontractor(self, operator, value):
assert operator in ('=', '!=', '<>') and value in (True, False), 'Operation not supported'
if operator != 'in':
return NotImplemented
subcontractor_ids = self.env['mrp.bom'].search(
[('type', '=', 'subcontract')]).subcontractor_ids.ids
if (operator == '=' and value is True) or (operator in ('<>', '!=') and value is False):
search_operator = 'in'
else:
search_operator = 'not in'
return [('id', search_operator, subcontractor_ids)]
return [('id', 'in', subcontractor_ids)]
@api.depends_context('uid')
def _compute_is_subcontractor(self):
""" Check if the user is a subcontractor before giving sudo access
"""
""" Determine whether the partner is a subcontractor (for giving sudo access) """
for partner in self:
partner.is_subcontractor = (partner.user_has_groups('base.group_portal') and partner.env['mrp.bom'].search_count([
('type', '=', 'subcontract'),
('subcontractor_ids', 'in', (partner.env.user.partner_id | partner.env.user.partner_id.commercial_partner_id).ids),
]))
partner.is_subcontractor = (
any(user._is_portal() for user in partner.user_ids)
and partner.env['mrp.bom'].search_count([
('type', '=', 'subcontract'),
('subcontractor_ids', 'in', (partner | partner.commercial_partner_id).ids),
], limit=1)
)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
@ -8,37 +7,16 @@ from odoo.exceptions import ValidationError
class StockLocation(models.Model):
_inherit = 'stock.location'
is_subcontracting_location = fields.Boolean(
"Is a Subcontracting Location?",
help="Check this box to create a new dedicated subcontracting location for this company. Note that standard subcontracting routes will be adapted so as to take these into account automatically."
)
subcontractor_ids = fields.One2many('res.partner', 'property_stock_subcontractor')
@api.constrains('is_subcontracting_location', 'usage', 'location_id')
@api.constrains('usage', 'location_id')
def _check_subcontracting_location(self):
for location in self:
if location == location.company_id.subcontracting_location_id:
raise ValidationError(_("You cannot alter the company's subcontracting location"))
if location.is_subcontracting_location and location.usage != 'internal':
if location.is_subcontract() and location.usage != 'internal':
raise ValidationError(_("In order to manage stock accurately, subcontracting locations must be type Internal, linked to the appropriate company."))
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
new_subcontracting_locations = res.filtered(lambda l: l.is_subcontracting_location)
new_subcontracting_locations._activate_subcontracting_location_rules()
return res
def write(self, values):
res = super().write(values)
if 'is_subcontracting_location' in values:
if values['is_subcontracting_location']:
self._activate_subcontracting_location_rules()
else:
self._archive_subcontracting_location_rules()
return res
def _check_access_putaway(self):
""" Use sudo mode for subcontractor """
if self.env.user.partner_id.is_subcontractor:
@ -46,58 +24,6 @@ class StockLocation(models.Model):
else:
return super()._check_access_putaway()
def _activate_subcontracting_location_rules(self):
""" Create or unarchive rules for the 'custom' subcontracting location(s).
The subcontracting location defined on the company is considered as the 'reference' one.
All rules defined on this 'reference' location will be replicated on 'custom' subcontracting locations.
"""
locations_per_company = {}
for location in self:
if location.is_subcontracting_location and location != location.company_id.subcontracting_location_id:
locations_per_company.setdefault(location.company_id, []).extend(location)
new_rules_vals = []
rules_to_unarchive = self.env['stock.rule']
for company, locations in locations_per_company.items():
reference_location_id = company.subcontracting_location_id
if reference_location_id:
reference_rules_from = self.env['stock.rule'].search([('location_src_id', '=', reference_location_id.id)])
reference_rules_to = self.env['stock.rule'].search([('location_dest_id', '=', reference_location_id.id)])
for location in locations:
existing_rules = {
(rule.route_id, rule.picking_type_id, rule.action, rule.location_src_id): rule
for rule in self.env['stock.rule'].with_context(active_test=False).search([('location_src_id', '=', location.id)])
}
for rule in reference_rules_from:
if (rule.route_id, rule.picking_type_id, rule.action, location) not in existing_rules:
new_rules_vals.append(rule.copy_data({
'location_src_id': location.id,
'name': rule.name.replace(reference_location_id.name, location.name)
})[0])
else:
existing_rule = existing_rules[(rule.route_id, rule.picking_type_id, rule.action, location)]
if not existing_rule.active:
rules_to_unarchive += existing_rule
existing_rules = {
(rule.route_id, rule.picking_type_id, rule.action, rule.location_dest_id): rule
for rule in self.env['stock.rule'].with_context(active_test=False).search([('location_dest_id', '=', location.id)])
}
for rule in reference_rules_to:
if (rule.route_id, rule.picking_type_id, rule.action, location) not in existing_rules:
new_rules_vals.append(rule.copy_data({
'location_dest_id': location.id,
'name': rule.name.replace(reference_location_id.name, location.name)
})[0])
else:
existing_rule = existing_rules[(rule.route_id, rule.picking_type_id, rule.action, location)]
if not existing_rule.active:
rules_to_unarchive += existing_rule
self.env['stock.rule'].create(new_rules_vals)
rules_to_unarchive.action_unarchive()
def _archive_subcontracting_location_rules(self):
""" Archive subcontracting rules for locations that are no longer 'custom' subcontracting locations."""
reference_location_ids = self.company_id.subcontracting_location_id
reference_rules = self.env['stock.rule'].search(['|', ('location_src_id', 'in', reference_location_ids.ids), ('location_dest_id', 'in', reference_location_ids.ids)])
reference_routes = reference_rules.route_id
rules_to_archive = self.env['stock.rule'].search(['&', ('route_id', 'in', reference_routes.ids), '|', ('location_src_id', 'in', self.ids), ('location_dest_id', 'in', self.ids)])
rules_to_archive.action_archive()
def is_subcontract(self):
subcontracting_location = self.company_id.subcontracting_location_id
return subcontracting_location and self._child_of(subcontracting_location)

View file

@ -1,10 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import fields, models, api, _
from odoo.exceptions import UserError, AccessError
from odoo.exceptions import AccessError
from odoo.tools.float_utils import float_compare, float_is_zero, float_round
from odoo.tools.misc import OrderedSet
@ -17,132 +16,57 @@ class StockMove(models.Model):
compute='_compute_show_subcontracting_details_visible'
)
def _compute_display_assign_serial(self):
super(StockMove, self)._compute_display_assign_serial()
for move in self:
if not move.is_subcontract:
continue
productions = move._get_subcontract_production()
if not productions or move.has_tracking != 'serial':
continue
if productions._has_tracked_component() or productions[:1].consumption != 'strict':
move.display_assign_serial = False
def _compute_show_subcontracting_details_visible(self):
""" Compute if the action button in order to see moves raw is visible """
self.show_subcontracting_details_visible = False
for move in self:
if not move.is_subcontract:
continue
if float_is_zero(move.quantity_done, precision_rounding=move.product_uom.rounding):
if not move.move_line_ids or move.product_uom.is_zero(move.quantity):
continue
productions = move._get_subcontract_production()
if not productions or (productions[:1].consumption == 'strict' and not productions[:1]._has_tracked_component()):
productions = move._get_subcontract_production().filtered(lambda m: m.state != 'cancel')
if not productions:
continue
move.show_subcontracting_details_visible = True
def _compute_show_details_visible(self):
""" If the move is subcontract and the components are tracked. Then the
show details button is visible.
"""
res = super(StockMove, self)._compute_show_details_visible()
@api.depends('is_subcontract')
def _compute_show_info(self):
super()._compute_show_info()
subcontract_moves = self.filtered(lambda m: m.is_subcontract and m.show_lots_text)
subcontract_moves.show_lots_text = False
subcontract_moves.show_lots_m2o = True
@api.depends('is_subcontract', 'has_tracking')
def _compute_is_quantity_done_editable(self):
done_moves = self.env['stock.move']
for move in self:
if not move.is_subcontract:
if move.is_subcontract:
move.is_quantity_done_editable = move.has_tracking == 'none'
done_moves |= move
return super(StockMove, self - done_moves)._compute_is_quantity_done_editable()
def copy_data(self, default=None):
default = dict(default or {})
vals_list = super().copy_data(default=default)
for move, vals in zip(self, vals_list):
if 'location_id' in default or not move.is_subcontract:
continue
if self.env.user.has_group('base.group_portal'):
move.show_details_visible = any(not p._has_been_recorded() for p in move._get_subcontract_production())
continue
productions = move._get_subcontract_production()
if not productions._has_tracked_component() and productions[:1].consumption == 'strict':
continue
move.show_details_visible = True
return res
vals['location_id'] = move.picking_id.location_id.id
return vals_list
def _set_quantity_done(self, qty):
to_set_moves = self
for move in self:
if move.is_subcontract and move._subcontracting_possible_record():
# If 'done' quantity is changed through the move, record components as if done through the wizard.
move._auto_record_components(qty)
to_set_moves -= move
if to_set_moves:
super(StockMove, to_set_moves)._set_quantity_done(qty)
def _quantity_done_set(self):
to_set_moves = self
for move in self:
if move.is_subcontract and move._subcontracting_possible_record():
delta_qty = move.quantity_done - move._quantity_done_sml()
if float_compare(delta_qty, 0, precision_rounding=move.product_uom.rounding) > 0:
move._auto_record_components(delta_qty)
to_set_moves -= move
elif float_compare(delta_qty, 0, precision_rounding=move.product_uom.rounding) < 0 and not move.picking_id.immediate_transfer:
move.with_context(transfer_qty=True)._reduce_subcontract_order_qty(abs(delta_qty))
if to_set_moves:
super(StockMove, to_set_moves)._quantity_done_set()
def _auto_record_components(self, qty):
self.ensure_one()
subcontracted_productions = self._get_subcontract_production()
production = subcontracted_productions.filtered(lambda p: not p._has_been_recorded())[-1:]
if not production:
# If new quantity is over the already recorded quantity and we have no open production, then create a new one for the missing quantity.
production = subcontracted_productions[-1:]
production = production.sudo().with_context(allow_more=True)._split_productions({production: [production.qty_producing, qty]})[-1:]
qty = self.product_uom._compute_quantity(qty, production.product_uom_id)
if production.product_tracking == 'serial':
qty = float_round(qty, precision_digits=0, rounding_method='UP') # Makes no sense to have partial quantities for serial number
if float_compare(qty, production.product_qty, precision_rounding=production.product_uom_id.rounding) < 0:
remaining_qty = production.product_qty - qty
productions = production.sudo()._split_productions({production: ([1] * int(qty)) + [remaining_qty]})[:-1]
else:
productions = production.sudo().with_context(allow_more=True)._split_productions({production: ([1] * int(qty))})
for production in productions:
production.qty_producing = 1
if not production.lot_producing_id:
production.action_generate_serial()
production.with_context(cancel_backorder=False).subcontracting_record_component()
else:
production.qty_producing = qty
if float_compare(production.qty_producing, production.product_qty, precision_rounding=production.product_uom_id.rounding) > 0:
self.env['change.production.qty'].with_context(skip_activity=True).create({
'mo_id': production.id,
'product_qty': qty
}).change_prod_qty()
if production.product_tracking == 'lot' and not production.lot_producing_id:
production.action_generate_serial()
production._set_qty_producing()
production.with_context(cancel_backorder=False).subcontracting_record_component()
def copy(self, default=None):
self.ensure_one()
if not self.is_subcontract or 'location_id' in default:
return super(StockMove, self).copy(default=default)
if not default:
default = {}
default['location_id'] = self.picking_id.location_id.id
return super(StockMove, self).copy(default=default)
def write(self, values):
def write(self, vals):
""" If the initial demand is updated then also update the linked
subcontract order to the new quantity.
"""
self._check_access_if_subcontractor(values)
if 'product_uom_qty' in values and self.env.context.get('cancel_backorder') is not False and not self._context.get('extra_move_mode'):
self.filtered(
lambda m: m.is_subcontract and m.state not in ['draft', 'cancel', 'done']
and float_compare(m.product_uom_qty, values['product_uom_qty'], precision_rounding=m.product_uom.rounding) != 0
)._update_subcontract_order_qty(values['product_uom_qty'])
res = super().write(values)
if 'date' in values:
self._check_access_if_subcontractor(vals)
res = super().write(vals)
if 'date' in vals:
for move in self:
if move.state in ('done', 'cancel') or not move.is_subcontract:
continue
move.move_orig_ids.production_id.filtered(lambda p: p.state not in ('done', 'cancel')).write({
'date_planned_finished': move.date,
'date_planned_start': move.date,
move.move_orig_ids.production_id.with_context(from_subcontract=True).filtered(lambda p: p.state not in ('done', 'cancel')).write({
'date_start': move.date,
'date_finished': move.date,
})
return res
@ -157,44 +81,48 @@ class StockMove(models.Model):
subcontracted product. Otherwise use standard behavior.
"""
self.ensure_one()
if self.state != 'done' and (self._subcontrating_should_be_record() or self._subcontrating_can_be_record()):
return self._action_record_components()
action = super(StockMove, self).action_show_details()
if self.is_subcontract and all(p._has_been_recorded() for p in self._get_subcontract_production()):
action['views'] = [(self.env.ref('stock.view_stock_move_operations').id, 'form')]
action['context'].update({
'show_lots_m2o': self.has_tracking != 'none',
'show_lots_text': False,
})
elif self.env.user.has_group('base.group_portal'):
if self.picking_type_id.show_reserved:
if self.is_subcontract:
action = super(StockMove, self.with_context(force_lot_m2o=True)).action_show_details()
if self.env.user._is_portal():
action['views'] = [(self.env.ref('mrp_subcontracting.mrp_subcontracting_view_stock_move_operations').id, 'form')]
else:
action['views'] = [(self.env.ref('mrp_subcontracting.mrp_subcontracting_view_stock_move_nosuggest_operations').id, 'form')]
return action
return action
return super().action_show_details()
def action_show_subcontract_details(self):
def action_show_subcontract_details(self, lot_id=None):
""" Display moves raw for subcontracted product self. """
moves = self._get_subcontract_production().move_raw_ids.filtered(lambda m: m.state != 'cancel')
tree_view = self.env.ref('mrp_subcontracting.mrp_subcontracting_move_tree_view')
form_view = self.env.ref('mrp_subcontracting.mrp_subcontracting_move_form_view')
ctx = dict(self._context, search_default_by_product=True)
if self.env.user.has_group('base.group_portal'):
form_view = self.env.ref('mrp_subcontracting.mrp_subcontracting_portal_move_form_view')
productions = self._get_subcontract_production().filtered(lambda m: m.state != 'cancel')
if lot_id is not None:
if lot_id:
productions = productions.filtered(lambda p: p.lot_producing_ids and p.lot_producing_ids[0] == self.env['stock.lot'].browse(lot_id))
else:
productions = productions.filtered(lambda p: not p.lot_producing_ids)
ctx = {"mrp_subcontracting": True}
if self.env.user._is_portal():
form_view_id = self.env.ref('mrp_subcontracting.mrp_production_subcontracting_portal_form_view')
ctx.update(no_breadcrumbs=False)
return {
'name': _('Raw Materials for %s') % (self.product_id.display_name),
else:
form_view_id = self.env.ref('mrp_subcontracting.mrp_production_subcontracting_form_view')
action = {
'type': 'ir.actions.act_window',
'res_model': 'stock.move',
'views': [(tree_view.id, 'list'), (form_view.id, 'form')],
'res_model': 'mrp.production',
'target': 'current',
'domain': [('id', 'in', moves.ids)],
'context': ctx
}
def _set_quantities_to_reservation(self):
move_untouchable = self.filtered(lambda m: m.is_subcontract and m._get_subcontract_production()._has_tracked_component())
return super(StockMove, self - move_untouchable)._set_quantities_to_reservation()
if len(productions) > 1:
action.update({
'name': _('Subcontracting MOs'),
'views': [
(self.env.ref('mrp_subcontracting.mrp_production_subcontracting_tree_view').id, 'list'),
(form_view_id.id, 'form'),
],
'domain': [('id', 'in', productions.ids)],
})
else:
action.update({
'views': [(form_view_id.id, 'form')],
'res_id': productions.id,
})
return action
def _action_cancel(self):
productions_to_cancel_ids = OrderedSet()
@ -212,7 +140,7 @@ class StockMove(models.Model):
return super()._action_cancel()
def _action_confirm(self, merge=True, merge_into=False):
def _action_confirm(self, merge=True, merge_into=False, create_proc=True):
subcontract_details_per_picking = defaultdict(list)
for move in self:
if move.location_id.usage != 'supplier' or move.location_dest_id.usage == 'supplier':
@ -222,18 +150,16 @@ class StockMove(models.Model):
bom = move._get_subcontract_bom()
if not bom:
continue
if float_is_zero(move.product_qty, precision_rounding=move.product_uom.rounding) and\
move.picking_id.immediate_transfer is True:
raise UserError(_("To subcontract, use a planned transfer."))
company = move.company_id
subcontracting_location = \
move.picking_id.partner_id.with_company(company).property_stock_subcontractor \
or company.subcontracting_location_id
move.write({
'is_subcontract': True,
'location_id': move.picking_id.partner_id.with_company(move.company_id).property_stock_subcontractor.id
'location_id': subcontracting_location.id
})
if float_compare(move.product_qty, 0, precision_rounding=move.product_uom.rounding) <= 0:
# If a subcontracted amount is decreased, don't create a MO that would be for a negative value.
# We don't care if the MO decreases even when done since everything is handled through picking
continue
res = super()._action_confirm(merge=merge, merge_into=merge_into)
move._action_assign() # Re-reserve as the write on location_id will break the link
res = super()._action_confirm(merge=merge, merge_into=merge_into, create_proc=create_proc)
for move in res:
if move.is_subcontract:
subcontract_details_per_picking[move.picking_id].append((move, move._get_subcontract_bom()))
@ -244,26 +170,6 @@ class StockMove(models.Model):
self.env['stock.picking'].concat(*list(subcontract_details_per_picking.keys())).action_assign()
return res
def _action_record_components(self):
self.ensure_one()
production = self._get_subcontract_production()[-1:]
view = self.env.ref('mrp_subcontracting.mrp_production_subcontracting_form_view')
if self.env.user.has_group('base.group_portal'):
view = self.env.ref('mrp_subcontracting.mrp_production_subcontracting_portal_form_view')
context = dict(self._context)
context.pop('skip_consumption', False)
return {
'name': _('Subcontract'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'mrp.production',
'views': [(view.id, 'form')],
'view_id': view.id,
'target': 'new',
'res_id': production.id,
'context': context,
}
def _get_subcontract_bom(self):
self.ensure_one()
bom = self.env['mrp.bom'].sudo()._bom_subcontract_find(
@ -275,36 +181,19 @@ class StockMove(models.Model):
)
return bom
def _subcontrating_should_be_record(self):
return self._get_subcontract_production().filtered(lambda p: not p._has_been_recorded() and p._has_tracked_component())
def _subcontrating_can_be_record(self):
return self._get_subcontract_production().filtered(lambda p: not p._has_been_recorded() and p.consumption != 'strict')
def _subcontracting_possible_record(self):
return self._get_subcontract_production().filtered(lambda p: p._has_tracked_component() or p.consumption != 'strict')
def _get_subcontract_production(self):
return self.filtered(lambda m: m.is_subcontract).move_orig_ids.production_id
# TODO: To be deleted, use self._get_subcontract_production()._has_tracked_component() instead
def _has_tracked_subcontract_components(self):
return any(m.has_tracking != 'none' for m in self._get_subcontract_production().move_raw_ids)
def _prepare_extra_move_vals(self, qty):
vals = super(StockMove, self)._prepare_extra_move_vals(qty)
vals['location_id'] = self.location_id.id
return vals
def _prepare_move_split_vals(self, qty):
vals = super(StockMove, self)._prepare_move_split_vals(qty)
vals['location_id'] = self.location_id.id
return vals
def _should_bypass_set_qty_producing(self):
if (self.production_id | self.raw_material_production_id)._get_subcontract_move():
return False
return super()._should_bypass_set_qty_producing()
def _prepare_procurement_values(self):
res = super()._prepare_procurement_values()
if self.raw_material_production_id.subcontractor_id:
res['warehouse_id'] = self.picking_type_id.warehouse_id
return res
def _should_bypass_reservation(self, forced_location=False):
""" If the move is subcontracted then ignore the reservation. """
@ -313,40 +202,11 @@ class StockMove(models.Model):
return True
return should_bypass_reservation
def _update_subcontract_order_qty(self, new_quantity):
for move in self:
quantity_to_remove = move.product_uom_qty - new_quantity
move._reduce_subcontract_order_qty(quantity_to_remove)
def _reduce_subcontract_order_qty(self, quantity_to_remove):
self.ensure_one()
productions = self.move_orig_ids.production_id.filtered(lambda p: p.state not in ('done', 'cancel'))[::-1]
wip_production = productions[0] if self._context.get('transfer_qty') and len(productions) > 1 else self.env['mrp.production']
# Transfer removed qty to WIP production
if wip_production:
self.env['change.production.qty'].with_context(skip_activity=True).create({
'mo_id': wip_production.id,
'product_qty': wip_production.product_qty + quantity_to_remove
}).change_prod_qty()
# Cancel productions until reach new_quantity
for production in (productions - wip_production):
if float_compare(quantity_to_remove, production.product_qty, precision_rounding=production.product_uom_id.rounding) >= 0:
quantity_to_remove -= production.product_qty
production.with_context(skip_activity=True).action_cancel()
else:
if float_is_zero(quantity_to_remove, precision_rounding=production.product_uom_id.rounding):
# No need to do change_prod_qty for no change at all.
break
self.env['change.production.qty'].with_context(skip_activity=True).create({
'mo_id': production.id,
'product_qty': production.product_qty - quantity_to_remove
}).change_prod_qty()
break
def _get_available_move_lines(self, assigned_moves_ids, partially_available_moves_ids):
return super(StockMove, self.filtered(lambda m: not m.is_subcontract))._get_available_move_lines(assigned_moves_ids, partially_available_moves_ids)
def _check_access_if_subcontractor(self, vals):
if self.env.user.has_group('base.group_portal') and not self.env.su:
if self.env.user._is_portal() and not self.env.su:
if vals.get('state') == 'done':
raise AccessError(_("Portal users cannot create a stock move with a state 'Done' or change the current state to 'Done'."))
@ -358,3 +218,83 @@ class StockMove(models.Model):
and self.origin_returned_move_id.is_subcontract
and self.location_dest_id.id == subcontracting_location.id
)
def _can_create_lot(self):
return super()._can_create_lot() or self.env.context.get('force_lot_m2o')
def _sync_subcontracting_productions(self):
"""
Enforce the relationship between subcontracting receipt moves and their respective subcontracting productions.
* For untracked moves:
* There will always be only 1 production.
* Updating the move quantity will update the production quantity.
* For tracked moves:
* There will be 1 production for every lot on this move.
* This method will enforce the synchronisation between the total quantity per lot on the move and the linked productions.
* The split mechanism for productions will be used to create new subcontracting MOs.
* We take care to always keep at least 1 subcontracting production linked to the subcontracting receipt.
This ensures there will always be a production available for splitting.
"""
for move in self:
productions = move._get_subcontract_production()
if not productions:
continue
if move.has_tracking == 'none':
if productions.product_uom_id.compare(productions.product_qty, move.quantity) != 0:
self.sudo().env['change.production.qty'].with_context(skip_activity=True).create([{
'mo_id': productions.id,
'product_qty': move.quantity or move.product_uom_qty,
}]).change_prod_qty()
productions.action_assign()
else:
qty_by_lot = dict(move.move_line_ids._read_group([('move_id', '=', move.id)], ['lot_id'], ['quantity_product_uom:sum']))
mos_to_assign = self.env['mrp.production']
# 1. Ensure quantities of linked MOs still match the quantities on the move
mos_to_create = {} # lot -> qty
for lot_id, ml_qty in qty_by_lot.items():
lot_mo = productions.filtered(lambda p: (p.lot_producing_ids and p.lot_producing_ids[0] == lot_id) or (not lot_id and not p.lot_producing_ids))
if not lot_mo:
mos_to_create[lot_id] = ml_qty
elif lot_mo.product_uom_id.compare(lot_mo.product_qty, ml_qty) != 0:
self.sudo().env['change.production.qty'].with_context(skip_activity=True).create([{
'mo_id': lot_mo.id,
'product_qty': ml_qty
}]).change_prod_qty()
mos_to_assign |= lot_mo
# 2. Create new MOs where needed, by splitting them from an existing subcontracting MO
if mos_to_create:
production_to_split = move._get_subcontract_production()[0]
new_mos = production_to_split.sudo().with_context(allow_more=True, mrp_subcontracting=False)._split_productions({
production_to_split: [production_to_split.product_qty] + list(mos_to_create.values())
}, cancel_remaining_qty=True)[1:]
mos_to_assign |= new_mos
for mo, lot_id in zip(new_mos, mos_to_create.keys()):
mo.lot_producing_ids = lot_id
# 3. Delete 'orphan' MOs with lot not linked to any move line
productions = move._get_subcontract_production()
orphan_productions = productions.filtered(lambda p: (p.lot_producing_ids and p.lot_producing_ids[0] not in qty_by_lot) or (not p.lot_producing_ids and self.env['stock.lot'] not in qty_by_lot))
if len(productions) == len(orphan_productions):
# Make sure not to delete all MOs, leave 1 subcontracting MO as 'open' MO for splitting later
production_to_keep = orphan_productions[-1]
production_to_keep.lot_producing_ids = False
orphan_productions = orphan_productions[:-1]
if orphan_productions:
orphan_productions.sudo().with_context(skip_activity=True).unlink()
productions -= orphan_productions
mos_to_assign.sudo().action_assign()
def _generate_serial_numbers(self, next_serial, next_serial_count=False, location_id=False):
if self.is_subcontract:
return super(StockMove, self.with_context(force_lot_m2o=True))._generate_serial_numbers(next_serial, next_serial_count, location_id)
return super()._generate_serial_numbers(next_serial, next_serial_count, location_id)
def _get_partner_id(self):
if self.raw_material_production_id.subcontractor_id:
route = self.env.ref('mrp_subcontracting.route_resupply_subcontractor_mto', raise_if_not_found=False)
if route and self.rule_id.route_id == route:
return self.raw_material_production_id.subcontractor_id.id
return super()._get_partner_id()

View file

@ -12,7 +12,7 @@ class StockMoveLine(models.Model):
def _onchange_serial_number(self):
current_location_id = self.location_id
res = super()._onchange_serial_number()
if res and not self.lot_name and current_location_id.is_subcontracting_location:
if res and not self.lot_name and current_location_id.is_subcontract():
# we want to avoid auto-updating source location in this case + change the warning message
self.location_id = current_location_id
res['warning']['message'] = res['warning']['message'].split("\n\n", 1)[0] + "\n\n" + \
@ -20,10 +20,19 @@ class StockMoveLine(models.Model):
return res
def write(self, vals):
for move_line in self:
if vals.get('lot_id') and move_line.move_id.is_subcontract and move_line.location_id.is_subcontracting_location:
# Update related subcontracted production to keep consistency between production and reception.
subcontracted_production = move_line.move_id._get_subcontract_production().filtered(lambda p: p.state not in ('done', 'cancel') and p.lot_producing_id == move_line.lot_id)
if subcontracted_production:
subcontracted_production.lot_producing_id = vals['lot_id']
return super().write(vals)
res = super().write(vals)
if not self.env.context.get('mrp_subcontracting') and ('quantity' in vals or 'lot_id' in vals):
self.move_id.filtered(lambda m: m.is_subcontract).with_context(no_procurement=True)._sync_subcontracting_productions()
return res
def unlink(self):
moves_to_sync = self.move_id.filtered(lambda m: m.is_subcontract)
res = super().unlink()
moves_to_sync._sync_subcontracting_productions()
return res
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
res.move_id.filtered(lambda m: m.is_subcontract)._sync_subcontracting_productions()
return res

View file

@ -5,114 +5,89 @@ from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.fields import Command
from odoo.tools.float_utils import float_compare
from dateutil.relativedelta import relativedelta
class StockPicking(models.Model):
_name = 'stock.picking'
_inherit = 'stock.picking'
# override existing field domains to prevent suboncontracting production lines from showing in Detailed Operations tab
move_line_nosuggest_ids = fields.One2many(
domain=['&', '|', ('location_dest_id.usage', '!=', 'production'), ('move_id.picking_code', '!=', 'outgoing'),
'|', ('reserved_qty', '=', 0.0), '&', ('reserved_qty', '!=', 0.0), ('qty_done', '!=', 0.0)])
move_line_ids_without_package = fields.One2many(
domain=['&', '|', ('location_dest_id.usage', '!=', 'production'), ('move_id.picking_code', '!=', 'outgoing'),
'|', ('package_level_id', '=', False), ('picking_type_entire_packs', '=', False)])
display_action_record_components = fields.Selection(
[('hide', 'Hide'), ('facultative', 'Facultative'), ('mandatory', 'Mandatory')],
compute='_compute_display_action_record_components')
show_subcontracting_details_visible = fields.Boolean(compute='_compute_show_subcontracting_details_visible')
@api.depends('state', 'move_ids')
def _compute_display_action_record_components(self):
self.display_action_record_components = 'hide'
@api.depends('move_ids.show_subcontracting_details_visible')
def _compute_show_subcontracting_details_visible(self):
for picking in self:
# Hide if not encoding state or it is not a subcontracting picking
if picking.state in ('draft', 'cancel', 'done') or not picking._is_subcontract():
continue
subcontracted_moves = picking.move_ids.filtered(lambda m: m.is_subcontract)
if subcontracted_moves._subcontrating_should_be_record():
picking.display_action_record_components = 'mandatory'
continue
if subcontracted_moves._subcontrating_can_be_record():
picking.display_action_record_components = 'facultative'
picking.show_subcontracting_details_visible = any(m.show_subcontracting_details_visible for m in picking.move_ids)
@api.depends('picking_type_id', 'partner_id')
def _compute_location_id(self):
super()._compute_location_id()
for picking in self:
# If this is a subcontractor resupply transfer, set the destination location
# to the vendor subcontractor location
subcontracting_resupply_type_id = picking.picking_type_id.warehouse_id.subcontracting_resupply_type_id
if picking.picking_type_id == subcontracting_resupply_type_id\
and picking.partner_id.property_stock_subcontractor:
picking.location_dest_id = picking.partner_id.property_stock_subcontractor
@api.depends('move_ids.is_subcontract', 'move_ids.has_tracking')
def _compute_show_lots_text(self):
super()._compute_show_lots_text()
for picking in self:
if any(move.is_subcontract and move.has_tracking != 'none' for move in picking.move_ids):
picking.show_lots_text = False
# -------------------------------------------------------------------------
# Action methods
# -------------------------------------------------------------------------
def _action_done(self):
res = super(StockPicking, self)._action_done()
for move in self.move_ids:
if not move.is_subcontract:
continue
# Auto set qty_producing/lot_producing_id of MO wasn't recorded
# manually (if the flexible + record_component or has tracked component)
productions = move._get_subcontract_production()
recorded_productions = productions.filtered(lambda p: p._has_been_recorded())
recorded_qty = sum(recorded_productions.mapped('qty_producing'))
sm_done_qty = sum(productions._get_subcontract_move().mapped('quantity_done'))
rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
if float_compare(recorded_qty, sm_done_qty, precision_digits=rounding) >= 0:
continue
production = productions - recorded_productions
if not production:
continue
if len(production) > 1:
raise UserError(_("There shouldn't be multiple productions to record for the same subcontracted move."))
# Manage additional quantities
quantity_done_move = move.product_uom._compute_quantity(move.quantity_done, production.product_uom_id)
if float_compare(production.product_qty, quantity_done_move, precision_rounding=production.product_uom_id.rounding) == -1:
change_qty = self.env['change.production.qty'].create({
'mo_id': production.id,
'product_qty': quantity_done_move
})
change_qty.with_context(skip_activity=True).change_prod_qty()
# Create backorder MO for each move lines
amounts = [move_line.qty_done for move_line in move.move_line_ids]
len_amounts = len(amounts)
# _split_production can set the qty_done, but not split it.
# Remove the qty_done potentially set by a previous split to prevent any issue.
production.move_line_raw_ids.filtered(lambda sml: sml.state not in ['done', 'cancel']).write({'qty_done': 0})
productions = production._split_productions({production: amounts}, set_consumed_qty=True)
for production, move_line in zip(productions, move.move_line_ids):
if move_line.lot_id:
production.lot_producing_id = move_line.lot_id
production.qty_producing = production.product_qty
production._set_qty_producing()
productions[:len_amounts].subcontracting_has_been_recorded = True
for picking in self:
productions_to_done = picking._get_subcontract_production()._subcontracting_filter_to_done()
productions_to_done._subcontract_sanity_check()
if not productions_to_done:
continue
productions_to_done = productions_to_done.sudo()
production_ids_backorder = []
if not self.env.context.get('cancel_backorder'):
production_ids_backorder = productions_to_done.filtered(lambda mo: mo.state == "progress").ids
productions_to_done.with_context(mo_ids_to_backorder=production_ids_backorder).button_mark_done()
productions_to_done = picking._get_subcontract_production().sudo()
productions_to_done.button_mark_done()
# For concistency, set the date on production move before the date
# on picking. (Traceability report + Product Moves menu item)
minimum_date = min(picking.move_line_ids.mapped('date'))
production_moves = productions_to_done.move_raw_ids | productions_to_done.move_finished_ids
production_moves.write({'date': minimum_date - timedelta(seconds=1)})
production_moves.move_line_ids.write({'date': minimum_date - timedelta(seconds=1)})
if production_moves:
minimum_date = min(picking.move_line_ids.mapped('date'))
production_moves.write({'date': minimum_date - timedelta(seconds=1)})
production_moves.move_line_ids.write({'date': minimum_date - timedelta(seconds=1)})
return res
def action_record_components(self):
self.ensure_one()
move_subcontracted = self.move_ids.filtered(lambda m: m.is_subcontract)
for move in move_subcontracted:
production = move._subcontrating_should_be_record()
if production:
return move._action_record_components()
for move in move_subcontracted:
production = move._subcontrating_can_be_record()
if production:
return move._action_record_components()
raise UserError(_("Nothing to record"))
def action_show_subcontract_details(self):
productions = self._get_subcontract_production().filtered(lambda m: m.state != 'cancel')
ctx = {"mrp_subcontracting": True}
if self.env.user._is_portal():
form_view_id = self.env.ref('mrp_subcontracting.mrp_production_subcontracting_portal_form_view')
ctx.update(no_breadcrumbs=False)
else:
form_view_id = self.env.ref('mrp_subcontracting.mrp_production_subcontracting_form_view')
action = {
'type': 'ir.actions.act_window',
'res_model': 'mrp.production',
'target': 'current',
'context': ctx
}
if len(productions) > 1:
action.update({
'name': _('Subcontracting MOs'),
'views': [
(self.env.ref('mrp_subcontracting.mrp_production_subcontracting_tree_view').id, 'list'),
(form_view_id.id, 'form'),
],
'domain': [('id', 'in', productions.ids)],
})
elif len(productions) == 1:
action.update({
'views': [(form_view_id.id, 'form')],
'res_id': productions.id,
})
else:
return {}
return action
# -------------------------------------------------------------------------
# Subcontract helpers
@ -129,73 +104,75 @@ class StockPicking(models.Model):
def _prepare_subcontract_mo_vals(self, subcontract_move, bom):
subcontract_move.ensure_one()
group = self.env['procurement.group'].create({
reference = self.env['stock.reference'].create({
'name': self.name,
'partner_id': self.partner_id.id,
'move_ids': [Command.link(subcontract_move.id)],
})
product = subcontract_move.product_id
warehouse = self._get_warehouse(subcontract_move)
subcontracting_location = \
subcontract_move.picking_id.partner_id.with_company(subcontract_move.company_id).property_stock_subcontractor \
or subcontract_move.company_id.subcontracting_location_id
vals = {
'company_id': subcontract_move.company_id.id,
'procurement_group_id': group.id,
'subcontractor_id': subcontract_move.picking_id.partner_id.commercial_partner_id.id,
'picking_ids': [subcontract_move.picking_id.id],
'product_id': product.id,
'product_uom_id': subcontract_move.product_uom.id,
'bom_id': bom.id,
'location_src_id': subcontract_move.picking_id.partner_id.with_company(subcontract_move.company_id).property_stock_subcontractor.id,
'location_dest_id': subcontract_move.picking_id.partner_id.with_company(subcontract_move.company_id).property_stock_subcontractor.id,
'product_qty': subcontract_move.product_uom_qty,
'location_src_id': subcontracting_location.id,
'location_dest_id': subcontracting_location.id,
'product_qty': subcontract_move.product_uom_qty or subcontract_move.quantity,
'picking_type_id': warehouse.subcontracting_type_id.id,
'date_planned_start': subcontract_move.date - relativedelta(days=product.produce_delay)
'date_start': subcontract_move.date - relativedelta(days=bom.produce_delay),
'origin': self.name,
'reference_ids': [Command.link(reference.id)],
}
return vals
def _get_subcontract_mo_confirmation_ctx(self):
if self._is_subcontract() and not self.env.context.get('cancel_backorder', True):
# Do not trigger rules on raw moves when creating backorder for a subcontract receipt.
return {'no_procurement': True}
return {} # To override in mrp_subcontracting_purchase
def _subcontracted_produce(self, subcontract_details):
self.ensure_one()
group_move = defaultdict(list)
group_by_company = defaultdict(list)
group_by_company = defaultdict(lambda: ([], []))
for move, bom in subcontract_details:
# do not create extra production for move that have their quantity updated
if move.move_orig_ids.production_id:
continue
if float_compare(move.product_qty, 0, precision_rounding=move.product_uom.rounding) <= 0:
if len(move.move_orig_ids.move_dest_ids) > 1:
# Magic spicy sauce for the backorder case:
# To ensure correct splitting of the component moves of the SBC MO, we will invoke a split of the SBC
# MO here directly and then link the backorder MO to the backorder move.
# If we would just run _subcontracted_produce as usual for the newly created SBC receipt move, any
# reservations of raw component moves of the SBC MO would not be preserved properly (for example when
# using resupply subcontractor on order)
production_to_split = move.move_orig_ids[0].production_id
original_qty = move.move_orig_ids[0].product_qty
move.move_orig_ids = False
_, new_mo = production_to_split.with_context(allow_more=True)._split_productions({production_to_split: [original_qty, move.product_qty]})
new_mo.move_finished_ids.move_dest_ids = move
continue
else:
# do not create extra production for move that have their quantity updated
return
quantity = move.product_qty or move.quantity
if move.product_uom.compare(quantity, 0) <= 0:
# If a subcontracted amount is decreased, don't create a MO that would be for a negative value.
continue
mo_subcontract = self._prepare_subcontract_mo_vals(move, bom)
# Link the move to the id of the MO's procurement group
group_move[mo_subcontract['procurement_group_id']] = move
# Group the MO by company
group_by_company[move.company_id.id].append(mo_subcontract)
group_by_company[move.company_id.id][0].append(mo_subcontract)
group_by_company[move.company_id.id][1].append(move)
all_mo = set()
for company, group in group_by_company.items():
grouped_mo = self.env['mrp.production'].with_company(company).create(group)
all_mo.update(grouped_mo.ids)
all_mo = self.env['mrp.production'].browse(sorted(all_mo))
all_mo.action_confirm()
for mo in all_mo:
move = group_move[mo.procurement_group_id.id][0]
finished_move = mo.move_finished_ids.filtered(lambda m: m.product_id == move.product_id)
finished_move.write({'move_dest_ids': [(4, move.id, False)]})
all_mo.action_assign()
@api.onchange('location_id', 'location_dest_id')
def _onchange_locations(self):
moves = self.move_ids | self.move_ids_without_package
moves.filtered(lambda m: m.is_subcontract).update({
"location_dest_id": self.location_dest_id,
})
moves.filtered(lambda m: not m.is_subcontract).update({
"location_id": self.location_id,
"location_dest_id": self.location_dest_id,
})
if any(line.reserved_qty or line.qty_done for line in self.move_ids.move_line_ids):
return {'warning': {
'title': _("Locations to update"),
'message': _("You might want to update the locations of this transfer's operations"),
}
}
vals_list, moves = group
grouped_mo = self.env['mrp.production'].with_company(company).create(vals_list)
grouped_mo.with_context(self._get_subcontract_mo_confirmation_ctx()).action_confirm()
for mo, move in zip(grouped_mo, moves):
mo.date_finished = move.date
finished_move = mo.move_finished_ids.filtered(lambda m: m.product_id == move.product_id)
finished_move.move_dest_ids = [Command.link(move.id)]
grouped_mo.action_assign()

View file

@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.exceptions import UserError
from odoo import fields, models
class StockQuant(models.Model):
@ -11,7 +9,7 @@ class StockQuant(models.Model):
is_subcontract = fields.Boolean(store=False, search='_search_is_subcontract')
def _search_is_subcontract(self, operator, value):
if operator not in ['=', '!='] or not isinstance(value, bool):
raise UserError(_('Operation not supported'))
return [('location_id.is_subcontracting_location', operator, value)]
if operator != 'in':
return NotImplemented
subcontracting_location_ids = self.env.companies.subcontracting_location_id.child_internal_location_ids.ids
return [('location_id', operator, subcontracting_location_ids)]

View file

@ -11,3 +11,10 @@ class StockRule(models.Model):
new_move_vals = super(StockRule, self)._push_prepare_move_copy_values(move_to_copy, new_date)
new_move_vals["is_subcontract"] = False
return new_move_vals
def _get_stock_move_values(self, product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values):
move_values = super()._get_stock_move_values(product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values)
if not move_values.get('partner_id'):
if values.get('move_dest_ids') and values['move_dest_ids'].raw_material_production_id.subcontractor_id:
move_values['partner_id'] = values['move_dest_ids'].raw_material_production_id.subcontractor_id.id
return move_values

View file

@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.fields import Command
class StockWarehouse(models.Model):
@ -10,24 +11,23 @@ class StockWarehouse(models.Model):
subcontracting_to_resupply = fields.Boolean(
'Resupply Subcontractors', default=True)
subcontracting_mto_pull_id = fields.Many2one(
'stock.rule', 'Subcontracting MTO Rule')
'stock.rule', 'Subcontracting MTO Rule', copy=False)
subcontracting_pull_id = fields.Many2one(
'stock.rule', 'Subcontracting MTS Rule'
'stock.rule', 'Subcontracting MTS Rule', copy=False
)
subcontracting_route_id = fields.Many2one('stock.route', 'Resupply Subcontractor', ondelete='restrict')
subcontracting_route_id = fields.Many2one('stock.route', 'Resupply Subcontractor', ondelete='restrict', copy=False)
subcontracting_type_id = fields.Many2one(
'stock.picking.type', 'Subcontracting Operation Type',
domain=[('code', '=', 'mrp_operation')])
domain=[('code', '=', 'mrp_operation')], copy=False)
subcontracting_resupply_type_id = fields.Many2one(
'stock.picking.type', 'Subcontracting Resupply Operation Type',
domain=[('code', '=', 'outgoing')])
domain=[('code', '=', 'internal')], copy=False)
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
res._update_subcontracting_locations_rules()
# if new warehouse has resupply enabled, enable global route
if any([vals.get('subcontracting_to_resupply', False) for vals in vals_list]):
res._update_global_route_resupply_subcontractor()
@ -44,7 +44,7 @@ class StockWarehouse(models.Model):
return res
def get_rules_dict(self):
result = super(StockWarehouse, self).get_rules_dict()
result = super().get_rules_dict()
subcontract_location_id = self._get_subcontracting_location()
for warehouse in self:
result[warehouse.id].update({
@ -61,6 +61,7 @@ class StockWarehouse(models.Model):
route_id.active = False
else:
route_id.active = True
self.route_ids = [Command.link(route_id.id)]
def _get_routes_values(self):
routes = super(StockWarehouse, self)._get_routes_values()
@ -86,8 +87,8 @@ class StockWarehouse(models.Model):
})
return routes
def _get_global_route_rules_values(self):
rules = super(StockWarehouse, self)._get_global_route_rules_values()
def _generate_global_route_rules_values(self):
rules = super()._generate_global_route_rules_values()
subcontract_location_id = self._get_subcontracting_location()
production_location_id = self._get_production_location()
rules.update({
@ -98,7 +99,7 @@ class StockWarehouse(models.Model):
'company_id': self.company_id.id,
'action': 'pull',
'auto': 'manual',
'route_id': self._find_or_create_global_route('stock.route_warehouse0_mto', _('Make To Order')).id,
'route_id': self._find_or_create_global_route('stock.route_warehouse0_mto', _('Replenish on Order (MTO)')).id,
'name': self._format_rulename(self.lot_stock_id, subcontract_location_id, 'MTO'),
'location_dest_id': subcontract_location_id.id,
'location_src_id': self.lot_stock_id.id,
@ -115,8 +116,7 @@ class StockWarehouse(models.Model):
'company_id': self.company_id.id,
'action': 'pull',
'auto': 'manual',
'route_id': self._find_or_create_global_route('mrp_subcontracting.route_resupply_subcontractor_mto',
_('Resupply Subcontractor on Order')).id,
'route_id': self._find_or_create_global_route('mrp_subcontracting.route_resupply_subcontractor_mto', _('Resupply Subcontractor on Order')).id,
'name': self._format_rulename(subcontract_location_id, production_location_id, False),
'location_dest_id': production_location_id.id,
'location_src_id': subcontract_location_id.id,
@ -142,7 +142,7 @@ class StockWarehouse(models.Model):
},
'subcontracting_resupply_type_id': {
'name': _('Resupply Subcontractor'),
'code': 'outgoing',
'code': 'internal',
'use_create_lots': False,
'use_existing_lots': True,
'default_location_dest_id': self._get_subcontracting_location().id,
@ -156,16 +156,16 @@ class StockWarehouse(models.Model):
def _get_sequence_values(self, name=False, code=False):
values = super(StockWarehouse, self)._get_sequence_values(name=name, code=code)
count = self.env['ir.sequence'].search_count([('prefix', 'like', self.code + '/SBC%/%')])
count = self.env['ir.sequence'].search_count([('prefix', '=like', self.code + '/SBC%/%')])
values.update({
'subcontracting_type_id': {
'name': self.name + ' ' + _('Sequence subcontracting'),
'name': _('%(name)s Sequence subcontracting', name=self.name),
'prefix': self.code + '/' + (self.subcontracting_type_id.sequence_code or (('SBC' + str(count)) if count else 'SBC')) + '/',
'padding': 5,
'company_id': self.company_id.id
},
'subcontracting_resupply_type_id': {
'name': self.name + ' ' + _('Sequence Resupply Subcontractor'),
'name': _('%(name)s Sequence Resupply Subcontractor', name=self.name),
'prefix': self.code + '/' + (self.subcontracting_resupply_type_id.sequence_code or (('RES' + str(count)) if count else 'RES')) + '/',
'padding': 5,
'company_id': self.company_id.id
@ -186,7 +186,7 @@ class StockWarehouse(models.Model):
'subcontracting_resupply_type_id': {
'default_location_src_id': self.lot_stock_id.id,
'default_location_dest_id': subcontract_location_id.id,
'barcode': self.code.replace(" ", "").upper() + "-RESUPPLY",
'barcode': self.code.replace(" ", "").upper() + "RESUP",
'active': self.subcontracting_to_resupply and self.active
},
})
@ -196,14 +196,7 @@ class StockWarehouse(models.Model):
return self.company_id.subcontracting_location_id
def _get_subcontracting_locations(self):
return self.env['stock.location'].search([
('company_id', 'in', self.company_id.ids),
('is_subcontracting_location', '=', 'True'),
])
def _update_subcontracting_locations_rules(self):
subcontracting_locations = self._get_subcontracting_locations()
subcontracting_locations._activate_subcontracting_location_rules()
return self.company_id.subcontracting_location_id.child_internal_location_ids
def _update_resupply_rules(self):
'''update (archive/unarchive) any warehouse subcontracting location resupply rules'''