[IMP] mrp, product, {purchase_, sale_}stock{_picking_batch}: add reception report

New reception report added for non-outgoing transfers. This is intended
to support easier stock allocations by allowing dynamic MTO
assignment/unassignment, e.g. if we have an incoming transfer with
products we want to assign to an existing outgoing transfer, then we can
use the report to create a MTO link to the specific outgoing moves. To
support this assignment flow, the report also allows printing of labels
to place on the product so stock workers know which transfer/MO the
product has been assigned to.

Expected use case is when a product is purchased to fulfil a sale.
Demo data has been added so reception report can be immediately
seen/used for this flow.

Implementation Notes:
- Report has been made flexible to work with batch transfers.
- Only done moves can be assigned to moves that already have quants
  reserved (prevents undesired behavior + this makes sense logically)
- Confirmed (+ Done and everything inbetween) moves can be assigned to
  any confirmed moves that are not already assigned.
- Report does not affect quant reservation/unreservation at all. It
  works only with linking moves (i.e. move_orig_id/move_dest_id) so
  move/transfer linkage traceability is stored within db (i.e. this
  isn't possible with quants)

Limitations: To keep code simple for now, this assignment flow will
  break in certain cases:
1. Done amount of an assigned move is less than the Demand amount
   (linked move will not autoreserve correctly when assigned move is
   validated, same issue already occurs in multi-step transfer).
2. If a linked move is unreserved after its assigned move is
   validated, then another move can reserve its quants and its
   assignment link will not be accurate.
3. Already reserved SNs + assign move, may lead to mismatching SNs
   between report and what's actually reserved.
4. Changing a linked move's Demand amount after a move is assigned to
   it.
5. Potential moves to assign to are only checked for being in same
   warehouse, not in a matching source location to destination location.
   User is expected to make this match on their own.

Task: 2500844
ENT PR: odoo/enterprise#18268
Upgrade PR: odoo/upgrade#2731

closes odoo/odoo#70669

Signed-off-by: Arnold Moyaux <amoyaux@users.noreply.github.com>
This commit is contained in:
Tiffany Chang (tic)
2021-08-17 11:42:23 +00:00
parent 6be0f8872f
commit 53cc41016f
27 changed files with 1275 additions and 1 deletions
+2
View File
@@ -43,6 +43,7 @@
'report/mrp_report_bom_structure.xml',
'report/mrp_production_templates.xml',
'report/report_stock_forecasted.xml',
'report/report_stock_reception.xml',
'report/report_stock_rule.xml',
'report/mrp_zebra_production_templates.xml',
],
@@ -68,6 +69,7 @@
'mrp/static/src/js/mrp_document_kanban_view.js',
'mrp/static/src/js/mrp_should_consume.js',
'mrp/static/src/js/mrp_field_one2many_with_copy.js',
'mrp/static/src/js/report_stock_reception.js',
],
'web.assets_common': [
'mrp/static/src/scss/mrp_bom_report.scss',
+1
View File
@@ -3,4 +3,5 @@
from . import mrp_report_bom_structure
from . import report_stock_forecasted
from . import report_stock_reception
from . import report_stock_rule
@@ -40,5 +40,15 @@
<field name="binding_model_id" ref="model_mrp_production"/>
<field name="binding_type">report</field>
</record>
<record id="label_production_order" model="ir.actions.report">
<field name="name">Order Label</field>
<field name="model">mrp.production</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">mrp.report_reception_report_label_mrp</field>
<field name="report_file">mrp.report_reception_report_label_mrp</field>
<field name="paperformat_id" ref="product.paperformat_label_sheet_dymo"/>
<field name="binding_model_id" ref="model_mrp_production"/>
<field name="binding_type">report</field>
</record>
</data>
</odoo>
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.tools import format_date
class ReceptionReport(models.AbstractModel):
_inherit = 'report.stock.report_reception'
def _get_formatted_scheduled_date(self, source):
if source._name == 'mrp.production':
return format_date(self.env, source.date_planned_start)
return super()._get_formatted_scheduled_date(source)
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Reception Report Labels -->
<template id="report_reception_report_label_mrp">
<t t-if="quantity" t-set="qtys" t-value="[int(q) for q in quantity.split(',')]"/>
<t t-else="" t-set="qtys" t-value="[1 for q in range(len(docs))]"/>
<t t-call="web.basic_layout">
<div class="page">
<t t-foreach="range(len(docs))" t-as="index">
<t t-set="mo" t-value="docs[index]"/>
<t t-set="qty" t-value="qtys[index]"/>
<t t-foreach="range(qty)" t-as="j">
<div class="o_label_page o_label_dymo">
<div t-esc="mo.name"/>
<div class="font-weight-bold" t-esc="mo.product_id.display_name"/>
</div>
</t>
</t>
</div>
</t>
</template>
</odoo>
@@ -0,0 +1,16 @@
/** @odoo-module **/
import ReceptionReport from '@stock/js/report_stock_reception';
ReceptionReport.include({
/**
* @override
*/
_onClickPrintLabel: function (ev) {
return this._super(ev).then(() => {
return this._printLabel(ev, 'mrp.report_reception_report_label_mrp', 'mrp.production');
});
},
});
+9
View File
@@ -782,6 +782,15 @@
<field name="currency_id" ref="base.USD"/>
</record>
<record id="product_supplierinfo_26" model="product.supplierinfo">
<field name="product_tmpl_id" ref="product_product_5_product_template"/>
<field name="name" ref="base.res_partner_10"/>
<field name="delay">1</field>
<field name="min_qty">0</field>
<field name="price">145</field>
<field name="currency_id" ref="base.USD"/>
</record>
<record forcecreate="True" id="property_product_pricelist_demo" model="ir.property">
<field name="name">property_product_pricelist</field>
<field name="fields_id" search="[('model','=','res.partner'),('name','=','property_product_pricelist')]"/>
+15
View File
@@ -63,5 +63,20 @@
<field name="report_name">product.report_pricelist</field>
<field name="report_file">product.report_pricelist</field>
</record>
<record id="paperformat_label_sheet_dymo" model="report.paperformat">
<field name="name">Dymo Label Sheet</field>
<field name="default" eval="True" />
<field name="format">custom</field>
<field name="page_height">57</field>
<field name="page_width">32</field>
<field name="orientation">Landscape</field>
<field name="margin_top">0</field>
<field name="margin_bottom">0</field>
<field name="margin_left">0</field>
<field name="margin_right">0</field>
<field name="disable_shrinking" eval="True"/>
<field name="dpi">96</field>
</record>
</data>
</odoo>
@@ -14,6 +14,10 @@
<field name="route_ids" eval="[(4,ref('route_warehouse0_buy'))]"></field>
</record>
<record id="product.product_product_5" model="product.product">
<field name="route_ids" eval="[(4,ref('route_warehouse0_buy'))]"></field>
</record>
<record id="product.product_product_9" model="product.product">
<field name="route_ids" eval="[(4,ref('route_warehouse0_buy'))]"></field>
</record>
@@ -114,10 +114,35 @@
<field name="price_unit">140.00</field>
</record>
<record id="sale_order_22" model="sale.order">
<field name="partner_id" ref="base.res_partner_3"/>
<field name="partner_invoice_id" ref="base.res_partner_address_25"/>
<field name="partner_shipping_id" ref="base.res_partner_address_25"/>
<field name="user_id" ref="base.user_demo"/>
<field name="pricelist_id" ref="product.list0"/>
<field name="team_id" ref="sales_team.team_sales_department"/>
<field name="campaign_id" ref="utm.utm_campaign_email_campaign_products"/>
<field name="medium_id" ref="utm.utm_medium_email"/>
<field name="source_id" ref="sale.utm_source_sale_order_0"/>
<field name="date_order" eval="(datetime.now() + relativedelta(days=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="warehouse_id" ref="stock.warehouse0"/>
</record>
<record id="sale_order_line_47" model="sale.order.line">
<field name="order_id" ref="sale_order_22"/>
<field name="name" model="sale.order.line" eval="obj().env.ref('product.product_product_5').get_product_multiline_description_sale()"/>
<field name="product_id" ref="product.product_product_5"/>
<field name="product_uom_qty">10</field>
<field name="product_uom" ref="uom.product_uom_unit"/>
<field name="price_unit">199.00</field>
</record>
<function model="sale.order" name="action_confirm" eval="[[
ref('sale_order_19'),
ref('sale_order_20'),
ref('sale_order_21'),
ref('sale_order_22'),
]]"/>
<!-- Change date of those sale orders' delivery -->
+4
View File
@@ -30,6 +30,7 @@
'report/report_stock_forecasted.xml',
'report/report_stock_quantity.xml',
'report/report_stock_reception.xml',
'report/stock_report_views.xml',
'report/report_package_barcode.xml',
'report/report_lot_barcode.xml',
@@ -94,6 +95,7 @@
'web/static/src/legacy/scss/views.scss',
'web/static/src/legacy/scss/graph_view.scss',
'stock/static/src/scss/report_stock_forecasted.scss',
'stock/static/src/scss/report_stock_reception.scss',
'stock/static/src/scss/report_stock_rule.scss',
],
'web.assets_common': [
@@ -106,6 +108,7 @@
'stock/static/src/js/inventory_singleton_list_controller.js',
'stock/static/src/js/inventory_singleton_list_view.js',
'stock/static/src/js/report_stock_forecasted.js',
'stock/static/src/js/report_stock_reception.js',
'stock/static/src/js/stock_orderpoint_list_controller.js',
'stock/static/src/js/stock_orderpoint_list_model.js',
'stock/static/src/js/stock_orderpoint_list_view.js',
@@ -132,6 +135,7 @@
'stock/static/src/xml/popover_widget.xml',
'stock/static/src/xml/forecast_widget.xml',
'stock/static/src/xml/report_stock_forecasted.xml',
'stock/static/src/xml/report_stock_reception.xml',
'stock/static/src/xml/stock_orderpoint.xml',
'stock/static/src/xml/stock_traceability_report_backend.xml',
],
@@ -43,6 +43,8 @@ class ResConfigSettings(models.TransientModel):
'Storage Categories', implied_group='stock.group_stock_storage_categories')
annual_inventory_month = fields.Selection(related='company_id.annual_inventory_month', readonly=False)
annual_inventory_day = fields.Integer(related='company_id.annual_inventory_day', readonly=False)
group_stock_reception_report = fields.Boolean("Reception Report", implied_group='stock.group_reception_report')
group_stock_auto_reception_report = fields.Boolean("Show Reception Report at Validation", implied_group='stock.group_auto_reception_report')
@api.onchange('group_stock_multi_locations')
def _onchange_group_stock_multi_locations(self):
+1 -1
View File
@@ -114,7 +114,7 @@ class StockMove(models.Model):
procure_method = fields.Selection([
('make_to_stock', 'Default: Take From Stock'),
('make_to_order', 'Advanced: Apply Procurement Rules')], string='Supply Method',
default='make_to_stock', required=True,
default='make_to_stock', required=True, copy=False,
help="By default, the system will take from the stock in the source location and passively wait for availability. "
"The other possibility allows you to directly create a procurement on the source location (and thus ignore "
"its current stock) to gather products. If we want to chain moves and have this one to wait for the previous, "
+21
View File
@@ -954,6 +954,24 @@ class Picking(models.Model):
pickings_to_backorder = self
pickings_not_to_backorder.with_context(cancel_backorder=True)._action_done()
pickings_to_backorder.with_context(cancel_backorder=False)._action_done()
if self.user_has_groups('stock.group_reception_report') \
and self.user_has_groups('stock.group_auto_reception_report') \
and self.filtered(lambda p: p.picking_type_id.code != 'outgoing'):
lines = self.move_lines.filtered(lambda m: m.product_id.type == 'product' and m.state != 'cancel' and m.quantity_done and not m.move_dest_ids)
if lines:
# don't show reception report if all already assigned/nothing to assign
wh_location_ids = self.env['stock.location'].search([('id', 'child_of', self.picking_type_id.warehouse_id.view_location_id.id), ('location_id.usage', '!=', 'supplier')]).ids
if self.env['stock.move'].search([
('state', 'in', ['confirmed', 'partially_available', 'waiting', 'assigned']),
('product_qty', '>', 0),
('location_id', 'in', wh_location_ids),
('move_orig_ids', '=', False),
('picking_id', 'not in', self.ids),
('product_id', 'in', lines.product_id.ids)], limit=1):
action = self.action_view_reception_report()
action['context'] = {'default_picking_ids': self.ids}
return action
return True
def action_set_quantities_to_reservation(self):
@@ -1400,6 +1418,9 @@ class Picking(models.Model):
action['domain'] = [('picking_id', 'in', self.ids)]
return action
def action_view_reception_report(self):
return self.env["ir.actions.actions"]._for_xml_id("stock.stock_reception_action")
def _attach_sign(self):
""" Render the delivery report in pdf and attach it to the picking in `self`. """
self.ensure_one()
+1
View File
@@ -3,5 +3,6 @@
from . import report_stock_forecasted
from . import report_stock_quantity
from . import report_stock_reception
from . import report_stock_rule
from . import stock_traceability
@@ -0,0 +1,294 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict, OrderedDict
from odoo import _, api, models
from odoo.tools import float_compare, float_is_zero, format_date
class ReceptionReport(models.AbstractModel):
_name = 'report.stock.report_reception'
_description = "Stock Reception Report"
@api.model
def _get_report_values(self, docids, data=None):
''' This report is flexibly designed to work with both individual and batch pickings.
'''
docids = self.env.context.get('default_picking_ids', docids)
pickings = self.env['stock.picking'].search([('id', 'in', docids), ('picking_type_code', '!=', 'outgoing'), ('state', '!=', 'cancel')])
picking_states = pickings.mapped('state')
# unsupported cases
if not pickings:
msg = _("No transfers selected or a delivery order selected")
elif 'done' in picking_states and len(set(picking_states)) > 1:
pickings = False
msg = _("This report cannot be used for done and not done transfers at the same time")
if not pickings:
return {'pickings': False, 'reason': msg}
# incoming move qtys
product_to_qty_draft = defaultdict(float)
product_to_qty_to_assign = defaultdict(list)
product_to_total_assigned = defaultdict(lambda: [0.0, []])
# to support batch pickings we need to track the total already assigned
move_lines = pickings.move_lines.filtered(lambda m: m.product_id.type == 'product' and m.state != 'cancel')
assigned_moves = move_lines.mapped('move_dest_ids')
product_to_assigned_qty = defaultdict(float)
for assigned in assigned_moves:
product_to_assigned_qty[assigned.product_id] += assigned.product_qty
for move in move_lines:
qty_already_assigned = 0
if move.move_dest_ids:
qty_already_assigned = min(product_to_assigned_qty[move.product_id], move.product_qty)
product_to_assigned_qty[move.product_id] -= qty_already_assigned
if qty_already_assigned:
product_to_total_assigned[move.product_id][0] += qty_already_assigned
product_to_total_assigned[move.product_id][1].append(move.id)
if move.product_qty != qty_already_assigned:
if move.state == 'draft':
product_to_qty_draft[move.product_id] += move.product_qty - qty_already_assigned
else:
quantity_to_assign = move.product_qty
if move.picking_id.immediate_transfer:
# if immediate transfer is not Done and quantity_done hasn't been edited, then move.product_qty will incorrectly = 1 (due to default)
quantity_to_assign = move.product_uom._compute_quantity(move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP')
product_to_qty_to_assign[move.product_id].append((quantity_to_assign - qty_already_assigned, move))
# only match for non-mto moves in same warehouse
warehouse = pickings[0].picking_type_id.warehouse_id
wh_location_ids = self.env['stock.location'].search([('id', 'child_of', warehouse.view_location_id.id), ('location_id.usage', '!=', 'supplier')]).ids
allowed_states = ['confirmed', 'partially_available', 'waiting']
if 'done' in picking_states:
# only done moves are allowed to be assigned to already reserved moves
allowed_states += ['assigned']
outs = self.env['stock.move'].search(
[
('state', 'in', allowed_states),
('product_qty', '>', 0),
('location_id', 'in', wh_location_ids),
('move_orig_ids', '=', False),
('picking_id', 'not in', pickings.ids),
('product_id', 'in',
[p.id for p in list(product_to_qty_to_assign.keys()) + list(product_to_qty_draft.keys())]),
],
order='reservation_date, priority desc, date, id')
products_to_outs = defaultdict(list)
for out in outs:
products_to_outs[out.product_id].append(out)
sources_to_lines = defaultdict(list) # group by source so we can print them together
# show potential moves that can be assigned
for product_id, outs in products_to_outs.items():
for out in outs:
# only show pickings or MOs, no SOs
source = out.picking_id if out.picking_id else out._get_source_document()
if not source:
continue
qty_to_reserve = out.product_qty
product_uom = out.product_id.uom_id
if 'done' not in picking_states and out.state == 'partially_available':
qty_to_reserve -= out.product_uom._compute_quantity(out.reserved_availability, product_uom)
moves_in_ids = []
qty_done = 0
for move_in_qty, move_in in product_to_qty_to_assign[out.product_id]:
moves_in_ids.append(move_in.id)
if float_compare(qty_done + move_in_qty, qty_to_reserve, precision_rounding=product_uom.rounding) <= 0:
qty_to_add = move_in_qty
move_in_qty = 0
else:
qty_to_add = qty_to_reserve - qty_done
move_in_qty -= qty_to_add
qty_done += qty_to_add
if move_in_qty:
product_to_qty_to_assign[out.product_id][0] = (move_in_qty, move_in)
else:
product_to_qty_to_assign[out.product_id] = product_to_qty_to_assign[out.product_id][1:]
if float_compare(qty_to_reserve, qty_done, precision_rounding=product_uom.rounding) == 0:
break
if not float_is_zero(qty_done, precision_rounding=product_uom.rounding):
sources_to_lines[source].append(self._prepare_report_line(qty_done, product_id, out, source, move_ins=self.env['stock.move'].browse(moves_in_ids)))
# draft qtys can be shown but not assigned
qty_expected = product_to_qty_draft.get(product_id, 0)
if float_compare(qty_to_reserve, qty_done, precision_rounding=product_uom.rounding) > 0 and\
not float_is_zero(qty_expected, precision_rounding=product_uom.rounding):
to_expect = min(qty_expected, qty_to_reserve - qty_done)
sources_to_lines[source].append(self._prepare_report_line(to_expect, product_id, out, source, is_qty_assignable=False))
product_to_qty_draft[product_id] -= to_expect
# show already assigned moves
for product_id, qty_and_ins in product_to_total_assigned.items():
total_assigned = qty_and_ins[0]
moves_in = self.env['stock.move'].browse(qty_and_ins[1])
out_moves = moves_in.move_dest_ids
for out_move in out_moves:
if float_is_zero(total_assigned, precision_rounding=out_move.product_id.uom_id.rounding):
# it is possible there are different in moves linked to the same out moves due to batch
# => we guess as to which outs correspond to this report...
continue
source = out_move.picking_id if out_move.picking_id else out_move._get_source_document()
if not source:
continue
qty_assigned = min(total_assigned, out_move.product_qty)
sources_to_lines[source].append(
self._prepare_report_line(qty_assigned, product_id, out_move, source, is_assigned=True, move_ins=moves_in))
# dates aren't auto-formatted when printed in report :(
sources_to_formatted_scheduled_date = defaultdict(list)
for source, dummy in sources_to_lines.items():
sources_to_formatted_scheduled_date[source] = self._get_formatted_scheduled_date(source)
return {
'data': data,
'doc_ids': docids,
'doc_model': 'stock.picking',
'sources_to_lines': sources_to_lines,
'precision': self.env['decimal.precision'].precision_get('Product Unit of Measure'),
'pickings': pickings,
'sources_to_formatted_scheduled_date': sources_to_formatted_scheduled_date,
}
def _prepare_report_line(self, quantity, product, move_out, source=False, is_assigned=False, is_qty_assignable=True, move_ins=False):
return {
'source': source,
'product': {
'id': product.id,
'display_name': product.display_name
},
'uom': product.uom_id.display_name,
'quantity': quantity,
'is_qty_assignable': is_qty_assignable,
'move_out': move_out,
'is_assigned': is_assigned,
'move_ins': move_ins and move_ins.ids or False,
}
def _get_formatted_scheduled_date(self, source):
""" Unfortunately different source record types have different field names for their "Scheduled Date"
Therefore an extendable method is needed.
"""
if source._name == 'stock.picking':
return format_date(self.env, source.scheduled_date)
return False
def action_assign(self, move_ids, qtys, in_ids):
""" Assign picking move(s) [i.e. link] to other moves (i.e. make them MTO)
:param move_id ids: the ids of the moves to make MTO
:param qtys list: the quantities that are being assigned to the move_ids (in same order as move_ids)
:param in_ids ids: the ids of the moves that are to be assigned to move_ids
"""
outs = self.env['stock.move'].browse(move_ids)
# Split outs with only part of demand assigned to prevent reservation problems later on.
# We do this first so we can create their split moves in batch
out_to_new_out = OrderedDict()
new_move_vals = []
for out, qty_to_link in zip(outs, qtys):
if float_compare(out.product_qty, qty_to_link, precision_rounding=out.product_id.uom_id.rounding) == 1:
new_move_vals += out._split(out.product_qty - qty_to_link)
out_to_new_out[out.id] = self.env['stock.move']
new_outs = self.env['stock.move'].create(new_move_vals)
# don't do action confirm to avoid creating additional unintentional reservations
new_outs.write({'state': 'confirmed'})
for i, k in enumerate(out_to_new_out.keys()):
out_to_new_out[k] = new_outs[i]
for out, qty_to_link, ins in zip(outs, qtys, in_ids):
potential_ins = self.env['stock.move'].browse(ins)
if out.id in out_to_new_out:
new_out = out_to_new_out[out.id]
if potential_ins[0].state != 'done' and out.reserved_availability:
# let's assume if 1 of the potential_ins isn't done, then none of them are => we are only assigning the not-reserved
# qty and the new move should have all existing reserved quants (i.e. move lines) assigned to it
out.move_line_ids.move_id = new_out
elif potential_ins[0].state == 'done' and out.reserved_availability > qty_to_link:
# let's assume if 1 of the potential_ins is done, then all of them are => we can link them to already reserved moves, but we
# need to make sure the reserved qtys still match the demand amount the move (we're assigning).
out.move_line_ids.move_id = new_out
assigned_amount = 0
for move_line_id in new_out.move_line_ids:
if assigned_amount + move_line_id.product_qty > qty_to_link:
new_move_line = move_line_id.copy({'product_uom_qty': 0, 'qty_done': 0})
new_move_line.product_uom_qty = move_line_id.product_uom_qty
move_line_id.product_uom_qty = out.product_id.uom_id._compute_quantity(qty_to_link - assigned_amount, out.product_uom, rounding_method='HALF-UP')
new_move_line.product_uom_qty -= out.product_id.uom_id._compute_quantity(move_line_id.product_qty, out.product_uom, rounding_method='HALF-UP')
move_line_id.move_id = out
assigned_amount += move_line_id.product_qty
if float_compare(assigned_amount, qty_to_link, precision_rounding=out.product_id.uom_id.rounding) == 0:
break
for in_move in reversed(potential_ins):
quantity_remaining = in_move.product_qty - sum(in_move.move_dest_ids.mapped('product_qty'))
if in_move.product_id != out.product_id or float_compare(0, quantity_remaining, precision_rounding=in_move.product_id.uom_id.rounding) >= 0:
# in move is already completely linked (e.g. during another assign click) => don't count it again
potential_ins = potential_ins[1:]
continue
linked_qty = min(in_move.product_qty, qty_to_link)
in_move.move_dest_ids |= out
out.procure_method = 'make_to_order'
quantity_remaining -= linked_qty
qty_to_link -= linked_qty
if float_is_zero(qty_to_link, precision_rounding=out.product_id.uom_id.rounding):
break # we have satistfied the qty_to_link
(outs | new_outs)._recompute_state()
# always try to auto-assign to prevent another move from reserving the quant if incoming move is done
self.env['stock.move'].browse(move_ids)._action_assign()
def action_unassign(self, move_id, qty, in_ids):
""" Unassign moves [i.e. unlink] from a move (i.e. make non-MTO)
:param move_id id: the id of the move to make non-MTO
:param qty float: the total quantity that is being unassigned from move_id
:param in_ids ids: the ids of the moves that are to be unassigned from move_id
"""
out = self.env['stock.move'].browse(move_id)
ins = self.env['stock.move'].browse(in_ids)
amount_unassigned = 0
for in_move in ins:
if out.id not in in_move.move_dest_ids.ids:
continue
in_move.move_dest_ids -= out
amount_unassigned += min(qty, in_move.product_qty)
if float_compare(qty, amount_unassigned, precision_rounding=out.product_id.uom_id.rounding) <= 0:
break
if out.move_orig_ids:
# annoying use case: batch reserved + individual picking unreserved, need to split the out move
new_move_vals = out._split(amount_unassigned)
if new_move_vals:
new_move_vals[0]['procure_method'] = 'make_to_order'
new_out = self.env['stock.move'].create(new_move_vals)
# don't do action confirm to avoid creating additional unintentional reservations
new_out.write({'state': 'confirmed'})
out.move_line_ids.move_id = new_out
(out | new_out)._compute_reserved_availability()
if new_out.reserved_availability > new_out.product_qty:
# extra reserved amount goes to no longer linked out
reserved_amount_to_remain = new_out.reserved_availability - new_out.product_qty
for move_line_id in new_out.move_line_ids:
if reserved_amount_to_remain <= 0:
break
if move_line_id.product_qty > reserved_amount_to_remain:
new_move_line = move_line_id.copy({'product_uom_qty': 0, 'qty_done': 0})
new_move_line.product_uom_qty = out.product_id.uom_id._compute_quantity(move_line_id.product_qty - reserved_amount_to_remain, move_line_id.product_uom_id, rounding_method='HALF-UP')
move_line_id.product_uom_qty -= new_move_line.product_uom_qty
new_move_line.move_id = out
break
else:
move_line_id.move_id = out
reserved_amount_to_remain -= move_line_id.product_qty
(out | new_out)._compute_reserved_availability()
out.move_orig_ids = False
new_out._recompute_state()
out.procure_method = 'make_to_stock'
out._recompute_state()
@@ -0,0 +1,165 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Reception Report Labels -->
<template id="report_reception_report_label">
<t t-if="quantity" t-set="qtys" t-value="[int(q) for q in quantity.split(',')]"/>
<t t-else="" t-set="qtys" t-value="[1 for q in range(len(docs))]"/>
<t t-call="web.basic_layout">
<div class="page">
<t t-foreach="range(len(docs))" t-as="index">
<t t-set="picking" t-value="docs[index]"/>
<t t-set="qty" t-value="qtys[index]"/>
<t t-foreach="range(qty)" t-as="j">
<div class="o_label_page o_label_dymo">
<span t-if="picking._name == 'stock.picking' and picking.origin" t-esc="picking.origin"/>
<span t-else="" t-esc="picking.name"/>
<div class="address"
t-if="picking._name == 'stock.picking' and picking.partner_id"
t-field="picking.partner_id"
t-options='{"widget": "contact", "fields": ["address", "name", "phone"], "no_marker": True}'/>
</div>
</t>
</t>
</div>
</t>
</template>
<!-- Reception Report -->
<record id="stock_reception_report_action" model="ir.actions.report">
<field name="name">Reception Report</field>
<field name="model">stock.picking</field>
<field name="report_type">qweb-html</field>
<field name="report_name">stock.report_reception</field>
</record>
<record id="stock_reception_action" model="ir.actions.client">
<field name="name">Reception Report</field>
<field name="tag">reception_report</field>
</record>
<template id="report_reception_body">
<div class="o_report_reception justify-content-between">
<div class="o_report_reception_header my-4">
<h3>
<t t-if="pickings">
<t t-foreach="pickings" t-as="receipt">
<div>
<a href="#" res-model="stock.picking" view-type="form" t-att-res-id="receipt.id">
<t t-esc="receipt.display_name"/>
</a>
<span t-field="receipt.state" t-attf-class="badge badge-pill #{'bg-success-light' if receipt.state == 'done' else 'bg-info-light'}"/>
</div>
</t>
</t>
<t t-else="">
<span t-esc="reason"/>
</t>
</h3>
</div>
<div><table t-if="sources_to_lines" class="o_report_reception_table table table-sm">
<t t-foreach="sources_to_lines" t-as="source">
<thead t-if="report_type == 'html' or any(line['is_assigned'] for line in sources_to_lines[source])">
<tr class="bg-light">
<th>
<i t-if="source.priority == '1'" class="o_priority o_priority_star fa fa-star"/>
<a name="source_link" href="#" t-att-res-model="source._name" view-type="form" t-att-res-id="source.id" t-esc="source.display_name"/>
<span t-if="source._name == 'stock.picking' and source.origin">
(<t t-esc="source.origin"/>)
</span>
<span t-if="source._name == 'stock.picking' and source.partner_id">:
<a name="source_link" href="#"
t-att-res-model="source.partner_id._name"
t-att-res-id="source.partner_id.id"
view-type="form"
t-esc="source.partner_id.name"/>
</span>
</th>
<th>Expected Delivery: <t t-esc="sources_to_formatted_scheduled_date[source]"/></th>
<th t-if="report_type == 'html' and any(s['move_ins'] for s in sources_to_lines[source])">
<button t-if="any(not s['is_assigned'] and s['is_qty_assignable'] for s in sources_to_lines[source])"
class="btn btn-sm btn-primary o_report_reception_assign o_assign_all"
name="assign_source_link">
Assign All
</button>
</th>
<th t-if="report_type == 'html'">
<button t-if="any(s['move_ins'] for s in sources_to_lines[source])"
class="btn btn-sm btn-primary o_print_label o_print_label_all"
t-att-disabled="not any(s['is_assigned'] for s in sources_to_lines[source])"
name="print_labels">
Print Labels
</button>
</th>
</tr>
</thead>
<tbody>
<t t-set="possible_moves" t-value="sources_to_lines[source]"/>
<t t-foreach="possible_moves" t-as="line">
<!-- don't print non-assigned lines when printing pdf -->
<tr t-if="report_type == 'html' or line['is_assigned']">
<td>
<t t-esc="line['product']['display_name']"/>
</td>
<td>
<t t-esc="line['quantity']"/>
<t groups="uom.group_uom" t-esc="line['uom']"/>
<button t-if="report_type == 'html'"
class="o_report_reception_forecasted btn btn-link fa fa-area-chart"
t-att-move-id="line['move_out'].id"
name="forecasted_report_link">
</button>
</td>
<td t-if="report_type == 'html' and line['is_qty_assignable']">
<button t-if="not line['is_assigned']"
class="btn btn-sm btn-primary o_report_reception_assign"
t-attf-model="stock.move"
t-att-move-id="line['move_out'].id"
t-att-move-ins-ids="line['move_ins']"
t-att-qty="line['quantity']"
name="assign_link">
Assign
</button>
<button t-if="line['is_assigned']"
class="btn btn-sm btn-primary o_report_reception_unassign"
t-attf-model="stock.move"
t-att-move-id="line['move_out'].id"
t-att-move-ins-ids="line['move_ins']"
t-att-qty="line['quantity']"
name="unassign_link">
Unassign
</button>
</td>
<td t-if="report_type == 'html'">
<button t-if="line['is_qty_assignable'] and line['source']"
t-att-source-id="line['source'].id"
t-att-source-model="line['source']._name"
t-att-qty="line['quantity']"
t-attf-class="btn btn-sm btn-primary o_print_label"
t-att-disabled="not line['is_assigned']"
name="print_label">
Print Label
</button>
</td>
</tr>
</t>
</tbody>
</t>
</table>
<p t-else="">
No allocation need found for incoming products.
</p></div>
</div>
</template>
<template id="report_reception">
<t t-call="web.html_container">
<t t-if="report_type == 'pdf'" t-call="web.internal_layout">
<t t-call="stock.report_reception_body"/>
</t>
<t t-else="">
<t t-call="stock.report_reception_body"/>
</t>
</t>
</template>
</odoo>
@@ -176,5 +176,15 @@
<field name="binding_model_id" ref="model_stock_picking_type"/>
<field name="binding_type">report</field>
</record>
<record id="label_picking" model="ir.actions.report">
<field name="name">Picking Label</field>
<field name="model">stock.picking</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">stock.report_reception_report_label</field>
<field name="report_file">stock.report_reception_report_label</field>
<field name="paperformat_id" ref="product.paperformat_label_sheet_dymo"/>
<field name="binding_model_id" ref="model_stock_picking"/>
<field name="binding_type">report</field>
</record>
</data>
</odoo>
+10
View File
@@ -68,6 +68,16 @@
<field name="name">Manage Storage Categories</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="group_reception_report" model="res.groups">
<field name="name">Use Reception Report</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="group_auto_reception_report" model="res.groups">
<field name="name">Display Reception Report at Validation</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
</data>
<data noupdate="1">
<record id="base.default_user" model="res.users">
@@ -0,0 +1,205 @@
/** @odoo-module **/
import clientAction from 'report.client_action';
import core from 'web.core';
const qweb = core.qweb;
const ReceptionReport = clientAction.extend({
/**
* @override
*/
init: function (parent, action, options) {
this._super(...arguments);
this.context = Object.assign(action.context || {}, {
active_ids: action.context.default_picking_ids,
});
this.report_name = `stock.report_reception`;
this.report_url = `/report/html/${this.report_name}/?context=${JSON.stringify(this.context)}`;
this._title = action.name;
},
/**
* @override
*/
start: function () {
return Promise.all([
this._super(...arguments),
]).then(() => {
this._renderButtons();
});
},
/**
* @override
*/
on_attach_callback: function () {
this._super();
this.iframe.addEventListener("load",
() => this._bindAdditionalActionHandlers(),
{ once: true }
);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Renders extra report buttons in control panel
*/
_renderButtons: function () {
this.$buttons.append(qweb.render('reception_report_buttons', {}));
this.$buttons.on('click', '.o_report_reception_assign', this._onClickAssign.bind(this));
this.$buttons.on('click', '.o_print_label', this._onClickPrintLabel.bind(this));
this.controlPanelProps.cp_content = {
$buttons: this.$buttons,
};
},
/**
* Bind additional <button> action handlers
*/
_bindAdditionalActionHandlers: function () {
const rr = $(this.iframe).contents().find('.o_report_reception');
rr.on('click', '.o_report_reception_assign', this._onClickAssign.bind(this));
rr.on('click', '.o_report_reception_unassign', this._onClickUnassign.bind(this));
rr.on('click', '.o_report_reception_forecasted', this._onClickForecastReport.bind(this));
rr.on('click', '.o_print_label', this._onClickPrintLabel.bind(this));
},
_switchButton: function (button) {
button.innerText = button.innerText.includes('Unassign') ? "Assign" : "Unassign";
button.name = button.name === 'assign_link' ? 'unassign_link' : 'assign_link';
button.classList.toggle("o_report_reception_assign");
button.classList.toggle("o_report_reception_unassign");
},
/**
* Assign the specified move(s)
*
* @returns {Promise}
*/
_onClickAssign: function (ev) {
const el = ev.currentTarget;
const quantities = []; // incoming qty amounts to assign
const moveIds = [];
const inIds = [];
let nodeToAssign = [];
if (el.name === 'assign_link') { // One line "Assign"
nodeToAssign = [el];
el.closest('tbody').previousElementSibling.querySelectorAll('.o_print_label_all').forEach(button => button.removeAttribute('disabled'));
} else {
el.style.display = 'none';
if (el.name === "assign_all_link") { // Global "Assign All"
const iframe = this.iframe.contentDocument;
iframe.querySelectorAll('.o_assign_all').forEach(button => button.style.display = 'none');
iframe.querySelectorAll('.o_print_label_all').forEach(button => button.removeAttribute('disabled'));
nodeToAssign = iframe.querySelectorAll('.o_report_reception_assign:not(.o_assign_all)');
} else { // Local assign all
nodeToAssign = el.closest('thead').nextElementSibling.querySelectorAll('.o_report_reception_assign:not(.o_assign_all)');
el.closest('thead').nextElementSibling.querySelectorAll('.o_print_label_all').forEach(button => button.removeAttribute('disabled'));
}
}
nodeToAssign.forEach(node => {
node.closest('td').nextElementSibling.querySelectorAll('.o_print_label').forEach(button => button.removeAttribute('disabled'));
moveIds.push(parseInt(node.getAttribute('move-id')));
quantities.push(parseFloat(node.getAttribute('qty')));
inIds.push(JSON.parse(node.getAttribute('move-ins-ids')));
this._switchButton(node);
});
return this._rpc({
model: 'report.stock.report_reception',
args: [false, moveIds, quantities, inIds],
method: 'action_assign'
});
},
/**
* Unassign the specified move
*
* @returns {Promise}
*/
_onClickUnassign: function (ev) {
const el = ev.currentTarget;
this._switchButton(el);
const quantity = parseFloat(el.getAttribute('qty'));
const modelId = parseInt(el.getAttribute('move-id'));
const inIds = JSON.parse("[" + el.getAttribute('move-ins-ids') + "]");
el.closest('td').nextElementSibling.querySelectorAll('.o_print_label').forEach(button => button.setAttribute('disabled', true));
return this._rpc({
model: 'report.stock.report_reception',
args: [false, modelId, quantity, inIds[0]],
method: 'action_unassign'
});
},
/**
* Open the forecast report for the product of the selected move.
*
* @returns {Promise}
*/
_onClickForecastReport: function (ev) {
const modelId = parseInt(ev.currentTarget.getAttribute('move-id'));
return this._rpc({
model: 'stock.move',
args: [[modelId]],
method: 'action_product_forecast_report'
}).then((action) => {
return this.do_action(action);
});
},
/**
* Print the corresponding source label
*/
_onClickPrintLabel: function (ev) {
// unfortunately, due to different reports needed for different models, we will handle
// pickings here and others models will have to be extended/printed separately until better
// technique to merge into continuous pdf to written
return this._printLabel(ev, 'stock.report_reception_report_label', 'stock.picking');
},
_printLabel: function (ev, report_file, sourceModel) {
const el = ev.currentTarget;
const modelIds = [];
const productQtys = [];
let nodeToPrint = [];
if (el.name === 'print_label') { // One line print
nodeToPrint = [el];
} else {
if (el.name === "print_all_labels") { // Global "Print Labels"
nodeToPrint = this.iframe.contentDocument.querySelectorAll('.o_print_label:not(.o_print_label_all):not(:disabled)');
} else { // Local "Print Labels"
nodeToPrint = el.closest('thead').nextElementSibling.querySelectorAll('.o_print_label:not(.o_print_label_all):not(:disabled)');
}
}
nodeToPrint.forEach(node => {
if (node.getAttribute('source-model') === sourceModel) {
modelIds.push(parseInt(node.getAttribute('source-id')));
productQtys.push(Math.ceil(node.getAttribute('qty')) || '1');
}
});
if (!modelIds.length) { // Nothing to print for this model.
return Promise.resolve();
}
const report_name = `${report_file}?docids=${modelIds}&report_type=qweb-pdf&quantity=${productQtys}`;
const action = {
type: 'ir.actions.report',
report_type: 'qweb-pdf',
report_name,
report_file,
};
return this.do_action(action);
}
});
core.action_registry.add('reception_report', ReceptionReport);
export default ReceptionReport;
@@ -0,0 +1,46 @@
.o_report_reception {
.o_priority {
&.o_priority_star {
font-size: 1.35em;
&.fa-star {
color: gold;
}
}
}
& .btn {
&.btn-primary {
background-color: $o-brand-primary;
border-color: $o-brand-primary;
&:hover:not([disabled]) {
background-color: darken($o-brand-primary, 10%);
}
}
}
& .badge {
line-height: .75;
}
@each $-name, $-bg-color in $theme-colors {
$-safe-text-color: color-yiq(mix($-bg-color, $o-view-background-color));
@include bg-variant(".bg-#{$-name}-light", rgba(theme-color($-name), 0.5), $-safe-text-color);
}
}
.o_label_page {
margin-left: -3mm;
margin-right: -3mm;
overflow: hidden;
page-break-before: always;
padding: 1mm 0mm 0mm;
&.o_label_dymo {
font-size:80%;
width: 57mm;
height: 32mm;
}
span[itemprop="name"] {
font-weight: bold;
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="reception_report_buttons">
<button
name="assign_all_link"
class="btn btn-secondary o_report_reception_assign o_assign_all">
Assign All
</button>
<button
name="print_all_labels"
class="btn btn-secondary o_print_label o_print_label_all">
Print Labels
</button>
</t>
</templates>
+342
View File
@@ -1173,3 +1173,345 @@ class TestReports(TestReportsCommon):
self.assertEqual(lines[1]['document_out'].id, delivery_at_confirm.id)
self.assertEqual(lines[2]['document_out'].id, delivery_by_date_priority.id)
self.assertEqual(lines[3]['document_out'].id, delivery_manual.id)
def test_report_reception_1_one_receipt(self):
""" Create 2 deliveries and 1 receipt where some of the products being received
can be reserved for the deliveries. Check that the reception report correctly
shows these corresponding potential allocations + correctly reserves incoming moves
when reserve button is pushed.
"""
product2 = self.env['product.product'].create({
'name': 'Extra Product',
'type': 'product',
'categ_id': self.env.ref('product.product_category_all').id,
})
product3 = self.env['product.product'].create({
'name': 'Unpopular Product',
'type': 'product',
'categ_id': self.env.ref('product.product_category_all').id,
})
# Creates some deliveries for reception report to match against
delivery_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
delivery_form.partner_id = self.partner
delivery_form.picking_type_id = self.picking_type_out
with delivery_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 5
with delivery_form.move_ids_without_package.new() as move_line:
move_line.product_id = product2
move_line.product_uom_qty = 10
delivery1 = delivery_form.save()
delivery1.action_confirm()
delivery_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
delivery_form.partner_id = self.partner
delivery_form.picking_type_id = self.picking_type_out
with delivery_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 2
delivery2 = delivery_form.save()
delivery2.action_confirm()
# Create a receipt
receipt_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
receipt_form.partner_id = self.partner
receipt_form.picking_type_id = self.picking_type_in
with receipt_form.move_ids_without_package.new() as move_line:
# incoming qty greater than total (2 moves) outgoing amount => 2 report lines, each = outgoing qty
move_line.product_id = self.product
move_line.product_uom_qty = 15
with receipt_form.move_ids_without_package.new() as move_line:
# outgoing qty greater than incoming amount => report line = incoming qty
move_line.product_id = product2
move_line.product_uom_qty = 5
with receipt_form.move_ids_without_package.new() as move_line:
# not outgoing => shouldn't appear in report
move_line.product_id = product3
move_line.product_uom_qty = 5
receipt = receipt_form.save()
# check that reception report has correct number of deliveries/outgoing moves
# but the quantities aren't available for assignment yet (i.e. can link as chained moves)
report = self.env['report.stock.report_reception']
report_values = report._get_report_values(docids=[receipt.id])
sources_to_lines = report_values['sources_to_lines']
self.assertEqual(len(sources_to_lines), 2, "The report has wrong number of outgoing pickings.")
all_lines = []
for dummy, lines in sources_to_lines.items():
for line in lines:
self.assertFalse(line['is_qty_assignable'], "The receipt IS DRAFT => its move quantities ARE NOT available to assign.")
all_lines.append(line)
self.assertEqual(len(all_lines), 3, "The report has wrong number of outgoing moves.")
# we expect this order based on move creation
self.assertEqual(all_lines[0]['quantity'], 5, "The first move has wrong incoming qty.")
self.assertEqual(all_lines[0]['product']['id'], self.product.id, "The first move has wrong incoming product to assign.")
self.assertEqual(all_lines[1]['quantity'], 5, "The second move has wrong incoming qty.")
self.assertEqual(all_lines[1]['product']['id'], product2.id, "The second move has wrong incoming product to assign.")
self.assertEqual(all_lines[2]['quantity'], 2, "The last move has wrong incoming qty.")
self.assertEqual(all_lines[2]['product']['id'], self.product.id, "The third move has wrong incoming product to assign.")
# check that report correctly realizes outgoing moves can be linked when receipt is done
receipt.action_confirm()
for move in receipt.move_lines:
move.quantity_done = move.product_uom_qty
receipt.button_validate()
report_values = report._get_report_values(docids=[receipt.id])
sources_to_lines = report_values['sources_to_lines']
all_lines = []
move_ids = []
qtys = []
in_ids = []
for dummy, lines in sources_to_lines.items():
for line in lines:
self.assertTrue(line['is_qty_assignable'], "The receipt IS DONE => all of its move quantities ARE assignable")
all_lines.append(line)
move_ids.append(line['move_out'].id)
qtys.append(line['quantity'])
in_ids += line['move_ins']
# line quantities should be the same when receipt is done compared to when it was draft
self.assertEqual(len(all_lines), 3, "The report has wrong number of outgoing moves.")
self.assertEqual(all_lines[0]['quantity'], 5, "The first move has wrong incoming qty to reserve.")
self.assertEqual(all_lines[0]['product']['id'], self.product.id, "The first move has wrong product to reserve.")
self.assertEqual(all_lines[1]['quantity'], 5, "The second move has wrong incoming qty to reserve.")
self.assertEqual(all_lines[1]['product']['id'], product2.id, "The second move has wrong product to reserve.")
self.assertEqual(all_lines[2]['quantity'], 2, "The last move has wrong incoming qty to reserve.")
self.assertEqual(all_lines[2]['product']['id'], self.product.id, "The third move has wrong product to reserve.")
# check that report assign button works correctly
report.action_assign(move_ids, qtys, in_ids)
self.assertEqual(len(receipt.move_lines[0].move_dest_ids.ids), 2, "Demand qty of first and last moves should now be linked to incoming.")
self.assertEqual(len(receipt.move_lines[1].move_dest_ids.ids), 1, "Demand qty of second move should now be linked to incoming.")
self.assertEqual(len(receipt.move_lines[2].move_dest_ids.ids), 0, "product3 should have no moves linked to it.")
self.assertEqual(len(delivery1.move_lines.filtered(lambda m: m.product_id == product2)), 2, "product2 outgoing move should be split between linked and non-linked quantities.")
def test_report_reception_2_two_receipts(self):
""" Create 1 delivery and 2 receipts where the products being received
can be reserved for the delivery. Check that the reception report correctly
shows corresponding potential allocations when receipts have differing states.
"""
# Creates delivery for reception report to match against
delivery_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
delivery_form.partner_id = self.partner
delivery_form.picking_type_id = self.picking_type_out
with delivery_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 100
delivery = delivery_form.save()
delivery.action_confirm()
# Create 2 receipts and check its reception report values
receipt_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
receipt_form.partner_id = self.partner
receipt_form.picking_type_id = self.picking_type_in
with receipt_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 5
receipt1 = receipt_form.save()
receipt_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
receipt_form.partner_id = self.partner
receipt_form.picking_type_id = self.picking_type_in
with receipt_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 3
receipt2 = receipt_form.save()
# check that report correctly merges not draft incoming quantities
report = self.env['report.stock.report_reception']
report_values = report._get_report_values(docids=[receipt1.id, receipt2.id])
self.assertEqual(len(report_values['pickings']), 2, "There should be 2 receipts to assign from in this report")
sources_to_lines = report_values['sources_to_lines']
self.assertEqual(len(sources_to_lines), 1, "The report has wrong number of outgoing pickings.")
all_lines = list(sources_to_lines.values())[0]
self.assertEqual(len(all_lines), 1, "The report has wrong number of outgoing move lines.")
self.assertFalse(all_lines[0]['is_qty_assignable'], "The receipt IS NOT done => its move quantities ARE NOT available to reserve (i.e. done).")
self.assertEqual(all_lines[0]['quantity'], 8, "The move has wrong incoming qty.")
# check that report splits assignable and non-assignable quantities when 1 receipt is draft and other is confirmed
receipt1.action_confirm()
for move in receipt1.move_lines:
move.quantity_done = move.product_uom_qty
report_values = report._get_report_values(docids=[receipt1.id, receipt2.id])
sources_to_lines = report_values['sources_to_lines']
all_lines = list(sources_to_lines.values())[0]
# line quantities depends on done vs not done incoming quantities => should be 2 lines now
self.assertEqual(len(all_lines), 2, "The report has wrong number of lines (1 assignable + 1 not).")
self.assertEqual(all_lines[0]['quantity'], 5, "The first move has wrong incoming qty to assign.")
self.assertTrue(all_lines[0]['is_qty_assignable'], "1 receipt is done => should have 1 reservable move.")
self.assertEqual(all_lines[1]['quantity'], 3, "The second move has wrong (expected) incoming qty.")
self.assertFalse(all_lines[1]['is_qty_assignable'], "1 receipt is draft => should have 1 non-assignable move.")
# check that report doesn't allow done and non-done moves at same time
receipt1.button_validate()
reason = report._get_report_values(docids=[receipt1.id, receipt2.id])['reason']
self.assertEqual(reason, "This report cannot be used for done and not done transfers at the same time", "empty report reason not shown")
def test_report_reception_3_multiwarehouse(self):
""" Check that reception report respects same warehouse for
receipts and deliveries.
"""
# Warehouse config.
wh_2 = self.env['stock.warehouse'].create({
'name': 'Other Warehouse',
'code': 'OTHER',
})
picking_type_out_2 = self.env['stock.picking.type'].search([
('code', '=', 'outgoing'),
('warehouse_id', '=', wh_2.id),
])
# Creates delivery in warehouse2
delivery_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
delivery_form.partner_id = self.partner
delivery_form.picking_type_id = picking_type_out_2
with delivery_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 100
delivery = delivery_form.save()
delivery.action_confirm()
# Create a receipt in warehouse1
receipt_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
receipt_form.partner_id = self.partner
receipt_form.picking_type_id = self.picking_type_in
with receipt_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 15
receipt = receipt_form.save()
report = self.env['report.stock.report_reception']
report_values = report._get_report_values(docids=[receipt.id])
self.assertEqual(len(report_values['sources_to_lines']), 0, "The receipt and delivery are in different warehouses => no moves to link to should be found.")
def test_report_reception_4_pick_pack(self):
""" Check that reception report ignores outgoing moves that are not beginning of chain
"""
warehouse = self.env['stock.warehouse'].search([('lot_stock_id', '=', self.stock_location.id)], limit=1)
warehouse.write({'delivery_steps': 'pick_pack_ship'})
ship_move = self.env['stock.move'].create({
'name': 'The ship move',
'product_id': self.product.id,
'product_uom_qty': 5.0,
'product_uom': self.product.uom_id.id,
'location_id': warehouse.wh_output_stock_loc_id.id,
'location_dest_id': self.env.ref('stock.stock_location_customers').id,
'warehouse_id': warehouse.id,
'picking_type_id': warehouse.out_type_id.id,
'procure_method': 'make_to_order',
'state': 'draft',
})
# create chained pick/pack moves to test with
ship_move._assign_picking()
ship_move._action_confirm()
pack_move = ship_move.move_orig_ids[0]
pick_move = pack_move.move_orig_ids[0]
self.assertEqual(pack_move.state, 'waiting', "Pack move wasn't created...")
self.assertEqual(pick_move.state, 'confirmed', "Pick move wasn't created...")
receipt_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
receipt_form.partner_id = self.partner
receipt_form.picking_type_id = self.picking_type_in
with receipt_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = 15
receipt = receipt_form.save()
report = self.env['report.stock.report_reception']
report_values = report._get_report_values(docids=[receipt.id])
self.assertEqual(len(report_values['sources_to_lines']), 1, "There should only be 1 line (pick move)")
def test_report_reception_5_move_splitting(self):
""" Check the complicated use cases of correct move splitting when assigning/unassigning when:
1. Qty to assign is less than delivery qty demand
2. Delivery already has some reserved quants
"""
incoming_qty = 4
outgoing_qty = 10
qty_in_stock = outgoing_qty - incoming_qty
self.env['stock.quant'].with_context(inventory_mode=True).create({
'product_id': self.product.id,
'location_id': self.stock_location.id,
'inventory_quantity': qty_in_stock
}).action_apply_inventory()
# create delivery + receipt
delivery_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
delivery_form.picking_type_id = self.picking_type_out
with delivery_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = outgoing_qty
delivery = delivery_form.save()
delivery.action_confirm()
receipt_form = Form(self.env['stock.picking'].with_context(
force_detailed_view=True
), view='stock.view_picking_form')
receipt_form.partner_id = self.partner
receipt_form.picking_type_id = self.picking_type_in
with receipt_form.move_ids_without_package.new() as move_line:
move_line.product_id = self.product
move_line.product_uom_qty = incoming_qty
receipt = receipt_form.save()
receipt.action_confirm()
self.assertEqual(len(delivery.move_ids_without_package), 1)
report = self.env['report.stock.report_reception']
# -------------------
# check report assign
# -------------------
report.action_assign(delivery.move_ids_without_package.ids, [incoming_qty], receipt.move_ids_without_package.ids)
mto_move = delivery.move_ids_without_package.filtered(lambda m: m.procure_method == 'make_to_order')
non_mto_move = delivery.move_ids_without_package - mto_move
# check that delivery move splits correctly when receipt move is assigned to it
self.assertEqual(len(delivery.move_ids_without_package), 2, "Delivery moves should have split into assigned + not assigned")
self.assertEqual(len(delivery.move_ids_without_package.mapped('move_orig_ids')), 1, "Only 1 delivery + 1 receipt move should be assigned")
self.assertEqual(len(receipt.move_ids_without_package.mapped('move_dest_ids')), 1, "Receipt move should remain unsplit")
# check that assigned (MTO) move is correctly created
self.assertEqual(len(mto_move), 1, "Only 1 delivery move should be MTO")
self.assertEqual(mto_move.product_uom_qty, incoming_qty, "Incorrect quantity split for MTO move")
self.assertEqual(mto_move.reserved_availability, 0, "Receipt is not done => assigned move can't have a reserved qty")
self.assertEqual(mto_move.state, 'waiting', "MTO move state not correctly set")
# check that non-assigned move has correct values
self.assertEqual(non_mto_move.product_uom_qty, outgoing_qty - incoming_qty, "Incorrect quantity split for non-MTO move")
self.assertEqual(non_mto_move.reserved_availability, qty_in_stock, "Reserved qty not correctly linked to non-MTO move")
self.assertEqual(non_mto_move.state, 'assigned', "Fully reserved move has not correctly set state")
# ---------------------
# check report unassign
# ---------------------
report.action_unassign([mto_move.id], incoming_qty, receipt.move_ids_without_package.ids)
self.assertEqual(mto_move.product_uom_qty, incoming_qty, "Move quantities should be unchanged")
self.assertEqual(mto_move.procure_method, 'make_to_stock', "Procure method not correctly reset")
self.assertEqual(mto_move.state, 'confirmed', "Move state not correctly reset (to non-MTO state)")
@@ -79,6 +79,26 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="reception_report">
<div class="o_setting_left_pane">
<field name="group_stock_reception_report"/>
</div>
<div class="o_setting_right_pane">
<label for="group_stock_reception_report"/>
<div class="text-muted">
View and allocate received quantities.
</div>
<div class="row mt-2" attrs="{'invisible': [('group_stock_reception_report','=',False)]}">
<field name="group_stock_auto_reception_report" class="col-lg-1 ml16 mr0"/>
<div class="col pl-0">
<label for="group_stock_auto_reception_report"/>
<div class="text-muted">
Automatically open reception report when a receipt is validated.
</div>
</div>
</div>
</div>
</div>
</div>
<h2>Barcode</h2>
<div class="row mt16 o_settings_container" name="barcode_setting_container">
@@ -261,6 +261,11 @@
class="oe_stat_button" icon="fa-cubes"
attrs="{'invisible': [('has_packages', '=', False)]}"/>
<button name="%(action_stock_report)d" icon="fa-arrow-up" class="oe_stat_button" string="Traceability" type="action" attrs="{'invisible': ['|', ('state', '!=', 'done'), ('has_tracking', '=', False)]}" groups="stock.group_production_lot"/>
<button name="action_view_reception_report" string="Allocation" type="object"
context="{'default_picking_ids': [id]}"
class="oe_stat_button" icon="fa-list"
attrs="{'invisible': ['|', ('picking_type_code', '=', 'outgoing'), ('picking_type_code', '=', False)]}"
groups="stock.group_reception_report"/>
<!-- Use the following button to avoid onchange on one2many -->
<button name="action_picking_move_tree"
class="oe_stat_button"
@@ -50,6 +50,8 @@ class StockPickingBatch(models.Model):
picking_type_id = fields.Many2one(
'stock.picking.type', 'Operation Type', check_company=True, copy=False,
readonly=True, states={'draft': [('readonly', False)]})
picking_type_code = fields.Selection(
related='picking_type_id.code')
scheduled_date = fields.Datetime(
'Scheduled Date', copy=False, store=True, readonly=False, compute="_compute_scheduled_date",
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
@@ -226,6 +228,11 @@ class StockPickingBatch(models.Model):
else:
raise UserError(_("Please add 'Done' quantities to the batch picking to create a new pack."))
def action_view_reception_report(self):
action = self.picking_ids[0].action_view_reception_report()
action['context'] = {'default_picking_ids': self.picking_ids.ids}
return action
# -------------------------------------------------------------------------
# Miscellaneous
# -------------------------------------------------------------------------
@@ -69,6 +69,7 @@
<form string="Stock Batch Transfer">
<field name="show_check_availability" invisible="1"/>
<field name="show_validate" invisible="1"/>
<field name="picking_type_code" invisible="1"/>
<header>
<button name="action_confirm" states="draft" string="Confirm" type="object" class="oe_highlight"/>
<button name="action_done" string="Validate" type="object" class="oe_highlight"
@@ -105,6 +106,12 @@
<field name="state" widget="statusbar" statusbar_visible="draft,in_progress,done"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_reception_report" string="Allocation" type="object"
class="oe_stat_button" icon="fa-list"
attrs="{'invisible': ['|', ('picking_ids', '=', []), ('picking_type_code', '=', 'outgoing')]}"
groups="stock.group_reception_report"/>
</div>
<div class="oe_title">
<h1><field name="name" class="oe_inline"/></h1>
</div>