diff --git a/setup/stock_production_lot_traceability_shipment_report/odoo/addons/stock_production_lot_traceability_shipment_report b/setup/stock_production_lot_traceability_shipment_report/odoo/addons/stock_production_lot_traceability_shipment_report new file mode 120000 index 0000000..e661726 --- /dev/null +++ b/setup/stock_production_lot_traceability_shipment_report/odoo/addons/stock_production_lot_traceability_shipment_report @@ -0,0 +1 @@ +../../../../stock_production_lot_traceability_shipment_report \ No newline at end of file diff --git a/setup/stock_production_lot_traceability_shipment_report/setup.py b/setup/stock_production_lot_traceability_shipment_report/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/stock_production_lot_traceability_shipment_report/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_production_lot_traceability_shipment_report/README.rst b/stock_production_lot_traceability_shipment_report/README.rst new file mode 100644 index 0000000..8595449 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/README.rst @@ -0,0 +1 @@ +TO BE GENERATED \ No newline at end of file diff --git a/stock_production_lot_traceability_shipment_report/__init__.py b/stock_production_lot_traceability_shipment_report/__init__.py new file mode 100644 index 0000000..8b31d39 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/__init__.py @@ -0,0 +1,2 @@ +from . import reports +from . import wizards diff --git a/stock_production_lot_traceability_shipment_report/__manifest__.py b/stock_production_lot_traceability_shipment_report/__manifest__.py new file mode 100644 index 0000000..c631669 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2022 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Stock Lot Shipment Traceability", + "summary": "Show the Shipments directly or indirectly involving a Lot/SN", + "version": "12.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["ivantodorovich"], + "website": "https://github.com/OCA/stock-logistics-reporting", + "license": "AGPL-3", + "category": "Stock", + "depends": ["stock_production_lot_traceability"], + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "reports/stock_shipment_traceability_report.xml", + "wizards/stock_shipment_traceability_report_wizard.xml", + ], +} diff --git a/stock_production_lot_traceability_shipment_report/readme/CONTRIBUTORS.rst b/stock_production_lot_traceability_shipment_report/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..f22d761 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Camptocamp `_ + + * Damien Crier + * Iván Todorovich diff --git a/stock_production_lot_traceability_shipment_report/readme/DESCRIPTION.rst b/stock_production_lot_traceability_shipment_report/readme/DESCRIPTION.rst new file mode 100644 index 0000000..ed70dd2 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/readme/DESCRIPTION.rst @@ -0,0 +1,6 @@ +Show all the shipments that directly or indirectly involved a Lot/Serial Number. + +This report is useful to track the involved parties in a specific Lot/Serial Number. + +It can be used to track customers that received a malfuctioning/contaminated product, +either directly (product sent), or indirectly (used in the components) diff --git a/stock_production_lot_traceability_shipment_report/readme/USAGE.rst b/stock_production_lot_traceability_shipment_report/readme/USAGE.rst new file mode 100644 index 0000000..6a5068b --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/readme/USAGE.rst @@ -0,0 +1,9 @@ +#. Go to *Inventory > Analysis > Shipment Traceability Report*. +#. Select the *Lot/Serial Number* to be traced. +#. Confirm + +You'll see a list of shipment lines involving the selected Lot/Serial Number, either +directly or indirectly. + +This means, if this lot was used as a component for another product and it was delivered +instead, you'll find all the affected deliveries. diff --git a/stock_production_lot_traceability_shipment_report/reports/__init__.py b/stock_production_lot_traceability_shipment_report/reports/__init__.py new file mode 100644 index 0000000..c15b08b --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/reports/__init__.py @@ -0,0 +1 @@ +from . import stock_shipment_traceability_report diff --git a/stock_production_lot_traceability_shipment_report/reports/stock_shipment_traceability_report.py b/stock_production_lot_traceability_shipment_report/reports/stock_shipment_traceability_report.py new file mode 100644 index 0000000..dc70370 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/reports/stock_shipment_traceability_report.py @@ -0,0 +1,202 @@ +# Copyright 2022 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from textwrap import indent, dedent +from odoo import models, fields, tools, api + + +class StockMoveLineDeliveryReport(models.Model): + _name = "stock.shipment.traceability.report" + _description = "Analysis on received/delivered stock lines" + _auto = False + _order = "date asc" + + name = fields.Char(readonly=True) + date = fields.Datetime(readonly=True) + usage = fields.Selection( + selection=[("customer", "Customer"), ("supplier", "Supplier")], + readonly=True, + ) + direction = fields.Selection([("in", "In"), ("out", "Out")], readonly=True) + kind = fields.Selection( + [ + ("reception", "Reception"), + ("in_return", "Supplier Return"), + ("delivery", "Delivery"), + ("out_return", "Customer Return"), + ], + readonly=True, + ) + move_id = fields.Many2one("stock.move", "Move", readonly=True) + group_id = fields.Many2one("procurement.group", "Procurement Group", readonly=True) + product_id = fields.Many2one("product.product", "Product", readonly=True) + product_uom_id = fields.Many2one("uom.uom", "Unit of Measure", readonly=True) + product_uom_qty = fields.Float("Quantity", readonly=True) + product_uom_qty_directed = fields.Float( + "Quantity (directed)", + help="Quantity directed to the customer/supplier.", + readonly=True, + ) + picking_id = fields.Many2one("stock.picking", "Picking", readonly=True) + owner_id = fields.Many2one("res.partner", "Owner", readonly=True) + location_id = fields.Many2one("stock.location", "From", readonly=True) + location_dest_id = fields.Many2one("stock.location", "To", readonly=True) + origin = fields.Char(readonly=True) + reference = fields.Char(readonly=True) + lot_id = fields.Many2one("stock.production.lot", "Lot/Serial Number", readonly=True) + company_id = fields.Many2one("res.company", "Company", readonly=True) + partner_id = fields.Many2one("res.partner", "Partner", readonly=True) + + def _with_expressions(self): + return [] + + def _with(self): + expressions = self._with_expressions() + return ("WITH %s" % ", ".join(expressions)) if expressions else "" + + def _select_fields(self): + return { + "id": "sml.id", + "name": "sm.name", + "date": "sml.date", + "usage": """ + COALESCE( + NULLIF(sl_from.usage, 'internal'), + NULLIF(sl_dest.usage, 'internal') + ) + """, + "direction": """ + ( + CASE + WHEN sl_from.usage = 'internal' THEN 'out' + ELSE 'in' + END + ) + """, + "kind": """ + ( + CASE + WHEN sl_dest.usage = 'customer' THEN 'delivery' + WHEN sl_from.usage = 'supplier' THEN 'reception' + WHEN sl_from.usage = 'customer' THEN 'out_return' + WHEN sl_dest.usage = 'supplier' THEN 'in_return' + END + ) + """, + "move_id": "sml.move_id", + "group_id": "sm.group_id", + "product_id": "sml.product_id", + "product_uom_id": "pt.uom_id", + "product_uom_qty": """ + ( + CASE + WHEN sl_from.usage = 'internal' THEN -sml.qty_done + ELSE sml.qty_done + END + ) + """, + "product_uom_qty_directed": """ + CASE + WHEN sl_from.usage = 'internal' + THEN -sml.qty_done + ELSE sml.qty_done + END + * + CASE + WHEN ( + COALESCE( + NULLIF(sl_from.usage, 'internal'), + NULLIF(sl_dest.usage, 'internal') + ) + = 'customer' + ) + THEN -1 + ELSE 1 + END + """, + "picking_id": "sm.picking_id", + "owner_id": "sml.owner_id", + "location_id": "sml.location_id", + "location_dest_id": "sml.location_dest_id", + "origin": "sm.origin", + "reference": "sm.reference", + "lot_id": "sml.lot_id", + "company_id": "sm.company_id", + "partner_id": "sp.partner_id", + } + + def _select_expressions(self): + return [ + "%s AS %s" % (dedent(expr).strip(), fname) + for fname, expr in self._select_fields().items() + ] + + def _select(self): + indented_expressions = ( + indent(expr, " ") + for expr in self._select_expressions() + ) + return "SELECT\n%s" % ",\n".join(indented_expressions) + + def _from(self): + return "FROM stock_move_line sml" + + def _join_expressions(self): + return [ + "INNER JOIN stock_move sm ON sm.id = sml.move_id", + "INNER JOIN stock_picking sp ON sp.id = sm.picking_id", + "INNER JOIN product_product pp ON pp.id = sml.product_id", + "INNER JOIN product_template pt ON pt.id = pp.product_tmpl_id", + "INNER JOIN stock_location sl_from ON sl_from.id = sml.location_id", + "INNER JOIN stock_location sl_dest ON sl_dest.id = sml.location_dest_id", + ] + + def _join(self): + return "\n".join(self._join_expressions()) + + def _where_expressions(self): + return [ + "sml.state = 'done'", + """ + ( + ( + sl_from.usage = 'internal' + AND sl_dest.usage IN ('customer', 'supplier') + ) OR ( + sl_from.usage IN ('customer', 'supplier') + AND sl_dest.usage = 'internal' + ) + ) + """, + ] + + def _where(self): + expressions = self._where_expressions() + return "WHERE %s" % "\nAND ".join(expressions) if expressions else "" + + def _groupby_expressions(self): + return [] + + def _groupby(self): + expressions = self._groupby_expressions() + return "GROUP BY %s" % ", ".join(expressions) if expressions else "" + + def _query(self): + return "\n".join( + ( + self._with(), + self._select(), + self._from(), + self._join(), + self._where(), + self._groupby(), + ) + ) + + @api.model_cr + def init(self): + tools.drop_view_if_exists(self.env.cr, self._table) + self.env.cr.execute( + "CREATE or REPLACE VIEW %s AS (%s)" % (self._table, self._query()) + ) diff --git a/stock_production_lot_traceability_shipment_report/reports/stock_shipment_traceability_report.xml b/stock_production_lot_traceability_shipment_report/reports/stock_shipment_traceability_report.xml new file mode 100644 index 0000000..ebddb77 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/reports/stock_shipment_traceability_report.xml @@ -0,0 +1,60 @@ + + + + + + stock.shipment.traceability.report + + + + + + + + + + stock.shipment.traceability.report + + + + + + + + + + + + + + + + + stock.shipment.traceability.report + + + + + + + + + + + + + + + + Shipment Traceability Report + stock.shipment.traceability.report + form + tree,pivot + main + + + diff --git a/stock_production_lot_traceability_shipment_report/security/ir.model.access.csv b/stock_production_lot_traceability_shipment_report/security/ir.model.access.csv new file mode 100644 index 0000000..ae3905e --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_stock_shipment_traceability_report,access stock.shipment.traceability.report,model_stock_shipment_traceability_report,base.group_user,1,0,0,0 +access_stock_shipment_traceability_report_wizard,access stock.shipment.traceability.report.wizard,model_stock_shipment_traceability_report_wizard,base.group_user,1,1,1,1 diff --git a/stock_production_lot_traceability_shipment_report/security/security.xml b/stock_production_lot_traceability_shipment_report/security/security.xml new file mode 100644 index 0000000..61209a6 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/security/security.xml @@ -0,0 +1,15 @@ + + + + + + Analysis on received/delivered stock lines: Multi-company + + [('company_id', 'child_of', [user.company_id.id])] + + + diff --git a/stock_production_lot_traceability_shipment_report/tests/__init__.py b/stock_production_lot_traceability_shipment_report/tests/__init__.py new file mode 100644 index 0000000..b7846d5 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/tests/__init__.py @@ -0,0 +1 @@ +from . import test_stock_shipment_report diff --git a/stock_production_lot_traceability_shipment_report/tests/test_stock_shipment_report.py b/stock_production_lot_traceability_shipment_report/tests/test_stock_shipment_report.py new file mode 100644 index 0000000..d033ab2 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/tests/test_stock_shipment_report.py @@ -0,0 +1,72 @@ +# Copyright 2022 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests import Form +from odoo.addons.stock_production_lot_traceability.tests.common import ( + CommonStockLotTraceabilityCase, +) + + +class TestStockLotTraceability(CommonStockLotTraceabilityCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner1 = cls.env.ref("base.res_partner_1") + cls.partner2 = cls.env.ref("base.res_partner_2") + cls.picking_type_out = cls.env.ref("stock.picking_type_out") + cls.location_customers = cls.env.ref("stock.stock_location_customers") + cls._do_picking_out(cls.partner1, cls.product3, cls.product3_lot1, 2) + cls._do_picking_out(cls.partner2, cls.product3, cls.product3_lot2, 2) + + @classmethod + def _do_picking_out(cls, partner, product, lot, quantity, validate=True): + picking = cls.env["stock.picking"].create( + { + "partner_id": partner.id, + "picking_type_id": cls.picking_type_out.id, + "location_id": cls.location_stock.id, + "location_dest_id": cls.location_customers.id, + "move_type": "direct", + } + ) + moves = cls._do_stock_move( + product, + lot, + quantity, + picking.location_id, + picking.location_dest_id, + validate=False, + ) + moves.write({"picking_id": picking.id}) + moves.move_line_ids.write({"picking_id": picking.id}) + if validate: + picking.action_confirm() + picking.button_validate() + return picking + + def _get_shipment_report_lines(self, lot): + Wizard = self.env["stock.shipment.traceability.report.wizard"] + wizard = Wizard.create({"lot_id": lot.id}) + res = wizard.confirm() + self.assertIn("res_model", res) + self.assertIn("domain", res) + return self.env[res["res_model"]].search(res["domain"]) + + def test_report_wizard_form_onchange(self): + Wizard = self.env["stock.shipment.traceability.report.wizard"] + with Form(Wizard) as form: + form.lot_id = self.product1_lot1 + self.assertEqual(form.product_id, self.product1) + + def test_shipment_report(self): + # Case 1: Deliveries involving product1_lot1 + lines = self._get_shipment_report_lines(self.product1_lot1) + self.assertEqual(lines.partner_id, self.partner1) + self.assertEqual(lines.product_id, self.product3) + self.assertEqual(lines.lot_id, self.product3_lot1) + # Case 1: Deliveries involving product1_lot1 + lines = self._get_shipment_report_lines(self.product1_lot2) + self.assertEqual(lines.partner_id, self.partner2) + self.assertEqual(lines.product_id, self.product3) + self.assertEqual(lines.lot_id, self.product3_lot2) diff --git a/stock_production_lot_traceability_shipment_report/wizards/__init__.py b/stock_production_lot_traceability_shipment_report/wizards/__init__.py new file mode 100644 index 0000000..04d6efb --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/wizards/__init__.py @@ -0,0 +1 @@ +from . import stock_shipment_traceability_report_wizard diff --git a/stock_production_lot_traceability_shipment_report/wizards/stock_shipment_traceability_report_wizard.py b/stock_production_lot_traceability_shipment_report/wizards/stock_shipment_traceability_report_wizard.py new file mode 100644 index 0000000..67b3156 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/wizards/stock_shipment_traceability_report_wizard.py @@ -0,0 +1,57 @@ +# Copyright 2022 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class StockTraceabilityDeliveryReportWizard(models.TransientModel): + _name = "stock.shipment.traceability.report.wizard" + _description = "Stock Traceability Delivery Report Wizard" + + product_id = fields.Many2one( + "product.product", + string="Product", + domain="[('type', '=', 'product')]", + ) + lot_id = fields.Many2one( + "stock.production.lot", + string="Lot/Serial Number", + domain="[('product_id', '=?', product_id)]", + required=True, + ) + + @api.onchange("lot_id") + def _onchange_lot_id(self): + if self.lot_id and self.lot_id.product_id != self.product_id: + self.product_id = self.lot_id.product_id + + def _get_affected_lots(self): + """Get all the lots affected by ``self.lot_id``""" + return self.lot_id | self.lot_id.produce_lot_ids + + def _get_affected_move_lines(self): + lots = self._get_affected_lots() + return self.env["stock.move.line"].search( + [ + ("state", "=", "done"), + ("lot_id", "in", lots.ids), + "|", + ("location_id.usage", "=", "customer"), + ("location_dest_id.usage", "=", "customer"), + ] + ) + + def confirm(self): + self.ensure_one() + lines = self._get_affected_move_lines() + if not lines: # pragma: no cover + raise UserError(_("There isn't any shipment involving this lot.")) + action = self.env["ir.actions.act_window"].for_xml_id( + "stock_production_lot_traceability_shipment_report", + "action_stock_shipment_traceability_report" + ) + action["display_name"] = "%s (%s)" % (action["name"], self.lot_id.display_name) + action["domain"] = [("id", "in", lines.ids)] + return action diff --git a/stock_production_lot_traceability_shipment_report/wizards/stock_shipment_traceability_report_wizard.xml b/stock_production_lot_traceability_shipment_report/wizards/stock_shipment_traceability_report_wizard.xml new file mode 100644 index 0000000..c7db059 --- /dev/null +++ b/stock_production_lot_traceability_shipment_report/wizards/stock_shipment_traceability_report_wizard.xml @@ -0,0 +1,43 @@ + + + + + + stock.shipment.traceability.report.wizard + +
+ + + + + + + + +
+
+
+
+
+ + + Shipment Traceability Report + stock.shipment.traceability.report.wizard + form + form + new + + + + +