From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- addons/stock/report/__init__.py | 7 + addons/stock/report/package_templates.xml | 18 ++ addons/stock/report/picking_templates.xml | 115 ++++++++++ addons/stock/report/product_packaging.xml | 24 ++ addons/stock/report/product_templates.xml | 98 ++++++++ addons/stock/report/report_deliveryslip.xml | 234 +++++++++++++++++++ addons/stock/report/report_location_barcode.xml | 41 ++++ addons/stock/report/report_lot_barcode.xml | 38 ++++ addons/stock/report/report_package_barcode.xml | 82 +++++++ addons/stock/report/report_stock_forecasted.py | 247 +++++++++++++++++++++ addons/stock/report/report_stock_forecasted.xml | 179 +++++++++++++++ addons/stock/report/report_stock_quantity.py | 138 ++++++++++++ addons/stock/report/report_stock_quantity.xml | 58 +++++ addons/stock/report/report_stock_rule.py | 139 ++++++++++++ addons/stock/report/report_stock_rule.xml | 189 ++++++++++++++++ addons/stock/report/report_stockinventory.xml | 56 +++++ .../report/report_stockpicking_operations.xml | 173 +++++++++++++++ addons/stock/report/stock_report_views.xml | 180 +++++++++++++++ addons/stock/report/stock_traceability.py | 243 ++++++++++++++++++++ 19 files changed, 2259 insertions(+) create mode 100644 addons/stock/report/__init__.py create mode 100644 addons/stock/report/package_templates.xml create mode 100644 addons/stock/report/picking_templates.xml create mode 100644 addons/stock/report/product_packaging.xml create mode 100644 addons/stock/report/product_templates.xml create mode 100644 addons/stock/report/report_deliveryslip.xml create mode 100644 addons/stock/report/report_location_barcode.xml create mode 100644 addons/stock/report/report_lot_barcode.xml create mode 100644 addons/stock/report/report_package_barcode.xml create mode 100644 addons/stock/report/report_stock_forecasted.py create mode 100644 addons/stock/report/report_stock_forecasted.xml create mode 100644 addons/stock/report/report_stock_quantity.py create mode 100644 addons/stock/report/report_stock_quantity.xml create mode 100644 addons/stock/report/report_stock_rule.py create mode 100644 addons/stock/report/report_stock_rule.xml create mode 100644 addons/stock/report/report_stockinventory.xml create mode 100644 addons/stock/report/report_stockpicking_operations.xml create mode 100644 addons/stock/report/stock_report_views.xml create mode 100644 addons/stock/report/stock_traceability.py (limited to 'addons/stock/report') diff --git a/addons/stock/report/__init__.py b/addons/stock/report/__init__.py new file mode 100644 index 00000000..9d26ba48 --- /dev/null +++ b/addons/stock/report/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import report_stock_forecasted +from . import report_stock_quantity +from . import report_stock_rule +from . import stock_traceability diff --git a/addons/stock/report/package_templates.xml b/addons/stock/report/package_templates.xml new file mode 100644 index 00000000..b7f924a5 --- /dev/null +++ b/addons/stock/report/package_templates.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/addons/stock/report/picking_templates.xml b/addons/stock/report/picking_templates.xml new file mode 100644 index 00000000..7b41cd08 --- /dev/null +++ b/addons/stock/report/picking_templates.xml @@ -0,0 +1,115 @@ + + + + + + + + + + diff --git a/addons/stock/report/product_packaging.xml b/addons/stock/report/product_packaging.xml new file mode 100644 index 00000000..984dbd04 --- /dev/null +++ b/addons/stock/report/product_packaging.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/addons/stock/report/product_templates.xml b/addons/stock/report/product_templates.xml new file mode 100644 index 00000000..20b0faba --- /dev/null +++ b/addons/stock/report/product_templates.xml @@ -0,0 +1,98 @@ + + + + + + + + + + diff --git a/addons/stock/report/report_deliveryslip.xml b/addons/stock/report/report_deliveryslip.xml new file mode 100644 index 00000000..24bd5e23 --- /dev/null +++ b/addons/stock/report/report_deliveryslip.xml @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + diff --git a/addons/stock/report/report_location_barcode.xml b/addons/stock/report/report_location_barcode.xml new file mode 100644 index 00000000..36493b05 --- /dev/null +++ b/addons/stock/report/report_location_barcode.xml @@ -0,0 +1,41 @@ + + + + + + + + + diff --git a/addons/stock/report/report_lot_barcode.xml b/addons/stock/report/report_lot_barcode.xml new file mode 100644 index 00000000..722c11ab --- /dev/null +++ b/addons/stock/report/report_lot_barcode.xml @@ -0,0 +1,38 @@ + + + + + + diff --git a/addons/stock/report/report_package_barcode.xml b/addons/stock/report/report_package_barcode.xml new file mode 100644 index 00000000..6e0f9f55 --- /dev/null +++ b/addons/stock/report/report_package_barcode.xml @@ -0,0 +1,82 @@ + + + + + + + + diff --git a/addons/stock/report/report_stock_forecasted.py b/addons/stock/report/report_stock_forecasted.py new file mode 100644 index 00000000..80844230 --- /dev/null +++ b/addons/stock/report/report_stock_forecasted.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict + +from odoo import api, models +from odoo.tools import float_is_zero, format_datetime, format_date, float_round + + +class ReplenishmentReport(models.AbstractModel): + _name = 'report.stock.report_product_product_replenishment' + _description = "Stock Replenishment Report" + + def _product_domain(self, product_template_ids, product_variant_ids): + if product_template_ids: + return [('product_tmpl_id', 'in', product_template_ids)] + return [('product_id', 'in', product_variant_ids)] + + def _move_domain(self, product_template_ids, product_variant_ids, wh_location_ids): + move_domain = self._product_domain(product_template_ids, product_variant_ids) + move_domain += [('product_uom_qty', '!=', 0)] + out_domain = move_domain + [ + '&', + ('location_id', 'in', wh_location_ids), + ('location_dest_id', 'not in', wh_location_ids), + ] + in_domain = move_domain + [ + '&', + ('location_id', 'not in', wh_location_ids), + ('location_dest_id', 'in', wh_location_ids), + ] + return in_domain, out_domain + + def _move_draft_domain(self, product_template_ids, product_variant_ids, wh_location_ids): + in_domain, out_domain = self._move_domain(product_template_ids, product_variant_ids, wh_location_ids) + in_domain += [('state', '=', 'draft')] + out_domain += [('state', '=', 'draft')] + return in_domain, out_domain + + def _move_confirmed_domain(self, product_template_ids, product_variant_ids, wh_location_ids): + in_domain, out_domain = self._move_domain(product_template_ids, product_variant_ids, wh_location_ids) + out_domain += [('state', 'not in', ['draft', 'cancel', 'done'])] + in_domain += [('state', 'not in', ['draft', 'cancel', 'done'])] + return in_domain, out_domain + + def _compute_draft_quantity_count(self, product_template_ids, product_variant_ids, wh_location_ids): + in_domain, out_domain = self._move_draft_domain(product_template_ids, product_variant_ids, wh_location_ids) + incoming_moves = self.env['stock.move'].read_group(in_domain, ['product_qty:sum'], 'product_id') + outgoing_moves = self.env['stock.move'].read_group(out_domain, ['product_qty:sum'], 'product_id') + in_sum = sum(move['product_qty'] for move in incoming_moves) + out_sum = sum(move['product_qty'] for move in outgoing_moves) + return { + 'draft_picking_qty': { + 'in': in_sum, + 'out': out_sum + }, + 'qty': { + 'in': in_sum, + 'out': out_sum + } + } + + @api.model + def _get_report_values(self, docids, data=None): + return { + 'data': data, + 'doc_ids': docids, + 'doc_model': 'product.product', + 'docs': self._get_report_data(product_variant_ids=docids), + } + + def _get_report_data(self, product_template_ids=False, product_variant_ids=False): + assert product_template_ids or product_variant_ids + res = {} + + # Get the warehouse we're working on as well as its locations. + if self.env.context.get('warehouse'): + warehouse = self.env['stock.warehouse'].browse(self.env.context['warehouse']) + else: + warehouse = self.env['stock.warehouse'].search([ + ('company_id', '=', self.env.company.id) + ], limit=1) + self.env.context = dict(self.env.context, warehouse=warehouse.id) + wh_location_ids = [loc['id'] for loc in self.env['stock.location'].search_read( + [('id', 'child_of', warehouse.view_location_id.id)], + ['id'], + )] + res['active_warehouse'] = warehouse.display_name + + # Get the products we're working, fill the rendering context with some of their attributes. + if product_template_ids: + product_templates = self.env['product.template'].browse(product_template_ids) + res['product_templates'] = product_templates + res['product_variants'] = product_templates.product_variant_ids + res['multiple_product'] = len(product_templates.product_variant_ids) > 1 + res['uom'] = product_templates[:1].uom_id.display_name + res['quantity_on_hand'] = sum(product_templates.mapped('qty_available')) + res['virtual_available'] = sum(product_templates.mapped('virtual_available')) + elif product_variant_ids: + product_variants = self.env['product.product'].browse(product_variant_ids) + res['product_templates'] = False + res['product_variants'] = product_variants + res['multiple_product'] = len(product_variants) > 1 + res['uom'] = product_variants[:1].uom_id.display_name + res['quantity_on_hand'] = sum(product_variants.mapped('qty_available')) + res['virtual_available'] = sum(product_variants.mapped('virtual_available')) + res.update(self._compute_draft_quantity_count(product_template_ids, product_variant_ids, wh_location_ids)) + + res['lines'] = self._get_report_lines(product_template_ids, product_variant_ids, wh_location_ids) + return res + + def _prepare_report_line(self, quantity, move_out=None, move_in=None, replenishment_filled=True, product=False, reservation=False): + timezone = self._context.get('tz') + product = product or (move_out.product_id if move_out else move_in.product_id) + is_late = move_out.date < move_in.date if (move_out and move_in) else False + return { + 'document_in': move_in._get_source_document() if move_in else False, + 'document_out': move_out._get_source_document() if move_out else False, + 'product': { + 'id': product.id, + 'display_name': product.display_name + }, + 'replenishment_filled': replenishment_filled, + 'uom_id': product.uom_id, + 'receipt_date': format_datetime(self.env, move_in.date, timezone, dt_format=False) if move_in else False, + 'delivery_date': format_datetime(self.env, move_out.date, timezone, dt_format=False) if move_out else False, + 'is_late': is_late, + 'quantity': float_round(quantity, precision_rounding=product.uom_id.rounding), + 'move_out': move_out, + 'move_in': move_in, + 'reservation': reservation, + } + + def _get_report_lines(self, product_template_ids, product_variant_ids, wh_location_ids): + def _rollup_move_dests(move, seen): + for dst in move.move_dest_ids: + if dst.id not in seen: + seen.add(dst.id) + _rollup_move_dests(dst, seen) + return seen + + def _reconcile_out_with_ins(lines, out, ins, demand, only_matching_move_dest=True): + index_to_remove = [] + for index, in_ in enumerate(ins): + if float_is_zero(in_['qty'], precision_rounding=out.product_id.uom_id.rounding): + continue + if only_matching_move_dest and in_['move_dests'] and out.id not in in_['move_dests']: + continue + taken_from_in = min(demand, in_['qty']) + demand -= taken_from_in + lines.append(self._prepare_report_line(taken_from_in, move_in=in_['move'], move_out=out)) + in_['qty'] -= taken_from_in + if in_['qty'] <= 0: + index_to_remove.append(index) + if float_is_zero(demand, precision_rounding=out.product_id.uom_id.rounding): + break + for index in index_to_remove[::-1]: + ins.pop(index) + return demand + + in_domain, out_domain = self._move_confirmed_domain( + product_template_ids, product_variant_ids, wh_location_ids + ) + outs = self.env['stock.move'].search(out_domain, order='priority desc, date, id') + outs_per_product = defaultdict(lambda: []) + for out in outs: + outs_per_product[out.product_id.id].append(out) + ins = self.env['stock.move'].search(in_domain, order='priority desc, date, id') + ins_per_product = defaultdict(lambda: []) + for in_ in ins: + ins_per_product[in_.product_id.id].append({ + 'qty': in_.product_qty, + 'move': in_, + 'move_dests': _rollup_move_dests(in_, set()) + }) + currents = {c['id']: c['qty_available'] for c in outs.product_id.read(['qty_available'])} + + lines = [] + for product in (ins | outs).product_id: + for out in outs_per_product[product.id]: + if out.state not in ('partially_available', 'assigned'): + continue + current = currents[out.product_id.id] + reserved = out.product_uom._compute_quantity(out.reserved_availability, product.uom_id) + currents[product.id] -= reserved + lines.append(self._prepare_report_line(reserved, move_out=out, reservation=True)) + + unreconciled_outs = [] + for out in outs_per_product[product.id]: + # Reconcile with the current stock. + current = currents[out.product_id.id] + reserved = 0.0 + if out.state in ('partially_available', 'assigned'): + reserved = out.product_uom._compute_quantity(out.reserved_availability, product.uom_id) + demand = out.product_qty - reserved + taken_from_stock = min(demand, current) + if not float_is_zero(taken_from_stock, precision_rounding=product.uom_id.rounding): + currents[product.id] -= taken_from_stock + demand -= taken_from_stock + lines.append(self._prepare_report_line(taken_from_stock, move_out=out)) + # Reconcile with the ins. + if not float_is_zero(demand, precision_rounding=product.uom_id.rounding): + demand = _reconcile_out_with_ins(lines, out, ins_per_product[out.product_id.id], demand, only_matching_move_dest=True) + if not float_is_zero(demand, precision_rounding=product.uom_id.rounding): + unreconciled_outs.append((demand, out)) + if unreconciled_outs: + for (demand, out) in unreconciled_outs: + # Another pass, in case there are some ins linked to a dest move but that still have some quantity available + demand = _reconcile_out_with_ins(lines, out, ins_per_product[product.id], demand, only_matching_move_dest=False) + if not float_is_zero(demand, precision_rounding=product.uom_id.rounding): + # Not reconciled + lines.append(self._prepare_report_line(demand, move_out=out, replenishment_filled=False)) + # Unused remaining stock. + free_stock = currents.get(product.id, 0) + if not float_is_zero(free_stock, precision_rounding=product.uom_id.rounding): + lines.append(self._prepare_report_line(free_stock, product=product)) + # In moves not used. + for in_ in ins_per_product[product.id]: + if float_is_zero(in_['qty'], precision_rounding=product.uom_id.rounding): + continue + lines.append(self._prepare_report_line(in_['qty'], move_in=in_['move'])) + return lines + + @api.model + def get_filter_state(self): + res = {} + res['warehouses'] = self.env['stock.warehouse'].search_read(fields=['id', 'name', 'code']) + res['active_warehouse'] = self.env.context.get('warehouse', False) + if not res['active_warehouse']: + company_id = self.env.context.get('allowed_company_ids')[0] + res['active_warehouse'] = self.env['stock.warehouse'].search([('company_id', '=', company_id)], limit=1).id + return res + + +class ReplenishmentTemplateReport(models.AbstractModel): + _name = 'report.stock.report_product_template_replenishment' + _description = "Stock Replenishment Report" + _inherit = 'report.stock.report_product_product_replenishment' + + @api.model + def _get_report_values(self, docids, data=None): + return { + 'data': data, + 'doc_ids': docids, + 'doc_model': 'product.product', + 'docs': self._get_report_data(product_template_ids=docids), + } diff --git a/addons/stock/report/report_stock_forecasted.xml b/addons/stock/report/report_stock_forecasted.xml new file mode 100644 index 00000000..61748f6c --- /dev/null +++ b/addons/stock/report/report_stock_forecasted.xml @@ -0,0 +1,179 @@ + + + + + Forecasted Report + product.product + qweb-html + stock.report_product_product_replenishment + + + + Forecasted Report + product.template + qweb-html + stock.report_product_template_replenishment + + + + Forecasted Report + replenish_report + + + + + + + + + + + diff --git a/addons/stock/report/report_stock_quantity.py b/addons/stock/report/report_stock_quantity.py new file mode 100644 index 00000000..c54f1b1a --- /dev/null +++ b/addons/stock/report/report_stock_quantity.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, tools, api + + +class ReportStockQuantity(models.Model): + _name = 'report.stock.quantity' + _auto = False + _description = 'Stock Quantity Report' + + date = fields.Date(string='Date', readonly=True) + product_tmpl_id = fields.Many2one('product.template', related='product_id.product_tmpl_id') + product_id = fields.Many2one('product.product', string='Product', readonly=True) + state = fields.Selection([ + ('forecast', 'Forecasted Stock'), + ('in', 'Forecasted Receipts'), + ('out', 'Forecasted Deliveries'), + ], string='State', readonly=True) + product_qty = fields.Float(string='Quantity', readonly=True) + move_ids = fields.One2many('stock.move', readonly=True) + company_id = fields.Many2one('res.company', readonly=True) + warehouse_id = fields.Many2one('stock.warehouse', readonly=True) + + def init(self): + tools.drop_view_if_exists(self._cr, 'report_stock_quantity') + query = """ +CREATE or REPLACE VIEW report_stock_quantity AS ( +SELECT + MIN(id) as id, + product_id, + state, + date, + sum(product_qty) as product_qty, + company_id, + warehouse_id +FROM (SELECT + m.id, + m.product_id, + CASE + WHEN (whs.id IS NOT NULL AND whd.id IS NULL) OR ls.usage = 'transit' THEN 'out' + WHEN (whs.id IS NULL AND whd.id IS NOT NULL) OR ld.usage = 'transit' THEN 'in' + END AS state, + m.date::date AS date, + CASE + WHEN (whs.id IS NOT NULL AND whd.id IS NULL) OR ls.usage = 'transit' THEN -product_qty + WHEN (whs.id IS NULL AND whd.id IS NOT NULL) OR ld.usage = 'transit' THEN product_qty + END AS product_qty, + m.company_id, + CASE + WHEN (whs.id IS NOT NULL AND whd.id IS NULL) OR ls.usage = 'transit' THEN whs.id + WHEN (whs.id IS NULL AND whd.id IS NOT NULL) OR ld.usage = 'transit' THEN whd.id + END AS warehouse_id + FROM + stock_move m + LEFT JOIN stock_location ls on (ls.id=m.location_id) + LEFT JOIN stock_location ld on (ld.id=m.location_dest_id) + LEFT JOIN stock_warehouse whs ON ls.parent_path like concat('%/', whs.view_location_id, '/%') + LEFT JOIN stock_warehouse whd ON ld.parent_path like concat('%/', whd.view_location_id, '/%') + LEFT JOIN product_product pp on pp.id=m.product_id + LEFT JOIN product_template pt on pt.id=pp.product_tmpl_id + WHERE + pt.type = 'product' AND + product_qty != 0 AND + (whs.id IS NOT NULL OR whd.id IS NOT NULL) AND + (whs.id IS NULL OR whd.id IS NULL OR whs.id != whd.id) AND + m.state NOT IN ('cancel', 'draft', 'done') + UNION ALL + SELECT + -q.id as id, + q.product_id, + 'forecast' as state, + date.*::date, + q.quantity as product_qty, + q.company_id, + wh.id as warehouse_id + FROM + GENERATE_SERIES((now() at time zone 'utc')::date - interval '3month', + (now() at time zone 'utc')::date + interval '3 month', '1 day'::interval) date, + stock_quant q + LEFT JOIN stock_location l on (l.id=q.location_id) + LEFT JOIN stock_warehouse wh ON l.parent_path like concat('%/', wh.view_location_id, '/%') + WHERE + (l.usage = 'internal' AND wh.id IS NOT NULL) OR + l.usage = 'transit' + UNION ALL + SELECT + m.id, + m.product_id, + 'forecast' as state, + GENERATE_SERIES( + CASE + WHEN m.state = 'done' THEN (now() at time zone 'utc')::date - interval '3month' + ELSE m.date::date + END, + CASE + WHEN m.state != 'done' THEN (now() at time zone 'utc')::date + interval '3 month' + ELSE m.date::date - interval '1 day' + END, '1 day'::interval)::date date, + CASE + WHEN ((whs.id IS NOT NULL AND whd.id IS NULL) OR ls.usage = 'transit') AND m.state = 'done' THEN product_qty + WHEN ((whs.id IS NULL AND whd.id IS NOT NULL) OR ld.usage = 'transit') AND m.state = 'done' THEN -product_qty + WHEN (whs.id IS NOT NULL AND whd.id IS NULL) OR ls.usage = 'transit' THEN -product_qty + WHEN (whs.id IS NULL AND whd.id IS NOT NULL) OR ld.usage = 'transit' THEN product_qty + END AS product_qty, + m.company_id, + CASE + WHEN (whs.id IS NOT NULL AND whd.id IS NULL) OR ls.usage = 'transit' THEN whs.id + WHEN (whs.id IS NULL AND whd.id IS NOT NULL) OR ld.usage = 'transit' THEN whd.id + END AS warehouse_id + FROM + stock_move m + LEFT JOIN stock_location ls on (ls.id=m.location_id) + LEFT JOIN stock_location ld on (ld.id=m.location_dest_id) + LEFT JOIN stock_warehouse whs ON ls.parent_path like concat('%/', whs.view_location_id, '/%') + LEFT JOIN stock_warehouse whd ON ld.parent_path like concat('%/', whd.view_location_id, '/%') + LEFT JOIN product_product pp on pp.id=m.product_id + LEFT JOIN product_template pt on pt.id=pp.product_tmpl_id + WHERE + pt.type = 'product' AND + product_qty != 0 AND + (whs.id IS NOT NULL OR whd.id IS NOT NULL) AND + (whs.id IS NULL or whd.id IS NULL OR whs.id != whd.id) AND + m.state NOT IN ('cancel', 'draft')) AS forecast_qty +GROUP BY product_id, state, date, company_id, warehouse_id +); +""" + self.env.cr.execute(query) + + @api.model + def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): + for i in range(len(domain)): + if domain[i][0] == 'product_tmpl_id' and domain[i][1] in ('=', 'in'): + tmpl = self.env['product.template'].browse(domain[i][2]) + # Avoid the subquery done for the related, the postgresql will plan better with the SQL view + # and then improve a lot the performance for the forecasted report of the product template. + domain[i] = ('product_id', 'in', tmpl.with_context(active_test=False).product_variant_ids.ids) + return super().read_group(domain, fields, groupby, offset, limit, orderby, lazy) diff --git a/addons/stock/report/report_stock_quantity.xml b/addons/stock/report/report_stock_quantity.xml new file mode 100644 index 00000000..61018561 --- /dev/null +++ b/addons/stock/report/report_stock_quantity.xml @@ -0,0 +1,58 @@ + + + + stock_report_view_graph + report.stock.quantity + + + + + + + + + + + report.stock.quantity.search + report.stock.quantity + + + + + + + + + + + + + + + + + + + + + + Forecasted Inventory + report.stock.quantity + graph + { + 'search_default_filter_forecast': 1, + 'graph_groupbys': ['date:day', 'state', 'product_id'], + } + + + + Forecasted Inventory + report.stock.quantity + graph + { + 'search_default_filter_forecast': 1, + 'graph_groupbys': ['date:day', 'state', 'product_id'], + } + + + diff --git a/addons/stock/report/report_stock_rule.py b/addons/stock/report/report_stock_rule.py new file mode 100644 index 00000000..ad2fc264 --- /dev/null +++ b/addons/stock/report/report_stock_rule.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, _ +from odoo.exceptions import UserError + + +class ReportStockRule(models.AbstractModel): + _name = 'report.stock.report_stock_rule' + _description = 'Stock rule report' + + @api.model + def _get_report_values(self, docids, data=None): + # Overriding data values here since used also in _get_routes. + data['product_id'] = data.get('product_id', docids) + data['warehouse_ids'] = data.get('warehouse_ids', []) + + product = self.env['product.product'].browse(data['product_id']) + warehouses = self.env['stock.warehouse'].browse(data['warehouse_ids']) + + routes = self._get_routes(data) + + # Some routes don't have a warehouse_id but contain rules of different warehouses, + # we filter here the ones we want to display and build for each one a dict containing the rule, + # their source and destination location. + relevant_rules = routes.mapped('rule_ids').filtered(lambda r: not r.warehouse_id or r.warehouse_id in warehouses) + rules_and_loc = [] + for rule in relevant_rules: + rules_and_loc.append(self._get_rule_loc(rule, product)) + + locations = self._sort_locations(rules_and_loc, warehouses) + reordering_rules = self.env['stock.warehouse.orderpoint'].search([('product_id', '=', product.id)]) + locations |= reordering_rules.mapped('location_id').filtered(lambda l: l not in locations) + locations_names = locations.mapped('display_name') + # Here we handle reordering rules and putaway strategies by creating the header_lines dict. This dict is indexed + # by location_id and contains itself another dict with the relevant reordering rules and putaway strategies. + header_lines = {} + for location in locations: + # TODO: group the RR by location_id to avoid a filtered at each loop + rr = reordering_rules.filtered(lambda r: r.location_id.id == location.id) + putaways = product.putaway_rule_ids.filtered(lambda p: p.location_in_id.id == location.id) + if putaways or rr: + header_lines[location.id] = {'putaway': [], 'orderpoint': []} + for putaway in putaways: + header_lines[location.id]['putaway'].append(putaway) + for r in rr: + header_lines[location.id]['orderpoint'].append(r) + route_lines = [] + colors = self._get_route_colors() + for color_index, route in enumerate(routes): + rules_to_display = route.rule_ids & relevant_rules + if rules_to_display: + route_color = colors[color_index % len(colors)] + color_index = color_index + 1 + for rule in rules_to_display: + rule_loc = [r for r in rules_and_loc if r['rule'] == rule][0] + res = [] + for x in range(len(locations_names)): + res.append([]) + idx = locations_names.index(rule_loc['destination'].display_name) + tpl = (rule, 'destination', route_color, ) + res[idx] = tpl + idx = locations_names.index(rule_loc['source'].display_name) + tpl = (rule, 'origin', route_color, ) + res[idx] = tpl + route_lines.append(res) + return { + 'docs': product, + 'locations': locations, + 'header_lines': header_lines, + 'route_lines': route_lines, + } + + @api.model + def _get_route_colors(self): + return ['#FFA500', '#800080', '#228B22', '#008B8B', '#4682B4', '#FF0000', '#32CD32'] + + @api.model + def _get_routes(self, data): + """ Extract the routes to display from the wizard's content. + """ + product = self.env['product.product'].browse(data['product_id']) + warehouse_ids = self.env['stock.warehouse'].browse(data['warehouse_ids']) + return product.route_ids | product.categ_id.total_route_ids | warehouse_ids.mapped('route_ids') + + @api.model + def _get_rule_loc(self, rule, product): + rule.ensure_one() + return {'rule': rule, 'source': rule.location_src_id, 'destination': rule.location_id} + + @api.model + def _sort_locations(self, rules_and_loc, warehouses): + """ We order the locations by setting first the locations of type supplier and manufacture, + then we add the locations grouped by warehouse and we finish by the locations of type + customer and the ones that were not added by the sort. + """ + all_src = self.env['stock.location'].concat(*([r['source'] for r in rules_and_loc])) + all_dest = self.env['stock.location'].concat(*([r['destination'] for r in rules_and_loc])) + all_locations = all_src | all_dest + ordered_locations = self.env['stock.location'] + locations = all_locations.filtered(lambda l: l.usage in ('supplier', 'production')) + for warehouse_id in warehouses: + all_warehouse_locations = all_locations.filtered(lambda l: l.get_warehouse() == warehouse_id) + starting_rules = [d for d in rules_and_loc if d['source'] not in all_warehouse_locations] + if starting_rules: + start_locations = self.env['stock.location'].concat(*([r['destination'] for r in starting_rules])) + else: + starting_rules = [d for d in rules_and_loc if d['source'] not in all_dest] + start_locations = self.env['stock.location'].concat(*([r['source'] for r in starting_rules])) + used_rules = self.env['stock.rule'] + locations |= self._sort_locations_by_warehouse(rules_and_loc, used_rules, start_locations, ordered_locations, warehouse_id) + if any(location not in locations for location in all_warehouse_locations): + remaining_locations = self.env['stock.location'].concat(*([r['source'] for r in rules_and_loc])).filtered(lambda l: l not in locations) + locations |= self._sort_locations_by_warehouse(rules_and_loc, used_rules, remaining_locations, ordered_locations, warehouse_id) + locations |= all_locations.filtered(lambda l: l.usage in ('customer')) + locations |= all_locations.filtered(lambda l: l not in locations) + return locations + + @api.model + def _sort_locations_by_warehouse(self, rules_and_loc, used_rules, start_locations, ordered_locations, warehouse_id): + """ We order locations by putting first the locations that are not the destination of others and do it recursively. + """ + start_locations = start_locations.filtered(lambda l: l.get_warehouse() == warehouse_id) + ordered_locations |= start_locations + rules_start = [] + for rule in rules_and_loc: + if rule['source'] in start_locations: + rules_start.append(rule) + used_rules |= rule['rule'] + if rules_start: + rules_start_dest_locations = self.env['stock.location'].concat(*([r['destination'] for r in rules_start])) + remaining_rules = self.env['stock.rule'].concat(*([r['rule'] for r in rules_and_loc])) - used_rules + remaining_rules_location = self.env['stock.location'] + for r in rules_and_loc: + if r['rule'] in remaining_rules: + remaining_rules_location |= r['destination'] + start_locations = rules_start_dest_locations - ordered_locations - remaining_rules_location + ordered_locations = self._sort_locations_by_warehouse(rules_and_loc, used_rules, start_locations, ordered_locations, warehouse_id) + return ordered_locations diff --git a/addons/stock/report/report_stock_rule.xml b/addons/stock/report/report_stock_rule.xml new file mode 100644 index 00000000..21cd39ae --- /dev/null +++ b/addons/stock/report/report_stock_rule.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + diff --git a/addons/stock/report/report_stockinventory.xml b/addons/stock/report/report_stockinventory.xml new file mode 100644 index 00000000..2aff98f1 --- /dev/null +++ b/addons/stock/report/report_stockinventory.xml @@ -0,0 +1,56 @@ + + + + + + diff --git a/addons/stock/report/report_stockpicking_operations.xml b/addons/stock/report/report_stockpicking_operations.xml new file mode 100644 index 00000000..dacc8ff8 --- /dev/null +++ b/addons/stock/report/report_stockpicking_operations.xml @@ -0,0 +1,173 @@ + + + + + + + + diff --git a/addons/stock/report/stock_report_views.xml b/addons/stock/report/stock_report_views.xml new file mode 100644 index 00000000..dea0838f --- /dev/null +++ b/addons/stock/report/stock_report_views.xml @@ -0,0 +1,180 @@ + + + + + Picking Operations + stock.picking + qweb-pdf + stock.report_picking + stock.report_picking_operations + 'Picking Operations - %s - %s' % (object.partner_id.name or '', object.name) + + report + + + Delivery Slip + stock.picking + qweb-pdf + stock.report_deliveryslip + stock.report_deliveryslip + 'Delivery Slip - %s - %s' % (object.partner_id.name or '', object.name) + + report + + + Count Sheet + stock.inventory + qweb-pdf + stock.report_inventory + stock.report_inventory + 'Inventory - %s' % (object.name) + + report + + + Package Barcode with Content + stock.quant.package + qweb-pdf + stock.report_package_barcode + stock.report_package_barcode + + report + + + Package Barcode (PDF) + stock.quant.package + qweb-pdf + stock.report_package_barcode_small + stock.report_package_barcode + + report + + + Location Barcode + stock.location + qweb-pdf + stock.report_location_barcode + stock.report_location_barcode + 'Location - %s' % object.name + + report + + + Lot/Serial Number (PDF) + stock.production.lot + qweb-pdf + stock.report_lot_label + stock.report_lot_label + 'Lot-Serial - %s' % object.name + + report + + + Operation type (PDF) + stock.picking.type + qweb-pdf + stock.report_picking_type_label + stock.report_picking_type_label + 'Operation-type - %s' % object.name + + report + + + Product Routes Report + product.template + qweb-html + stock.report_stock_rule + stock.report_stock_rule + + + Product Label (ZPL) + product.template + qweb-text + stock.label_product_template_view + stock.label_product_template_view + + report + + + Product Label (ZPL) + product.product + qweb-text + stock.label_product_product_view + stock.label_product_product_view + + report + + + Product Barcode (ZPL) + product.template + qweb-text + stock.label_barcode_product_template_view + stock.label_barcode_product_template_view + + report + + + Product Barcode (ZPL) + product.product + qweb-text + stock.label_barcode_product_product_view + stock.label_barcode_product_product_view + + report + + + Lot/Serial Number (ZPL) + stock.production.lot + qweb-text + stock.label_lot_template_view + stock.label_lot_template_view + + report + + + Barcodes (ZPL) + stock.picking + qweb-text + stock.label_transfer_template_view_zpl + stock.label_transfer_template_view_zpl + + report + + + Barcodes (PDF) + stock.picking + qweb-pdf + stock.label_transfer_template_view_pdf + stock.label_transfer_template_view_pdf + + report + + + Package Barcode (ZPL) + stock.quant.package + qweb-text + stock.label_package_template_view + stock.label_package_template_view + + report + + + Product Packaging (ZPL) + product.packaging + qweb-text + stock.label_product_packaging_view + stock.label_product_packaging_view + + report + + + Operation type (ZPL) + stock.picking.type + qweb-text + stock.label_picking_type_view + stock.label_picking_type_view + + report + + + diff --git a/addons/stock/report/stock_traceability.py b/addons/stock/report/stock_traceability.py new file mode 100644 index 00000000..42495ea5 --- /dev/null +++ b/addons/stock/report/stock_traceability.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, _ +from odoo.tools import config +from odoo.tools import format_datetime + + +rec = 0 +def autoIncrement(): + global rec + pStart = 1 + pInterval = 1 + if rec == 0: + rec = pStart + else: + rec += pInterval + return rec + + +class MrpStockReport(models.TransientModel): + _name = 'stock.traceability.report' + _description = 'Traceability Report' + + @api.model + def _get_move_lines(self, move_lines, line_id=None): + lines_seen = move_lines + lines_todo = list(move_lines) + while lines_todo: + move_line = lines_todo.pop(0) + # if MTO + if move_line.move_id.move_orig_ids: + lines = move_line.move_id.move_orig_ids.mapped('move_line_ids').filtered( + lambda m: m.lot_id == move_line.lot_id and m.state == 'done' + ) - lines_seen + # if MTS + elif move_line.location_id.usage == 'internal': + lines = self.env['stock.move.line'].search([ + ('product_id', '=', move_line.product_id.id), + ('lot_id', '=', move_line.lot_id.id), + ('location_dest_id', '=', move_line.location_id.id), + ('id', 'not in', lines_seen.ids), + ('date', '<=', move_line.date), + ('state', '=', 'done') + ]) + else: + continue + if line_id is None or line_id in lines.ids: + lines_todo += list(lines) + lines_seen |= lines + return lines_seen - move_lines + + @api.model + def get_lines(self, line_id=None, **kw): + context = dict(self.env.context) + model = kw and kw['model_name'] or context.get('model') + rec_id = kw and kw['model_id'] or context.get('active_id') + level = kw and kw['level'] or 1 + lines = self.env['stock.move.line'] + move_line = self.env['stock.move.line'] + if rec_id and model == 'stock.production.lot': + lines = move_line.search([ + ('lot_id', '=', context.get('lot_name') or rec_id), + ('state', '=', 'done'), + ]) + elif rec_id and model == 'stock.move.line' and context.get('lot_name'): + record = self.env[model].browse(rec_id) + dummy, is_used = self._get_linked_move_lines(record) + if is_used: + lines = is_used + elif rec_id and model in ('stock.picking', 'mrp.production'): + record = self.env[model].browse(rec_id) + if model == 'stock.picking': + lines = record.move_lines.mapped('move_line_ids').filtered(lambda m: m.lot_id and m.state == 'done') + else: + lines = record.move_finished_ids.mapped('move_line_ids').filtered(lambda m: m.state == 'done') + move_line_vals = self._lines(line_id, model_id=rec_id, model=model, level=level, move_lines=lines) + final_vals = sorted(move_line_vals, key=lambda v: v['date'], reverse=True) + lines = self._final_vals_to_lines(final_vals, level) + return lines + + @api.model + def _get_reference(self, move_line): + res_model = '' + ref = '' + res_id = False + picking_id = move_line.picking_id or move_line.move_id.picking_id + if picking_id: + res_model = 'stock.picking' + res_id = picking_id.id + ref = picking_id.name + elif move_line.move_id.inventory_id: + res_model = 'stock.inventory' + res_id = move_line.move_id.inventory_id.id + ref = 'Inv. Adj.: ' + move_line.move_id.inventory_id.name + elif move_line.move_id.scrapped and move_line.move_id.scrap_ids: + res_model = 'stock.scrap' + res_id = move_line.move_id.scrap_ids[0].id + ref = move_line.move_id.scrap_ids[0].name + return res_model, res_id, ref + + @api.model + def _quantity_to_str(self, from_uom, to_uom, qty): + """ workaround to apply the float rounding logic of t-esc on data prepared server side """ + qty = from_uom._compute_quantity(qty, to_uom, rounding_method='HALF-UP') + return self.env['ir.qweb.field.float'].value_to_html(qty, {'decimal_precision': 'Product Unit of Measure'}) + + def _get_usage(self, move_line): + usage = '' + if (move_line.location_id.usage == 'internal') and (move_line.location_dest_id.usage == 'internal'): + usage = 'internal' + elif (move_line.location_id.usage != 'internal') and (move_line.location_dest_id.usage == 'internal'): + usage = 'in' + else: + usage = 'out' + return usage + + def _make_dict_move(self, level, parent_id, move_line, unfoldable=False): + res_model, res_id, ref = self._get_reference(move_line) + dummy, is_used = self._get_linked_move_lines(move_line) + data = [{ + 'level': level, + 'unfoldable': unfoldable, + 'date': move_line.move_id.date, + 'parent_id': parent_id, + 'is_used': bool(is_used), + 'usage': self._get_usage(move_line), + 'model_id': move_line.id, + 'model': 'stock.move.line', + 'product_id': move_line.product_id.display_name, + 'product_qty_uom': "%s %s" % (self._quantity_to_str(move_line.product_uom_id, move_line.product_id.uom_id, move_line.qty_done), move_line.product_id.uom_id.name), + 'lot_name': move_line.lot_id.name, + 'lot_id': move_line.lot_id.id, + 'location_source': move_line.location_id.name, + 'location_destination': move_line.location_dest_id.name, + 'reference_id': ref, + 'res_id': res_id, + 'res_model': res_model}] + return data + + @api.model + def _final_vals_to_lines(self, final_vals, level): + lines = [] + for data in final_vals: + lines.append({ + 'id': autoIncrement(), + 'model': data['model'], + 'model_id': data['model_id'], + 'parent_id': data['parent_id'], + 'usage': data.get('usage', False), + 'is_used': data.get('is_used', False), + 'lot_name': data.get('lot_name', False), + 'lot_id': data.get('lot_id', False), + 'reference': data.get('reference_id', False), + 'res_id': data.get('res_id', False), + 'res_model': data.get('res_model', False), + 'columns': [data.get('reference_id', False), + data.get('product_id', False), + format_datetime(self.env, data.get('date', False), tz=False, dt_format=False), + data.get('lot_name', False), + data.get('location_source', False), + data.get('location_destination', False), + data.get('product_qty_uom', 0)], + 'level': level, + 'unfoldable': data['unfoldable'], + }) + return lines + + def _get_linked_move_lines(self, move_line): + """ This method will return the consumed line or produced line for this operation.""" + return False, False + + @api.model + def _lines(self, line_id=None, model_id=False, model=False, level=0, move_lines=[], **kw): + final_vals = [] + lines = move_lines or [] + if model and line_id: + move_line = self.env[model].browse(model_id) + move_lines, is_used = self._get_linked_move_lines(move_line) + if move_lines: + lines = move_lines + else: + # Traceability in case of consumed in. + lines = self._get_move_lines(move_line, line_id=line_id) + for line in lines: + unfoldable = False + if line.consume_line_ids or ( line.lot_id and self._get_move_lines(line) and model != "stock.production.lot"): + unfoldable = True + final_vals += self._make_dict_move(level, parent_id=line_id, move_line=line, unfoldable=unfoldable) + return final_vals + + def get_pdf_lines(self, line_data=[]): + lines = [] + for line in line_data: + model = self.env[line['model_name']].browse(line['model_id']) + unfoldable = False + if line.get('unfoldable'): + unfoldable = True + final_vals = self._make_dict_move(line['level'], parent_id=line['id'], move_line=model, unfoldable=unfoldable) + lines.append(self._final_vals_to_lines(final_vals, line['level'])[0]) + return lines + + def get_pdf(self, line_data=[]): + lines = self.with_context(print_mode=True).get_pdf_lines(line_data) + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + rcontext = { + 'mode': 'print', + 'base_url': base_url, + } + + context = dict(self.env.context) + if not config['test_enable']: + context['commit_assetsbundle'] = True + + body = self.env['ir.ui.view'].with_context(context)._render_template( + "stock.report_stock_inventory_print", + values=dict(rcontext, lines=lines, report=self, context=self), + ) + + header = self.env['ir.actions.report']._render_template("web.internal_layout", values=rcontext) + header = self.env['ir.actions.report']._render_template("web.minimal_layout", values=dict(rcontext, subst=True, body=header)) + + return self.env['ir.actions.report']._run_wkhtmltopdf( + [body], + header=header, + landscape=True, + specific_paperformat_args={'data-report-margin-top': 10, 'data-report-header-spacing': 10} + ) + + def _get_html(self): + result = {} + rcontext = {} + context = dict(self.env.context) + rcontext['lines'] = self.with_context(context).get_lines() + result['html'] = self.env.ref('stock.report_stock_inventory')._render(rcontext) + return result + + @api.model + def get_html(self, given_context=None): + res = self.search([('create_uid', '=', self.env.uid)], limit=1) + if not res: + return self.create({}).with_context(given_context)._get_html() + return res.with_context(given_context)._get_html() -- cgit v1.2.3