1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4import json
5import time
6from ast import literal_eval
7from collections import defaultdict
8from datetime import date
9from itertools import groupby
10from operator import attrgetter, itemgetter
11from collections import defaultdict
12
13from odoo import SUPERUSER_ID, _, api, fields, models
14from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
15from odoo.exceptions import UserError
16from odoo.osv import expression
17from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, format_datetime
18from odoo.tools.float_utils import float_compare, float_is_zero, float_round
19from odoo.tools.misc import format_date
20
21
22class PickingType(models.Model):
23    _name = "stock.picking.type"
24    _description = "Picking Type"
25    _order = 'sequence, id'
26    _check_company_auto = True
27
28    def _default_show_operations(self):
29        return self.user_has_groups('stock.group_production_lot,'
30                                    'stock.group_stock_multi_locations,'
31                                    'stock.group_tracking_lot')
32
33    name = fields.Char('Operation Type', required=True, translate=True)
34    color = fields.Integer('Color')
35    sequence = fields.Integer('Sequence', help="Used to order the 'All Operations' kanban view")
36    sequence_id = fields.Many2one(
37        'ir.sequence', 'Reference Sequence',
38        check_company=True, copy=False)
39    sequence_code = fields.Char('Code', required=True)
40    default_location_src_id = fields.Many2one(
41        'stock.location', 'Default Source Location',
42        check_company=True,
43        help="This is the default source location when you create a picking manually with this operation type. It is possible however to change it or that the routes put another location. If it is empty, it will check for the supplier location on the partner. ")
44    default_location_dest_id = fields.Many2one(
45        'stock.location', 'Default Destination Location',
46        check_company=True,
47        help="This is the default destination location when you create a picking manually with this operation type. It is possible however to change it or that the routes put another location. If it is empty, it will check for the customer location on the partner. ")
48    code = fields.Selection([('incoming', 'Receipt'), ('outgoing', 'Delivery'), ('internal', 'Internal Transfer')], 'Type of Operation', required=True)
49    return_picking_type_id = fields.Many2one(
50        'stock.picking.type', 'Operation Type for Returns',
51        check_company=True)
52    show_entire_packs = fields.Boolean('Move Entire Packages', help="If ticked, you will be able to select entire packages to move")
53    warehouse_id = fields.Many2one(
54        'stock.warehouse', 'Warehouse', ondelete='cascade',
55        check_company=True)
56    active = fields.Boolean('Active', default=True)
57    use_create_lots = fields.Boolean(
58        'Create New Lots/Serial Numbers', default=True,
59        help="If this is checked only, it will suppose you want to create new Lots/Serial Numbers, so you can provide them in a text field. ")
60    use_existing_lots = fields.Boolean(
61        'Use Existing Lots/Serial Numbers', default=True,
62        help="If this is checked, you will be able to choose the Lots/Serial Numbers. You can also decide to not put lots in this operation type.  This means it will create stock with no lot or not put a restriction on the lot taken. ")
63    show_operations = fields.Boolean(
64        'Show Detailed Operations', default=_default_show_operations,
65        help="If this checkbox is ticked, the pickings lines will represent detailed stock operations. If not, the picking lines will represent an aggregate of detailed stock operations.")
66    show_reserved = fields.Boolean(
67        'Pre-fill Detailed Operations', default=True,
68        help="If this checkbox is ticked, Odoo will automatically pre-fill the detailed "
69        "operations with the corresponding products, locations and lot/serial numbers.")
70
71    count_picking_draft = fields.Integer(compute='_compute_picking_count')
72    count_picking_ready = fields.Integer(compute='_compute_picking_count')
73    count_picking = fields.Integer(compute='_compute_picking_count')
74    count_picking_waiting = fields.Integer(compute='_compute_picking_count')
75    count_picking_late = fields.Integer(compute='_compute_picking_count')
76    count_picking_backorders = fields.Integer(compute='_compute_picking_count')
77    rate_picking_late = fields.Integer(compute='_compute_picking_count')
78    rate_picking_backorders = fields.Integer(compute='_compute_picking_count')
79    barcode = fields.Char('Barcode', copy=False)
80    company_id = fields.Many2one(
81        'res.company', 'Company', required=True,
82        default=lambda s: s.env.company.id, index=True)
83
84    @api.model
85    def create(self, vals):
86        if 'sequence_id' not in vals or not vals['sequence_id']:
87            if vals['warehouse_id']:
88                wh = self.env['stock.warehouse'].browse(vals['warehouse_id'])
89                vals['sequence_id'] = self.env['ir.sequence'].sudo().create({
90                    'name': wh.name + ' ' + _('Sequence') + ' ' + vals['sequence_code'],
91                    'prefix': wh.code + '/' + vals['sequence_code'] + '/', 'padding': 5,
92                    'company_id': wh.company_id.id,
93                }).id
94            else:
95                vals['sequence_id'] = self.env['ir.sequence'].create({
96                    'name': _('Sequence') + ' ' + vals['sequence_code'],
97                    'prefix': vals['sequence_code'], 'padding': 5,
98                    'company_id': vals.get('company_id') or self.env.company.id,
99                }).id
100
101        picking_type = super(PickingType, self).create(vals)
102        return picking_type
103
104    def write(self, vals):
105        if 'company_id' in vals:
106            for picking_type in self:
107                if picking_type.company_id.id != vals['company_id']:
108                    raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one."))
109        if 'sequence_code' in vals:
110            for picking_type in self:
111                if picking_type.warehouse_id:
112                    picking_type.sequence_id.write({
113                        'name': picking_type.warehouse_id.name + ' ' + _('Sequence') + ' ' + vals['sequence_code'],
114                        'prefix': picking_type.warehouse_id.code + '/' + vals['sequence_code'] + '/', 'padding': 5,
115                        'company_id': picking_type.warehouse_id.company_id.id,
116                    })
117                else:
118                    picking_type.sequence_id.write({
119                        'name': _('Sequence') + ' ' + vals['sequence_code'],
120                        'prefix': vals['sequence_code'], 'padding': 5,
121                        'company_id': picking_type.env.company.id,
122                    })
123        return super(PickingType, self).write(vals)
124
125    def _compute_picking_count(self):
126        # TDE TODO count picking can be done using previous two
127        domains = {
128            'count_picking_draft': [('state', '=', 'draft')],
129            'count_picking_waiting': [('state', 'in', ('confirmed', 'waiting'))],
130            'count_picking_ready': [('state', '=', 'assigned')],
131            'count_picking': [('state', 'in', ('assigned', 'waiting', 'confirmed'))],
132            'count_picking_late': [('scheduled_date', '<', time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)), ('state', 'in', ('assigned', 'waiting', 'confirmed'))],
133            'count_picking_backorders': [('backorder_id', '!=', False), ('state', 'in', ('confirmed', 'assigned', 'waiting'))],
134        }
135        for field in domains:
136            data = self.env['stock.picking'].read_group(domains[field] +
137                [('state', 'not in', ('done', 'cancel')), ('picking_type_id', 'in', self.ids)],
138                ['picking_type_id'], ['picking_type_id'])
139            count = {
140                x['picking_type_id'][0]: x['picking_type_id_count']
141                for x in data if x['picking_type_id']
142            }
143            for record in self:
144                record[field] = count.get(record.id, 0)
145        for record in self:
146            record.rate_picking_late = record.count_picking and record.count_picking_late * 100 / record.count_picking or 0
147            record.rate_picking_backorders = record.count_picking and record.count_picking_backorders * 100 / record.count_picking or 0
148
149    def name_get(self):
150        """ Display 'Warehouse_name: PickingType_name' """
151        res = []
152        for picking_type in self:
153            if picking_type.warehouse_id:
154                name = picking_type.warehouse_id.name + ': ' + picking_type.name
155            else:
156                name = picking_type.name
157            res.append((picking_type.id, name))
158        return res
159
160    @api.model
161    def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
162        args = args or []
163        domain = []
164        if name:
165            domain = ['|', ('name', operator, name), ('warehouse_id.name', operator, name)]
166        return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
167
168    @api.onchange('code')
169    def _onchange_picking_code(self):
170        warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.company_id.id)], limit=1)
171        stock_location = warehouse.lot_stock_id
172        self.show_operations = self.code != 'incoming' and self.user_has_groups(
173            'stock.group_production_lot,'
174            'stock.group_stock_multi_locations,'
175            'stock.group_tracking_lot'
176        )
177        if self.code == 'incoming':
178            self.default_location_src_id = self.env.ref('stock.stock_location_suppliers').id
179            self.default_location_dest_id = stock_location.id
180        elif self.code == 'outgoing':
181            self.default_location_src_id = stock_location.id
182            self.default_location_dest_id = self.env.ref('stock.stock_location_customers').id
183        elif self.code == 'internal' and not self.user_has_groups('stock.group_stock_multi_locations'):
184            return {
185                'warning': {
186                    'message': _('You need to activate storage locations to be able to do internal operation types.')
187                }
188            }
189
190    @api.onchange('company_id')
191    def _onchange_company_id(self):
192        if self.company_id:
193            warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.company_id.id)], limit=1)
194            self.warehouse_id = warehouse
195        else:
196            self.warehouse_id = False
197
198    @api.onchange('show_operations')
199    def _onchange_show_operations(self):
200        if self.show_operations and self.code != 'incoming':
201            self.show_reserved = True
202
203    def _get_action(self, action_xmlid):
204        action = self.env["ir.actions.actions"]._for_xml_id(action_xmlid)
205        if self:
206            action['display_name'] = self.display_name
207
208        default_immediate_tranfer = True
209        if self.env['ir.config_parameter'].sudo().get_param('stock.no_default_immediate_tranfer'):
210            default_immediate_tranfer = False
211
212        context = {
213            'search_default_picking_type_id': [self.id],
214            'default_picking_type_id': self.id,
215            'default_immediate_transfer': default_immediate_tranfer,
216            'default_company_id': self.company_id.id,
217        }
218
219        action_context = literal_eval(action['context'])
220        context = {**action_context, **context}
221        action['context'] = context
222        return action
223
224    def get_action_picking_tree_late(self):
225        return self._get_action('stock.action_picking_tree_late')
226
227    def get_action_picking_tree_backorder(self):
228        return self._get_action('stock.action_picking_tree_backorder')
229
230    def get_action_picking_tree_waiting(self):
231        return self._get_action('stock.action_picking_tree_waiting')
232
233    def get_action_picking_tree_ready(self):
234        return self._get_action('stock.action_picking_tree_ready')
235
236    def get_stock_picking_action_picking_type(self):
237        return self._get_action('stock.stock_picking_action_picking_type')
238
239
240class Picking(models.Model):
241    _name = "stock.picking"
242    _inherit = ['mail.thread', 'mail.activity.mixin']
243    _description = "Transfer"
244    _order = "priority desc, scheduled_date asc, id desc"
245
246    name = fields.Char(
247        'Reference', default='/',
248        copy=False, index=True, readonly=True)
249    origin = fields.Char(
250        'Source Document', index=True,
251        states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
252        help="Reference of the document")
253    note = fields.Text('Notes')
254    backorder_id = fields.Many2one(
255        'stock.picking', 'Back Order of',
256        copy=False, index=True, readonly=True,
257        check_company=True,
258        help="If this shipment was split, then this field links to the shipment which contains the already processed part.")
259    backorder_ids = fields.One2many('stock.picking', 'backorder_id', 'Back Orders')
260    move_type = fields.Selection([
261        ('direct', 'As soon as possible'), ('one', 'When all products are ready')], 'Shipping Policy',
262        default='direct', required=True,
263        states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
264        help="It specifies goods to be deliver partially or all at once")
265    state = fields.Selection([
266        ('draft', 'Draft'),
267        ('waiting', 'Waiting Another Operation'),
268        ('confirmed', 'Waiting'),
269        ('assigned', 'Ready'),
270        ('done', 'Done'),
271        ('cancel', 'Cancelled'),
272    ], string='Status', compute='_compute_state',
273        copy=False, index=True, readonly=True, store=True, tracking=True,
274        help=" * Draft: The transfer is not confirmed yet. Reservation doesn't apply.\n"
275             " * Waiting another operation: This transfer is waiting for another operation before being ready.\n"
276             " * Waiting: The transfer is waiting for the availability of some products.\n(a) The shipping policy is \"As soon as possible\": no product could be reserved.\n(b) The shipping policy is \"When all products are ready\": not all the products could be reserved.\n"
277             " * Ready: The transfer is ready to be processed.\n(a) The shipping policy is \"As soon as possible\": at least one product has been reserved.\n(b) The shipping policy is \"When all products are ready\": all product have been reserved.\n"
278             " * Done: The transfer has been processed.\n"
279             " * Cancelled: The transfer has been cancelled.")
280    group_id = fields.Many2one(
281        'procurement.group', 'Procurement Group',
282        readonly=True, related='move_lines.group_id', store=True)
283    priority = fields.Selection(
284        PROCUREMENT_PRIORITIES, string='Priority', default='0', index=True,
285        help="Products will be reserved first for the transfers with the highest priorities.")
286    scheduled_date = fields.Datetime(
287        'Scheduled Date', compute='_compute_scheduled_date', inverse='_set_scheduled_date', store=True,
288        index=True, default=fields.Datetime.now, tracking=True,
289        states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
290        help="Scheduled time for the first part of the shipment to be processed. Setting manually a value here would set it as expected date for all the stock moves.")
291    date_deadline = fields.Datetime(
292        "Deadline", compute='_compute_date_deadline', store=True,
293        help="Date Promise to the customer on the top level document (SO/PO)")
294    has_deadline_issue = fields.Boolean(
295        "Is late", compute='_compute_has_deadline_issue', store=True, default=False,
296        help="Is late or will be late depending on the deadline and scheduled date")
297    date = fields.Datetime(
298        'Creation Date',
299        default=fields.Datetime.now, index=True, tracking=True,
300        states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
301        help="Creation Date, usually the time of the order")
302    date_done = fields.Datetime('Date of Transfer', copy=False, readonly=True, help="Date at which the transfer has been processed or cancelled.")
303    delay_alert_date = fields.Datetime('Delay Alert Date', compute='_compute_delay_alert_date', search='_search_delay_alert_date')
304    json_popover = fields.Char('JSON data for the popover widget', compute='_compute_json_popover')
305    location_id = fields.Many2one(
306        'stock.location', "Source Location",
307        default=lambda self: self.env['stock.picking.type'].browse(self._context.get('default_picking_type_id')).default_location_src_id,
308        check_company=True, readonly=True, required=True,
309        states={'draft': [('readonly', False)]})
310    location_dest_id = fields.Many2one(
311        'stock.location', "Destination Location",
312        default=lambda self: self.env['stock.picking.type'].browse(self._context.get('default_picking_type_id')).default_location_dest_id,
313        check_company=True, readonly=True, required=True,
314        states={'draft': [('readonly', False)]})
315    move_lines = fields.One2many('stock.move', 'picking_id', string="Stock Moves", copy=True)
316    move_ids_without_package = fields.One2many('stock.move', 'picking_id', string="Stock moves not in package", compute='_compute_move_without_package', inverse='_set_move_without_package')
317    has_scrap_move = fields.Boolean(
318        'Has Scrap Moves', compute='_has_scrap_move')
319    picking_type_id = fields.Many2one(
320        'stock.picking.type', 'Operation Type',
321        required=True, readonly=True,
322        states={'draft': [('readonly', False)]})
323    picking_type_code = fields.Selection(
324        related='picking_type_id.code',
325        readonly=True)
326    picking_type_entire_packs = fields.Boolean(related='picking_type_id.show_entire_packs',
327        readonly=True)
328    hide_picking_type = fields.Boolean(compute='_compute_hide_pickign_type')
329    partner_id = fields.Many2one(
330        'res.partner', 'Contact',
331        check_company=True,
332        states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
333    company_id = fields.Many2one(
334        'res.company', string='Company', related='picking_type_id.company_id',
335        readonly=True, store=True, index=True)
336    user_id = fields.Many2one(
337        'res.users', 'Responsible', tracking=True,
338        domain=lambda self: [('groups_id', 'in', self.env.ref('stock.group_stock_user').id)],
339        states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
340        default=lambda self: self.env.user)
341    move_line_ids = fields.One2many('stock.move.line', 'picking_id', 'Operations')
342    move_line_ids_without_package = fields.One2many('stock.move.line', 'picking_id', 'Operations without package', domain=['|',('package_level_id', '=', False), ('picking_type_entire_packs', '=', False)])
343    move_line_nosuggest_ids = fields.One2many('stock.move.line', 'picking_id', domain=[('product_qty', '=', 0.0)])
344    move_line_exist = fields.Boolean(
345        'Has Pack Operations', compute='_compute_move_line_exist',
346        help='Check the existence of pack operation on the picking')
347    has_packages = fields.Boolean(
348        'Has Packages', compute='_compute_has_packages',
349        help='Check the existence of destination packages on move lines')
350    show_check_availability = fields.Boolean(
351        compute='_compute_show_check_availability',
352        help='Technical field used to compute whether the button "Check Availability" should be displayed.')
353    show_mark_as_todo = fields.Boolean(
354        compute='_compute_show_mark_as_todo',
355        help='Technical field used to compute whether the button "Mark as Todo" should be displayed.')
356    show_validate = fields.Boolean(
357        compute='_compute_show_validate',
358        help='Technical field used to decide whether the button "Validate" should be displayed.')
359    use_create_lots = fields.Boolean(related='picking_type_id.use_create_lots')
360    owner_id = fields.Many2one(
361        'res.partner', 'Assign Owner',
362        states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
363        check_company=True,
364        help="When validating the transfer, the products will be assigned to this owner.")
365    printed = fields.Boolean('Printed', copy=False)
366    signature = fields.Image('Signature', help='Signature', copy=False, attachment=True)
367    is_locked = fields.Boolean(default=True, help='When the picking is not done this allows changing the '
368                               'initial demand. When the picking is done this allows '
369                               'changing the done quantities.')
370    # Used to search on pickings
371    product_id = fields.Many2one('product.product', 'Product', related='move_lines.product_id', readonly=True)
372    show_operations = fields.Boolean(compute='_compute_show_operations')
373    show_reserved = fields.Boolean(related='picking_type_id.show_reserved')
374    show_lots_text = fields.Boolean(compute='_compute_show_lots_text')
375    has_tracking = fields.Boolean(compute='_compute_has_tracking')
376    immediate_transfer = fields.Boolean(default=False)
377    package_level_ids = fields.One2many('stock.package_level', 'picking_id')
378    package_level_ids_details = fields.One2many('stock.package_level', 'picking_id')
379    products_availability = fields.Char(
380        string="Product Availability", compute='_compute_products_availability')
381    products_availability_state = fields.Selection([
382        ('available', 'Available'),
383        ('expected', 'Expected'),
384        ('late', 'Late')], compute='_compute_products_availability')
385
386    _sql_constraints = [
387        ('name_uniq', 'unique(name, company_id)', 'Reference must be unique per company!'),
388    ]
389
390    def _compute_has_tracking(self):
391        for picking in self:
392            picking.has_tracking = any(m.has_tracking != 'none' for m in picking.move_lines)
393
394    @api.depends('date_deadline', 'scheduled_date')
395    def _compute_has_deadline_issue(self):
396        for picking in self:
397            picking.has_deadline_issue = picking.date_deadline and picking.date_deadline < picking.scheduled_date or False
398
399    def _compute_hide_pickign_type(self):
400        self.hide_picking_type = self.env.context.get('default_picking_type_id', False)
401
402    @api.depends('move_lines.delay_alert_date')
403    def _compute_delay_alert_date(self):
404        delay_alert_date_data = self.env['stock.move'].read_group([('id', 'in', self.move_lines.ids), ('delay_alert_date', '!=', False)], ['delay_alert_date:max'], 'picking_id')
405        delay_alert_date_data = {data['picking_id'][0]: data['delay_alert_date'] for data in delay_alert_date_data}
406        for picking in self:
407            picking.delay_alert_date = delay_alert_date_data.get(picking.id, False)
408
409    @api.depends('move_lines', 'state', 'picking_type_code', 'move_lines.forecast_availability', 'move_lines.forecast_expected_date')
410    def _compute_products_availability(self):
411        self.products_availability = False
412        self.products_availability_state = 'available'
413        pickings = self.filtered(lambda picking: picking.state not in ['cancel', 'draft', 'done'] and picking.picking_type_code == 'outgoing')
414        pickings.products_availability = _('Available')
415        for picking in pickings:
416            forecast_date = max(picking.move_lines.filtered('forecast_expected_date').mapped('forecast_expected_date'), default=False)
417            if any(float_compare(move.forecast_availability, move.product_qty, precision_rounding=move.product_id.uom_id.rounding) == -1 for move in picking.move_lines):
418                picking.products_availability = _('Not Available')
419                picking.products_availability_state = 'late'
420            elif forecast_date:
421                picking.products_availability = _('Exp %s', format_date(self.env, forecast_date))
422                picking.products_availability_state = 'late' if picking.date_deadline and picking.date_deadline < forecast_date else 'expected'
423
424    @api.depends('picking_type_id.show_operations')
425    def _compute_show_operations(self):
426        for picking in self:
427            if self.env.context.get('force_detailed_view'):
428                picking.show_operations = True
429                continue
430            if picking.picking_type_id.show_operations:
431                if (picking.state == 'draft' and picking.immediate_transfer) or picking.state != 'draft':
432                    picking.show_operations = True
433                else:
434                    picking.show_operations = False
435            else:
436                picking.show_operations = False
437
438    @api.depends('move_line_ids', 'picking_type_id.use_create_lots', 'picking_type_id.use_existing_lots', 'state')
439    def _compute_show_lots_text(self):
440        group_production_lot_enabled = self.user_has_groups('stock.group_production_lot')
441        for picking in self:
442            if not picking.move_line_ids and not picking.picking_type_id.use_create_lots:
443                picking.show_lots_text = False
444            elif group_production_lot_enabled and picking.picking_type_id.use_create_lots \
445                    and not picking.picking_type_id.use_existing_lots and picking.state != 'done':
446                picking.show_lots_text = True
447            else:
448                picking.show_lots_text = False
449
450    def _compute_json_popover(self):
451        for picking in self:
452            if picking.state in ('done', 'cancel') or not picking.delay_alert_date:
453                picking.json_popover = False
454                continue
455            picking.json_popover = json.dumps({
456                'popoverTemplate': 'stock.PopoverStockRescheduling',
457                'delay_alert_date': format_datetime(self.env, picking.delay_alert_date, dt_format=False) if picking.delay_alert_date else False,
458                'late_elements': [{
459                        'id': late_move.id,
460                        'name': late_move.display_name,
461                        'model': late_move._name,
462                    } for late_move in picking.move_lines.filtered(lambda m: m.delay_alert_date).move_orig_ids._delay_alert_get_documents()
463                ]
464            })
465
466    @api.depends('move_type', 'immediate_transfer', 'move_lines.state', 'move_lines.picking_id')
467    def _compute_state(self):
468        ''' State of a picking depends on the state of its related stock.move
469        - Draft: only used for "planned pickings"
470        - Waiting: if the picking is not ready to be sent so if
471          - (a) no quantity could be reserved at all or if
472          - (b) some quantities could be reserved and the shipping policy is "deliver all at once"
473        - Waiting another move: if the picking is waiting for another move
474        - Ready: if the picking is ready to be sent so if:
475          - (a) all quantities are reserved or if
476          - (b) some quantities could be reserved and the shipping policy is "as soon as possible"
477        - Done: if the picking is done.
478        - Cancelled: if the picking is cancelled
479        '''
480        picking_moves_state_map = defaultdict(dict)
481        picking_move_lines = defaultdict(set)
482        for move in self.env['stock.move'].search([('picking_id', 'in', self.ids)]):
483            picking_id = move.picking_id
484            move_state = move.state
485            picking_moves_state_map[picking_id.id].update({
486                'any_draft': picking_moves_state_map[picking_id.id].get('any_draft', False) or move_state == 'draft',
487                'all_cancel': picking_moves_state_map[picking_id.id].get('all_cancel', True) and move_state == 'cancel',
488                'all_cancel_done': picking_moves_state_map[picking_id.id].get('all_cancel_done', True) and move_state in ('cancel', 'done'),
489            })
490            picking_move_lines[picking_id.id].add(move.id)
491        for picking in self:
492            if not picking_moves_state_map[picking.id]:
493                picking.state = 'draft'
494            elif picking_moves_state_map[picking.id]['any_draft']:
495                picking.state = 'draft'
496            elif picking_moves_state_map[picking.id]['all_cancel']:
497                picking.state = 'cancel'
498            elif picking_moves_state_map[picking.id]['all_cancel_done']:
499                picking.state = 'done'
500            else:
501                relevant_move_state = self.env['stock.move'].browse(picking_move_lines[picking.id])._get_relevant_state_among_moves()
502                if picking.immediate_transfer and relevant_move_state not in ('draft', 'cancel', 'done'):
503                    picking.state = 'assigned'
504                elif relevant_move_state == 'partially_available':
505                    picking.state = 'assigned'
506                else:
507                    picking.state = relevant_move_state
508
509    @api.depends('move_lines.state', 'move_lines.date', 'move_type')
510    def _compute_scheduled_date(self):
511        for picking in self:
512            moves_dates = picking.move_lines.filtered(lambda move: move.state not in ('done', 'cancel')).mapped('date')
513            if picking.move_type == 'direct':
514                picking.scheduled_date = min(moves_dates, default=picking.scheduled_date or fields.Datetime.now())
515            else:
516                picking.scheduled_date = max(moves_dates, default=picking.scheduled_date or fields.Datetime.now())
517
518    @api.depends('move_lines.date_deadline', 'move_type')
519    def _compute_date_deadline(self):
520        for picking in self:
521            if picking.move_type == 'direct':
522                picking.date_deadline = min(picking.move_lines.filtered('date_deadline').mapped('date_deadline'), default=False)
523            else:
524                picking.date_deadline = max(picking.move_lines.filtered('date_deadline').mapped('date_deadline'), default=False)
525
526    def _set_scheduled_date(self):
527        for picking in self:
528            if picking.state in ('done', 'cancel'):
529                raise UserError(_("You cannot change the Scheduled Date on a done or cancelled transfer."))
530            picking.move_lines.write({'date': picking.scheduled_date})
531
532    def _has_scrap_move(self):
533        for picking in self:
534            # TDE FIXME: better implementation
535            picking.has_scrap_move = bool(self.env['stock.move'].search_count([('picking_id', '=', picking.id), ('scrapped', '=', True)]))
536
537    def _compute_move_line_exist(self):
538        for picking in self:
539            picking.move_line_exist = bool(picking.move_line_ids)
540
541    def _compute_has_packages(self):
542        domain = [('picking_id', 'in', self.ids), ('result_package_id', '!=', False)]
543        cnt_by_picking = self.env['stock.move.line'].read_group(domain, ['picking_id'], ['picking_id'])
544        cnt_by_picking = {d['picking_id'][0]: d['picking_id_count'] for d in cnt_by_picking}
545        for picking in self:
546            picking.has_packages = bool(cnt_by_picking.get(picking.id, False))
547
548    @api.depends('immediate_transfer', 'state')
549    def _compute_show_check_availability(self):
550        """ According to `picking.show_check_availability`, the "check availability" button will be
551        displayed in the form view of a picking.
552        """
553        for picking in self:
554            if picking.immediate_transfer or picking.state not in ('confirmed', 'waiting', 'assigned'):
555                picking.show_check_availability = False
556                continue
557            picking.show_check_availability = any(
558                move.state in ('waiting', 'confirmed', 'partially_available') and
559                float_compare(move.product_uom_qty, 0, precision_rounding=move.product_uom.rounding)
560                for move in picking.move_lines
561            )
562
563    @api.depends('state', 'move_lines')
564    def _compute_show_mark_as_todo(self):
565        for picking in self:
566            if not picking.move_lines and not picking.package_level_ids:
567                picking.show_mark_as_todo = False
568            elif not picking.immediate_transfer and picking.state == 'draft':
569                picking.show_mark_as_todo = True
570            elif picking.state != 'draft' or not picking.id:
571                picking.show_mark_as_todo = False
572            else:
573                picking.show_mark_as_todo = True
574
575    @api.depends('state')
576    def _compute_show_validate(self):
577        for picking in self:
578            if not (picking.immediate_transfer) and picking.state == 'draft':
579                picking.show_validate = False
580            elif picking.state not in ('draft', 'waiting', 'confirmed', 'assigned'):
581                picking.show_validate = False
582            else:
583                picking.show_validate = True
584
585    @api.model
586    def _search_delay_alert_date(self, operator, value):
587        late_stock_moves = self.env['stock.move'].search([('delay_alert_date', operator, value)])
588        return [('move_lines', 'in', late_stock_moves.ids)]
589
590    @api.onchange('partner_id')
591    def onchange_partner_id(self):
592        for picking in self:
593            picking_id = isinstance(picking.id, int) and picking.id or getattr(picking, '_origin', False) and picking._origin.id
594            if picking_id:
595                moves = self.env['stock.move'].search([('picking_id', '=', picking_id)])
596                for move in moves:
597                    move.write({'partner_id': picking.partner_id.id})
598
599    @api.onchange('picking_type_id', 'partner_id')
600    def onchange_picking_type(self):
601        if self.picking_type_id and self.state == 'draft':
602            self = self.with_company(self.company_id)
603            if self.picking_type_id.default_location_src_id:
604                location_id = self.picking_type_id.default_location_src_id.id
605            elif self.partner_id:
606                location_id = self.partner_id.property_stock_supplier.id
607            else:
608                customerloc, location_id = self.env['stock.warehouse']._get_partner_locations()
609
610            if self.picking_type_id.default_location_dest_id:
611                location_dest_id = self.picking_type_id.default_location_dest_id.id
612            elif self.partner_id:
613                location_dest_id = self.partner_id.property_stock_customer.id
614            else:
615                location_dest_id, supplierloc = self.env['stock.warehouse']._get_partner_locations()
616
617            self.location_id = location_id
618            self.location_dest_id = location_dest_id
619            (self.move_lines | self.move_ids_without_package).update({
620                "picking_type_id": self.picking_type_id,
621                "company_id": self.company_id,
622            })
623
624        if self.partner_id and self.partner_id.picking_warn:
625            if self.partner_id.picking_warn == 'no-message' and self.partner_id.parent_id:
626                partner = self.partner_id.parent_id
627            elif self.partner_id.picking_warn not in ('no-message', 'block') and self.partner_id.parent_id.picking_warn == 'block':
628                partner = self.partner_id.parent_id
629            else:
630                partner = self.partner_id
631            if partner.picking_warn != 'no-message':
632                if partner.picking_warn == 'block':
633                    self.partner_id = False
634                return {'warning': {
635                    'title': ("Warning for %s") % partner.name,
636                    'message': partner.picking_warn_msg
637                }}
638
639    @api.model
640    def create(self, vals):
641        defaults = self.default_get(['name', 'picking_type_id'])
642        picking_type = self.env['stock.picking.type'].browse(vals.get('picking_type_id', defaults.get('picking_type_id')))
643        if vals.get('name', '/') == '/' and defaults.get('name', '/') == '/' and vals.get('picking_type_id', defaults.get('picking_type_id')):
644            if picking_type.sequence_id:
645                vals['name'] = picking_type.sequence_id.next_by_id()
646
647        # As the on_change in one2many list is WIP, we will overwrite the locations on the stock moves here
648        # As it is a create the format will be a list of (0, 0, dict)
649        moves = vals.get('move_lines', []) + vals.get('move_ids_without_package', [])
650        if moves and vals.get('location_id') and vals.get('location_dest_id'):
651            for move in moves:
652                if len(move) == 3 and move[0] == 0:
653                    move[2]['location_id'] = vals['location_id']
654                    move[2]['location_dest_id'] = vals['location_dest_id']
655                    # When creating a new picking, a move can have no `company_id` (create before
656                    # picking type was defined) or a different `company_id` (the picking type was
657                    # changed for an another company picking type after the move was created).
658                    # So, we define the `company_id` in one of these cases.
659                    picking_type = self.env['stock.picking.type'].browse(vals['picking_type_id'])
660                    if 'picking_type_id' not in move[2] or move[2]['picking_type_id'] != picking_type.id:
661                        move[2]['picking_type_id'] = picking_type.id
662                        move[2]['company_id'] = picking_type.company_id.id
663        # make sure to write `schedule_date` *after* the `stock.move` creation in
664        # order to get a determinist execution of `_set_scheduled_date`
665        scheduled_date = vals.pop('scheduled_date', False)
666        res = super(Picking, self).create(vals)
667        if scheduled_date:
668            res.with_context(mail_notrack=True).write({'scheduled_date': scheduled_date})
669        res._autoconfirm_picking()
670
671        # set partner as follower
672        if vals.get('partner_id'):
673            for picking in res.filtered(lambda p: p.location_id.usage == 'supplier' or p.location_dest_id.usage == 'customer'):
674                picking.message_subscribe([vals.get('partner_id')])
675        if vals.get('picking_type_id'):
676            for move in res.move_lines:
677                if not move.description_picking:
678                    move.description_picking = move.product_id.with_context(lang=move._get_lang())._get_description(move.picking_id.picking_type_id)
679
680        return res
681
682    def write(self, vals):
683        if vals.get('picking_type_id') and any(picking.state != 'draft' for picking in self):
684            raise UserError(_("Changing the operation type of this record is forbidden at this point."))
685        # set partner as a follower and unfollow old partner
686        if vals.get('partner_id'):
687            for picking in self:
688                if picking.location_id.usage == 'supplier' or picking.location_dest_id.usage == 'customer':
689                    if picking.partner_id:
690                        picking.message_unsubscribe(picking.partner_id.ids)
691                    picking.message_subscribe([vals.get('partner_id')])
692        res = super(Picking, self).write(vals)
693        if vals.get('signature'):
694            for picking in self:
695                picking._attach_sign()
696        # Change locations of moves if those of the picking change
697        after_vals = {}
698        if vals.get('location_id'):
699            after_vals['location_id'] = vals['location_id']
700        if vals.get('location_dest_id'):
701            after_vals['location_dest_id'] = vals['location_dest_id']
702        if after_vals:
703            self.mapped('move_lines').filtered(lambda move: not move.scrapped).write(after_vals)
704        if vals.get('move_lines'):
705            self._autoconfirm_picking()
706
707        return res
708
709    def unlink(self):
710        self.mapped('move_lines')._action_cancel()
711        self.with_context(prefetch_fields=False).mapped('move_lines').unlink()  # Checks if moves are not done
712        return super(Picking, self).unlink()
713
714    def action_assign_partner(self):
715        for picking in self:
716            picking.move_lines.write({'partner_id': picking.partner_id.id})
717
718    def do_print_picking(self):
719        self.write({'printed': True})
720        return self.env.ref('stock.action_report_picking').report_action(self)
721
722    def action_confirm(self):
723        self._check_company()
724        self.mapped('package_level_ids').filtered(lambda pl: pl.state == 'draft' and not pl.move_ids)._generate_moves()
725        # call `_action_confirm` on every draft move
726        self.mapped('move_lines')\
727            .filtered(lambda move: move.state == 'draft')\
728            ._action_confirm()
729
730        # run scheduler for moves forecasted to not have enough in stock
731        self.mapped('move_lines').filtered(lambda move: move.state not in ('draft', 'cancel', 'done'))._trigger_scheduler()
732        return True
733
734    def action_assign(self):
735        """ Check availability of picking moves.
736        This has the effect of changing the state and reserve quants on available moves, and may
737        also impact the state of the picking as it is computed based on move's states.
738        @return: True
739        """
740        self.filtered(lambda picking: picking.state == 'draft').action_confirm()
741        moves = self.mapped('move_lines').filtered(lambda move: move.state not in ('draft', 'cancel', 'done'))
742        if not moves:
743            raise UserError(_('Nothing to check the availability for.'))
744        # If a package level is done when confirmed its location can be different than where it will be reserved.
745        # So we remove the move lines created when confirmed to set quantity done to the new reserved ones.
746        package_level_done = self.mapped('package_level_ids').filtered(lambda pl: pl.is_done and pl.state == 'confirmed')
747        package_level_done.write({'is_done': False})
748        moves._action_assign()
749        package_level_done.write({'is_done': True})
750
751        return True
752
753    def action_cancel(self):
754        self.mapped('move_lines')._action_cancel()
755        self.write({'is_locked': True})
756        return True
757
758    def _action_done(self):
759        """Call `_action_done` on the `stock.move` of the `stock.picking` in `self`.
760        This method makes sure every `stock.move.line` is linked to a `stock.move` by either
761        linking them to an existing one or a newly created one.
762
763        If the context key `cancel_backorder` is present, backorders won't be created.
764
765        :return: True
766        :rtype: bool
767        """
768        self._check_company()
769
770        todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'waiting', 'partially_available', 'assigned', 'confirmed'])
771        for picking in self:
772            if picking.owner_id:
773                picking.move_lines.write({'restrict_partner_id': picking.owner_id.id})
774                picking.move_line_ids.write({'owner_id': picking.owner_id.id})
775        todo_moves._action_done(cancel_backorder=self.env.context.get('cancel_backorder'))
776        self.write({'date_done': fields.Datetime.now(), 'priority': '0'})
777
778        # if incoming moves make other confirmed/partially_available moves available, assign them
779        done_incoming_moves = self.filtered(lambda p: p.picking_type_id.code == 'incoming').move_lines.filtered(lambda m: m.state == 'done')
780        done_incoming_moves._trigger_assign()
781
782        self._send_confirmation_email()
783        return True
784
785    def _send_confirmation_email(self):
786        for stock_pick in self.filtered(lambda p: p.company_id.stock_move_email_validation and p.picking_type_id.code == 'outgoing'):
787            delivery_template_id = stock_pick.company_id.stock_mail_confirmation_template_id.id
788            stock_pick.with_context(force_send=True).message_post_with_template(delivery_template_id, email_layout_xmlid='mail.mail_notification_light')
789
790    @api.depends('state', 'move_lines', 'move_lines.state', 'move_lines.package_level_id', 'move_lines.move_line_ids.package_level_id')
791    def _compute_move_without_package(self):
792        for picking in self:
793            picking.move_ids_without_package = picking._get_move_ids_without_package()
794
795    def _set_move_without_package(self):
796        new_mwp = self[0].move_ids_without_package
797        for picking in self:
798            old_mwp = picking._get_move_ids_without_package()
799            picking.move_lines = (picking.move_lines - old_mwp) | new_mwp
800            moves_to_unlink = old_mwp - new_mwp
801            if moves_to_unlink:
802                moves_to_unlink.unlink()
803
804    def _get_move_ids_without_package(self):
805        self.ensure_one()
806        move_ids_without_package = self.env['stock.move']
807        if not self.picking_type_entire_packs:
808            move_ids_without_package = self.move_lines
809        else:
810            for move in self.move_lines:
811                if not move.package_level_id:
812                    if move.state == 'assigned' and move.picking_id and not move.picking_id.immediate_transfer or move.state == 'done':
813                        if any(not ml.package_level_id for ml in move.move_line_ids):
814                            move_ids_without_package |= move
815                    else:
816                        move_ids_without_package |= move
817        return move_ids_without_package.filtered(lambda move: not move.scrap_ids)
818
819    def _check_move_lines_map_quant_package(self, package):
820        """ This method checks that all product of the package (quant) are well present in the move_line_ids of the picking. """
821        all_in = True
822        pack_move_lines = self.move_line_ids.filtered(lambda ml: ml.package_id == package)
823        keys = ['product_id', 'lot_id']
824        keys_ids = ["{}.id".format(fname) for fname in keys]
825        precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
826
827        grouped_quants = {}
828        for k, g in groupby(sorted(package.quant_ids, key=attrgetter(*keys_ids)), key=itemgetter(*keys)):
829            grouped_quants[k] = sum(self.env['stock.quant'].concat(*list(g)).mapped('quantity'))
830
831        grouped_ops = {}
832        for k, g in groupby(sorted(pack_move_lines, key=attrgetter(*keys_ids)), key=itemgetter(*keys)):
833            grouped_ops[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty'))
834        if any(not float_is_zero(grouped_quants.get(key, 0) - grouped_ops.get(key, 0), precision_digits=precision_digits) for key in grouped_quants) \
835                or any(not float_is_zero(grouped_ops.get(key, 0) - grouped_quants.get(key, 0), precision_digits=precision_digits) for key in grouped_ops):
836            all_in = False
837        return all_in
838
839    def _get_entire_pack_location_dest(self, move_line_ids):
840        location_dest_ids = move_line_ids.mapped('location_dest_id')
841        if len(location_dest_ids) > 1:
842            return False
843        return location_dest_ids.id
844
845    def _check_entire_pack(self):
846        """ This function check if entire packs are moved in the picking"""
847        for picking in self:
848            origin_packages = picking.move_line_ids.mapped("package_id")
849            for pack in origin_packages:
850                if picking._check_move_lines_map_quant_package(pack):
851                    package_level_ids = picking.package_level_ids.filtered(lambda pl: pl.package_id == pack)
852                    move_lines_to_pack = picking.move_line_ids.filtered(lambda ml: ml.package_id == pack and not ml.result_package_id)
853                    if not package_level_ids:
854                        self.env['stock.package_level'].create({
855                            'picking_id': picking.id,
856                            'package_id': pack.id,
857                            'location_id': pack.location_id.id,
858                            'location_dest_id': self._get_entire_pack_location_dest(move_lines_to_pack) or picking.location_dest_id.id,
859                            'move_line_ids': [(6, 0, move_lines_to_pack.ids)],
860                            'company_id': picking.company_id.id,
861                        })
862                        # TODO: in master, move package field in `stock` and clean code.
863                        if pack._allowed_to_move_between_transfers():
864                            move_lines_to_pack.write({
865                                'result_package_id': pack.id,
866                            })
867                    else:
868                        move_lines_in_package_level = move_lines_to_pack.filtered(lambda ml: ml.move_id.package_level_id)
869                        move_lines_without_package_level = move_lines_to_pack - move_lines_in_package_level
870                        for ml in move_lines_in_package_level:
871                            ml.write({
872                                'result_package_id': pack.id,
873                                'package_level_id': ml.move_id.package_level_id.id,
874                            })
875                        move_lines_without_package_level.write({
876                            'result_package_id': pack.id,
877                            'package_level_id': package_level_ids[0].id,
878                        })
879                        for pl in package_level_ids:
880                            pl.location_dest_id = self._get_entire_pack_location_dest(pl.move_line_ids) or picking.location_dest_id.id
881
882    def do_unreserve(self):
883        self.move_lines._do_unreserve()
884        self.package_level_ids.filtered(lambda p: not p.move_ids).unlink()
885
886    def button_validate(self):
887        # Clean-up the context key at validation to avoid forcing the creation of immediate
888        # transfers.
889        ctx = dict(self.env.context)
890        ctx.pop('default_immediate_transfer', None)
891        self = self.with_context(ctx)
892
893        # Sanity checks.
894        pickings_without_moves = self.browse()
895        pickings_without_quantities = self.browse()
896        pickings_without_lots = self.browse()
897        products_without_lots = self.env['product.product']
898        for picking in self:
899            if not picking.move_lines and not picking.move_line_ids:
900                pickings_without_moves |= picking
901
902            picking.message_subscribe([self.env.user.partner_id.id])
903            picking_type = picking.picking_type_id
904            precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
905            no_quantities_done = all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in picking.move_line_ids.filtered(lambda m: m.state not in ('done', 'cancel')))
906            no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in picking.move_line_ids)
907            if no_reserved_quantities and no_quantities_done:
908                pickings_without_quantities |= picking
909
910            if picking_type.use_create_lots or picking_type.use_existing_lots:
911                lines_to_check = picking.move_line_ids
912                if not no_quantities_done:
913                    lines_to_check = lines_to_check.filtered(lambda line: float_compare(line.qty_done, 0, precision_rounding=line.product_uom_id.rounding))
914                for line in lines_to_check:
915                    product = line.product_id
916                    if product and product.tracking != 'none':
917                        if not line.lot_name and not line.lot_id:
918                            pickings_without_lots |= picking
919                            products_without_lots |= product
920
921        if not self._should_show_transfers():
922            if pickings_without_moves:
923                raise UserError(_('Please add some items to move.'))
924            if pickings_without_quantities:
925                raise UserError(self._get_without_quantities_error_message())
926            if pickings_without_lots:
927                raise UserError(_('You need to supply a Lot/Serial number for products %s.') % ', '.join(products_without_lots.mapped('display_name')))
928        else:
929            message = ""
930            if pickings_without_moves:
931                message += _('Transfers %s: Please add some items to move.') % ', '.join(pickings_without_moves.mapped('name'))
932            if pickings_without_quantities:
933                message += _('\n\nTransfers %s: You cannot validate these transfers if no quantities are reserved nor done. To force these transfers, switch in edit more and encode the done quantities.') % ', '.join(pickings_without_quantities.mapped('name'))
934            if pickings_without_lots:
935                message += _('\n\nTransfers %s: You need to supply a Lot/Serial number for products %s.') % (', '.join(pickings_without_lots.mapped('name')), ', '.join(products_without_lots.mapped('display_name')))
936            if message:
937                raise UserError(message.lstrip())
938
939        # Run the pre-validation wizards. Processing a pre-validation wizard should work on the
940        # moves and/or the context and never call `_action_done`.
941        if not self.env.context.get('button_validate_picking_ids'):
942            self = self.with_context(button_validate_picking_ids=self.ids)
943        res = self._pre_action_done_hook()
944        if res is not True:
945            return res
946
947        # Call `_action_done`.
948        if self.env.context.get('picking_ids_not_to_backorder'):
949            pickings_not_to_backorder = self.browse(self.env.context['picking_ids_not_to_backorder'])
950            pickings_to_backorder = self - pickings_not_to_backorder
951        else:
952            pickings_not_to_backorder = self.env['stock.picking']
953            pickings_to_backorder = self
954        pickings_not_to_backorder.with_context(cancel_backorder=True)._action_done()
955        pickings_to_backorder.with_context(cancel_backorder=False)._action_done()
956        return True
957
958    def _pre_action_done_hook(self):
959        if not self.env.context.get('skip_immediate'):
960            pickings_to_immediate = self._check_immediate()
961            if pickings_to_immediate:
962                return pickings_to_immediate._action_generate_immediate_wizard(show_transfers=self._should_show_transfers())
963
964        if not self.env.context.get('skip_backorder'):
965            pickings_to_backorder = self._check_backorder()
966            if pickings_to_backorder:
967                return pickings_to_backorder._action_generate_backorder_wizard(show_transfers=self._should_show_transfers())
968        return True
969
970    def _should_show_transfers(self):
971        """Whether the different transfers should be displayed on the pre action done wizards."""
972        return len(self) > 1
973
974    def _get_without_quantities_error_message(self):
975        """ Returns the error message raised in validation if no quantities are reserved or done.
976        The purpose of this method is to be overridden in case we want to adapt this message.
977
978        :return: Translated error message
979        :rtype: str
980        """
981        return _(
982            'You cannot validate a transfer if no quantities are reserved nor done. '
983            'To force the transfer, switch in edit mode and encode the done quantities.'
984        )
985
986    def _action_generate_backorder_wizard(self, show_transfers=False):
987        view = self.env.ref('stock.view_backorder_confirmation')
988        return {
989            'name': _('Create Backorder?'),
990            'type': 'ir.actions.act_window',
991            'view_mode': 'form',
992            'res_model': 'stock.backorder.confirmation',
993            'views': [(view.id, 'form')],
994            'view_id': view.id,
995            'target': 'new',
996            'context': dict(self.env.context, default_show_transfers=show_transfers, default_pick_ids=[(4, p.id) for p in self]),
997        }
998
999    def _action_generate_immediate_wizard(self, show_transfers=False):
1000        view = self.env.ref('stock.view_immediate_transfer')
1001        return {
1002            'name': _('Immediate Transfer?'),
1003            'type': 'ir.actions.act_window',
1004            'view_mode': 'form',
1005            'res_model': 'stock.immediate.transfer',
1006            'views': [(view.id, 'form')],
1007            'view_id': view.id,
1008            'target': 'new',
1009            'context': dict(self.env.context, default_show_transfers=show_transfers, default_pick_ids=[(4, p.id) for p in self]),
1010        }
1011
1012    def action_toggle_is_locked(self):
1013        self.ensure_one()
1014        self.is_locked = not self.is_locked
1015        return True
1016
1017    def _check_backorder(self):
1018        prec = self.env["decimal.precision"].precision_get("Product Unit of Measure")
1019        backorder_pickings = self.browse()
1020        for picking in self:
1021            quantity_todo = {}
1022            quantity_done = {}
1023            for move in picking.mapped('move_lines').filtered(lambda m: m.state != "cancel"):
1024                quantity_todo.setdefault(move.product_id.id, 0)
1025                quantity_done.setdefault(move.product_id.id, 0)
1026                quantity_todo[move.product_id.id] += move.product_uom._compute_quantity(move.product_uom_qty, move.product_id.uom_id, rounding_method='HALF-UP')
1027                quantity_done[move.product_id.id] += move.product_uom._compute_quantity(move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP')
1028            # FIXME: the next block doesn't seem nor should be used.
1029            for ops in picking.mapped('move_line_ids').filtered(lambda x: x.package_id and not x.product_id and not x.move_id):
1030                for quant in ops.package_id.quant_ids:
1031                    quantity_done.setdefault(quant.product_id.id, 0)
1032                    quantity_done[quant.product_id.id] += quant.qty
1033            for pack in picking.mapped('move_line_ids').filtered(lambda x: x.product_id and not x.move_id):
1034                quantity_done.setdefault(pack.product_id.id, 0)
1035                quantity_done[pack.product_id.id] += pack.product_uom_id._compute_quantity(pack.qty_done, pack.product_id.uom_id)
1036            if any(
1037                float_compare(quantity_done[x], quantity_todo.get(x, 0), precision_digits=prec,) == -1
1038                for x in quantity_done
1039            ):
1040                backorder_pickings |= picking
1041        return backorder_pickings
1042
1043    def _check_immediate(self):
1044        immediate_pickings = self.browse()
1045        precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
1046        for picking in self:
1047            if all(float_is_zero(move_line.qty_done, precision_digits=precision_digits) for move_line in picking.move_line_ids.filtered(lambda m: m.state not in ('done', 'cancel'))):
1048                immediate_pickings |= picking
1049        return immediate_pickings
1050
1051    def _autoconfirm_picking(self):
1052        """ Automatically run `action_confirm` on `self` if the picking is an immediate transfer or
1053        if the picking is a planned transfer and one of its move was added after the initial
1054        call to `action_confirm`. Note that `action_confirm` will only work on draft moves.
1055        """
1056        # Clean-up the context key to avoid forcing the creation of immediate transfers.
1057        ctx = dict(self.env.context)
1058        ctx.pop('default_immediate_transfer', None)
1059        self = self.with_context(ctx)
1060        for picking in self:
1061            if picking.state in ('done', 'cancel'):
1062                continue
1063            if not picking.move_lines and not picking.package_level_ids:
1064                continue
1065            if picking.immediate_transfer or any(move.additional for move in picking.move_lines):
1066                picking.action_confirm()
1067                # Make sure the reservation is bypassed in immediate transfer mode.
1068                if picking.immediate_transfer:
1069                    picking.move_lines.write({'state': 'assigned'})
1070
1071    def _create_backorder(self):
1072        """ This method is called when the user chose to create a backorder. It will create a new
1073        picking, the backorder, and move the stock.moves that are not `done` or `cancel` into it.
1074        """
1075        backorders = self.env['stock.picking']
1076        for picking in self:
1077            moves_to_backorder = picking.move_lines.filtered(lambda x: x.state not in ('done', 'cancel'))
1078            if moves_to_backorder:
1079                backorder_picking = picking.copy({
1080                    'name': '/',
1081                    'move_lines': [],
1082                    'move_line_ids': [],
1083                    'backorder_id': picking.id
1084                })
1085                picking.message_post(
1086                    body=_('The backorder <a href=# data-oe-model=stock.picking data-oe-id=%d>%s</a> has been created.') % (
1087                        backorder_picking.id, backorder_picking.name))
1088                moves_to_backorder.write({'picking_id': backorder_picking.id})
1089                moves_to_backorder.mapped('package_level_id').write({'picking_id':backorder_picking.id})
1090                moves_to_backorder.mapped('move_line_ids').write({'picking_id': backorder_picking.id})
1091                backorders |= backorder_picking
1092        return backorders
1093
1094    def _log_activity_get_documents(self, orig_obj_changes, stream_field, stream, sorted_method=False, groupby_method=False):
1095        """ Generic method to log activity. To use with
1096        _log_activity method. It either log on uppermost
1097        ongoing documents or following documents. This method
1098        find all the documents and responsible for which a note
1099        has to be log. It also generate a rendering_context in
1100        order to render a specific note by documents containing
1101        only the information relative to the document it. For example
1102        we don't want to notify a picking on move that it doesn't
1103        contain.
1104
1105        :param orig_obj_changes dict: contain a record as key and the
1106        change on this record as value.
1107        eg: {'move_id': (new product_uom_qty, old product_uom_qty)}
1108        :param stream_field string: It has to be a field of the
1109        records that are register in the key of 'orig_obj_changes'
1110        eg: 'move_dest_ids' if we use move as record (previous example)
1111            - 'UP' if we want to log on the upper most ongoing
1112            documents.
1113            - 'DOWN' if we want to log on following documents.
1114        :param sorted_method method, groupby_method: Only need when
1115        stream is 'DOWN', it should sort/group by tuple(object on
1116        which the activity is log, the responsible for this object)
1117        """
1118        if self.env.context.get('skip_activity'):
1119            return {}
1120        move_to_orig_object_rel = {co: ooc for ooc in orig_obj_changes.keys() for co in ooc[stream_field]}
1121        origin_objects = self.env[list(orig_obj_changes.keys())[0]._name].concat(*list(orig_obj_changes.keys()))
1122        # The purpose here is to group each destination object by
1123        # (document to log, responsible) no matter the stream direction.
1124        # example:
1125        # {'(delivery_picking_1, admin)': stock.move(1, 2)
1126        #  '(delivery_picking_2, admin)': stock.move(3)}
1127        visited_documents = {}
1128        if stream == 'DOWN':
1129            if sorted_method and groupby_method:
1130                grouped_moves = groupby(sorted(origin_objects.mapped(stream_field), key=sorted_method), key=groupby_method)
1131            else:
1132                raise UserError(_('You have to define a groupby and sorted method and pass them as arguments.'))
1133        elif stream == 'UP':
1134            # When using upstream document it is required to define
1135            # _get_upstream_documents_and_responsibles on
1136            # destination objects in order to ascend documents.
1137            grouped_moves = {}
1138            for visited_move in origin_objects.mapped(stream_field):
1139                for document, responsible, visited in visited_move._get_upstream_documents_and_responsibles(self.env[visited_move._name]):
1140                    if grouped_moves.get((document, responsible)):
1141                        grouped_moves[(document, responsible)] |= visited_move
1142                        visited_documents[(document, responsible)] |= visited
1143                    else:
1144                        grouped_moves[(document, responsible)] = visited_move
1145                        visited_documents[(document, responsible)] = visited
1146            grouped_moves = grouped_moves.items()
1147        else:
1148            raise UserError(_('Unknown stream.'))
1149
1150        documents = {}
1151        for (parent, responsible), moves in grouped_moves:
1152            if not parent:
1153                continue
1154            moves = list(moves)
1155            moves = self.env[moves[0]._name].concat(*moves)
1156            # Get the note
1157            rendering_context = {move: (orig_object, orig_obj_changes[orig_object]) for move in moves for orig_object in move_to_orig_object_rel[move]}
1158            if visited_documents:
1159                documents[(parent, responsible)] = rendering_context, visited_documents.values()
1160            else:
1161                documents[(parent, responsible)] = rendering_context
1162        return documents
1163
1164    def _log_activity(self, render_method, documents):
1165        """ Log a note for each documents, responsible pair in
1166        documents passed as argument. The render_method is then
1167        call in order to use a template and render it with a
1168        rendering_context.
1169
1170        :param documents dict: A tuple (document, responsible) as key.
1171        An activity will be log by key. A rendering_context as value.
1172        If used with _log_activity_get_documents. In 'DOWN' stream
1173        cases the rendering_context will be a dict with format:
1174        {'stream_object': ('orig_object', new_qty, old_qty)}
1175        'UP' stream will add all the documents browsed in order to
1176        get the final/upstream document present in the key.
1177        :param render_method method: a static function that will generate
1178        the html note to log on the activity. The render_method should
1179        use the args:
1180            - rendering_context dict: value of the documents argument
1181        the render_method should return a string with an html format
1182        :param stream string:
1183        """
1184        for (parent, responsible), rendering_context in documents.items():
1185            note = render_method(rendering_context)
1186            parent.activity_schedule(
1187                'mail.mail_activity_data_warning',
1188                date.today(),
1189                note=note,
1190                user_id=responsible.id or SUPERUSER_ID
1191            )
1192
1193    def _log_less_quantities_than_expected(self, moves):
1194        """ Log an activity on picking that follow moves. The note
1195        contains the moves changes and all the impacted picking.
1196
1197        :param dict moves: a dict with a move as key and tuple with
1198        new and old quantity as value. eg: {move_1 : (4, 5)}
1199        """
1200        def _keys_in_sorted(move):
1201            """ sort by picking and the responsible for the product the
1202            move.
1203            """
1204            return (move.picking_id.id, move.product_id.responsible_id.id)
1205
1206        def _keys_in_groupby(move):
1207            """ group by picking and the responsible for the product the
1208            move.
1209            """
1210            return (move.picking_id, move.product_id.responsible_id)
1211
1212        def _render_note_exception_quantity(rendering_context):
1213            """ :param rendering_context:
1214            {'move_dest': (move_orig, (new_qty, old_qty))}
1215            """
1216            origin_moves = self.env['stock.move'].browse([move.id for move_orig in rendering_context.values() for move in move_orig[0]])
1217            origin_picking = origin_moves.mapped('picking_id')
1218            move_dest_ids = self.env['stock.move'].concat(*rendering_context.keys())
1219            impacted_pickings = origin_picking._get_impacted_pickings(move_dest_ids) - move_dest_ids.mapped('picking_id')
1220            values = {
1221                'origin_picking': origin_picking,
1222                'moves_information': rendering_context.values(),
1223                'impacted_pickings': impacted_pickings,
1224            }
1225            return self.env.ref('stock.exception_on_picking')._render(values=values)
1226
1227        documents = self._log_activity_get_documents(moves, 'move_dest_ids', 'DOWN', _keys_in_sorted, _keys_in_groupby)
1228        documents = self._less_quantities_than_expected_add_documents(moves, documents)
1229        self._log_activity(_render_note_exception_quantity, documents)
1230
1231    def _less_quantities_than_expected_add_documents(self, moves, documents):
1232        return documents
1233
1234    def _get_impacted_pickings(self, moves):
1235        """ This function is used in _log_less_quantities_than_expected
1236        the purpose is to notify a user with all the pickings that are
1237        impacted by an action on a chained move.
1238        param: 'moves' contain moves that belong to a common picking.
1239        return: all the pickings that contain a destination moves
1240        (direct and indirect) from the moves given as arguments.
1241        """
1242
1243        def _explore(impacted_pickings, explored_moves, moves_to_explore):
1244            for move in moves_to_explore:
1245                if move not in explored_moves:
1246                    impacted_pickings |= move.picking_id
1247                    explored_moves |= move
1248                    moves_to_explore |= move.move_dest_ids
1249            moves_to_explore = moves_to_explore - explored_moves
1250            if moves_to_explore:
1251                return _explore(impacted_pickings, explored_moves, moves_to_explore)
1252            else:
1253                return impacted_pickings
1254
1255        return _explore(self.env['stock.picking'], self.env['stock.move'], moves)
1256
1257    def _pre_put_in_pack_hook(self, move_line_ids):
1258        return self._check_destinations(move_line_ids)
1259
1260    def _check_destinations(self, move_line_ids):
1261        if len(move_line_ids.mapped('location_dest_id')) > 1:
1262            view_id = self.env.ref('stock.stock_package_destination_form_view').id
1263            wiz = self.env['stock.package.destination'].create({
1264                'picking_id': self.id,
1265                'location_dest_id': move_line_ids[0].location_dest_id.id,
1266            })
1267            return {
1268                'name': _('Choose destination location'),
1269                'view_mode': 'form',
1270                'res_model': 'stock.package.destination',
1271                'view_id': view_id,
1272                'views': [(view_id, 'form')],
1273                'type': 'ir.actions.act_window',
1274                'res_id': wiz.id,
1275                'target': 'new'
1276            }
1277        else:
1278            return {}
1279
1280    def _put_in_pack(self, move_line_ids, create_package_level=True):
1281        package = False
1282        for pick in self:
1283            move_lines_to_pack = self.env['stock.move.line']
1284            package = self.env['stock.quant.package'].create({})
1285
1286            precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
1287            if float_is_zero(move_line_ids[0].qty_done, precision_digits=precision_digits):
1288                for line in move_line_ids:
1289                    line.qty_done = line.product_uom_qty
1290
1291            for ml in move_line_ids:
1292                if float_compare(ml.qty_done, ml.product_uom_qty,
1293                                 precision_rounding=ml.product_uom_id.rounding) >= 0:
1294                    move_lines_to_pack |= ml
1295                else:
1296                    quantity_left_todo = float_round(
1297                        ml.product_uom_qty - ml.qty_done,
1298                        precision_rounding=ml.product_uom_id.rounding,
1299                        rounding_method='UP')
1300                    done_to_keep = ml.qty_done
1301                    new_move_line = ml.copy(
1302                        default={'product_uom_qty': 0, 'qty_done': ml.qty_done})
1303                    vals = {'product_uom_qty': quantity_left_todo, 'qty_done': 0.0}
1304                    if pick.picking_type_id.code == 'incoming':
1305                        if ml.lot_id:
1306                            vals['lot_id'] = False
1307                        if ml.lot_name:
1308                            vals['lot_name'] = False
1309                    ml.write(vals)
1310                    new_move_line.write({'product_uom_qty': done_to_keep})
1311                    move_lines_to_pack |= new_move_line
1312            if create_package_level:
1313                package_level = self.env['stock.package_level'].create({
1314                    'package_id': package.id,
1315                    'picking_id': pick.id,
1316                    'location_id': False,
1317                    'location_dest_id': move_line_ids.mapped('location_dest_id').id,
1318                    'move_line_ids': [(6, 0, move_lines_to_pack.ids)],
1319                    'company_id': pick.company_id.id,
1320                })
1321            move_lines_to_pack.write({
1322                'result_package_id': package.id,
1323            })
1324        return package
1325
1326    def action_put_in_pack(self):
1327        self.ensure_one()
1328        if self.state not in ('done', 'cancel'):
1329            picking_move_lines = self.move_line_ids
1330            if (
1331                not self.picking_type_id.show_reserved
1332                and not self.immediate_transfer
1333                and not self.env.context.get('barcode_view')
1334            ):
1335                picking_move_lines = self.move_line_nosuggest_ids
1336
1337            move_line_ids = picking_move_lines.filtered(lambda ml:
1338                float_compare(ml.qty_done, 0.0, precision_rounding=ml.product_uom_id.rounding) > 0
1339                and not ml.result_package_id
1340            )
1341            if not move_line_ids:
1342                move_line_ids = picking_move_lines.filtered(lambda ml: float_compare(ml.product_uom_qty, 0.0,
1343                                     precision_rounding=ml.product_uom_id.rounding) > 0 and float_compare(ml.qty_done, 0.0,
1344                                     precision_rounding=ml.product_uom_id.rounding) == 0)
1345            if move_line_ids:
1346                res = self._pre_put_in_pack_hook(move_line_ids)
1347                if not res:
1348                    res = self._put_in_pack(move_line_ids)
1349                return res
1350            else:
1351                raise UserError(_("Please add 'Done' quantities to the picking to create a new pack."))
1352
1353    def button_scrap(self):
1354        self.ensure_one()
1355        view = self.env.ref('stock.stock_scrap_form_view2')
1356        products = self.env['product.product']
1357        for move in self.move_lines:
1358            if move.state not in ('draft', 'cancel') and move.product_id.type in ('product', 'consu'):
1359                products |= move.product_id
1360        return {
1361            'name': _('Scrap'),
1362            'view_mode': 'form',
1363            'res_model': 'stock.scrap',
1364            'view_id': view.id,
1365            'views': [(view.id, 'form')],
1366            'type': 'ir.actions.act_window',
1367            'context': {'default_picking_id': self.id, 'product_ids': products.ids, 'default_company_id': self.company_id.id},
1368            'target': 'new',
1369        }
1370
1371    def action_see_move_scrap(self):
1372        self.ensure_one()
1373        action = self.env["ir.actions.actions"]._for_xml_id("stock.action_stock_scrap")
1374        scraps = self.env['stock.scrap'].search([('picking_id', '=', self.id)])
1375        action['domain'] = [('id', 'in', scraps.ids)]
1376        action['context'] = dict(self._context, create=False)
1377        return action
1378
1379    def action_see_packages(self):
1380        self.ensure_one()
1381        action = self.env["ir.actions.actions"]._for_xml_id("stock.action_package_view")
1382        packages = self.move_line_ids.mapped('result_package_id')
1383        action['domain'] = [('id', 'in', packages.ids)]
1384        action['context'] = {'picking_id': self.id}
1385        return action
1386
1387    def action_picking_move_tree(self):
1388        action = self.env["ir.actions.actions"]._for_xml_id("stock.stock_move_action")
1389        action['views'] = [
1390            (self.env.ref('stock.view_picking_move_tree').id, 'tree'),
1391        ]
1392        action['context'] = self.env.context
1393        action['domain'] = [('picking_id', 'in', self.ids)]
1394        return action
1395
1396    def _attach_sign(self):
1397        """ Render the delivery report in pdf and attach it to the picking in `self`. """
1398        self.ensure_one()
1399        report = self.env.ref('stock.action_report_delivery')._render_qweb_pdf(self.id)
1400        filename = "%s_signed_delivery_slip" % self.name
1401        if self.partner_id:
1402            message = _('Order signed by %s') % (self.partner_id.name)
1403        else:
1404            message = _('Order signed')
1405        self.message_post(
1406            attachments=[('%s.pdf' % filename, report[0])],
1407            body=message,
1408        )
1409        return True
1410