mirror of
https://github.com/OCA/stock-logistics-reporting.git
synced 2025-02-16 17:13:21 +02:00
@@ -0,0 +1 @@
|
||||
../../../../stock_production_lot_traceability_shipment_report
|
||||
@@ -0,0 +1,6 @@
|
||||
import setuptools
|
||||
|
||||
setuptools.setup(
|
||||
setup_requires=['setuptools-odoo'],
|
||||
odoo_addon=True,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
TO BE GENERATED
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import reports
|
||||
from . import wizards
|
||||
@@ -0,0 +1,21 @@
|
||||
# Copyright 2022 Camptocamp SA (https://www.camptocamp.com).
|
||||
# @author Iván Todorovich <ivan.todorovich@camptocamp.com>
|
||||
# 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",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
* `Camptocamp <https://www.camptocamp.com>`_
|
||||
|
||||
* Damien Crier <damien.crier@camptocamp.com>
|
||||
* Iván Todorovich <ivan.todorovich@camptocamp.com>
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
from . import stock_shipment_traceability_report
|
||||
@@ -0,0 +1,202 @@
|
||||
# Copyright 2022 Camptocamp SA (https://www.camptocamp.com).
|
||||
# @author Iván Todorovich <ivan.todorovich@camptocamp.com>
|
||||
# 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())
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2022 Camptocamp SA (https://www.camptocamp.com).
|
||||
@author Iván Todorovich <ivan.todorovich@camptocamp.com>
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_stock_shipment_traceability_report_pivot" model="ir.ui.view">
|
||||
<field name="model">stock.shipment.traceability.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot>
|
||||
<field name="partner_id" type="row" />
|
||||
<field name="product_uom_qty" type="measure" />
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_stock_shipment_traceability_report_tree" model="ir.ui.view">
|
||||
<field name="model">stock.shipment.traceability.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-danger="kind in ('in_return', 'out_return')" decoration-success="kind == 'reception'">
|
||||
<field name="kind"/>
|
||||
<field name="date"/>
|
||||
<field name="picking_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="lot_id"/>
|
||||
<field name="product_uom_qty_directed" string="Quantity"/>
|
||||
<field name="product_uom_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="origin"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_stock_shipment_traceability_report_search" model="ir.ui.view">
|
||||
<field name="model">stock.shipment.traceability.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="product_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="date"/>
|
||||
<field name="direction"/>
|
||||
<group expand="1" string="Group By">
|
||||
<filter string="Product" name="Product" context="{'group_by':'product_id'}"/>
|
||||
<filter string="Partner" name="partner" context="{'group_by':'partner_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_stock_shipment_traceability_report" model="ir.actions.act_window">
|
||||
<field name="name">Shipment Traceability Report</field>
|
||||
<field name="res_model">stock.shipment.traceability.report</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">tree,pivot</field>
|
||||
<field name="target">main</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2022 Camptocamp SA (https://www.camptocamp.com).
|
||||
@author Iván Todorovich <ivan.todorovich@camptocamp.com>
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="access_stock_shipment_traceability_report_rule_multi_company" model="ir.rule">
|
||||
<field name="name">Analysis on received/delivered stock lines: Multi-company</field>
|
||||
<field name="model_id" ref="model_stock_shipment_traceability_report"/>
|
||||
<field name="domain_force">[('company_id', 'child_of', [user.company_id.id])]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1 @@
|
||||
from . import test_stock_shipment_report
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright 2022 Camptocamp SA (https://www.camptocamp.com).
|
||||
# @author Iván Todorovich <ivan.todorovich@camptocamp.com>
|
||||
# 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)
|
||||
@@ -0,0 +1 @@
|
||||
from . import stock_shipment_traceability_report_wizard
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright 2022 Camptocamp SA (https://www.camptocamp.com).
|
||||
# @author Iván Todorovich <ivan.todorovich@camptocamp.com>
|
||||
# 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
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2022 Camptocamp SA (https://www.camptocamp.com).
|
||||
@author Iván Todorovich <ivan.todorovich@camptocamp.com>
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_stock_shipment_traceability_report_wizard_form" model="ir.ui.view">
|
||||
<field name="model">stock.shipment.traceability.report.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<group name="main">
|
||||
<group name="left">
|
||||
<field name="product_id" options="{'no_create': True}" />
|
||||
<field name="lot_id" options="{'no_create': True}" />
|
||||
</group>
|
||||
<group name="right">
|
||||
</group>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Confirm" name="confirm" type="object" class="btn-primary"/>
|
||||
<button string="Cancel" special="cancel" class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_stock_shipment_traceability_report_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Shipment Traceability Report</field>
|
||||
<field name="res_model">stock.shipment.traceability.report.wizard</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
id="menu_stock_shipment_traceability_report_wizard"
|
||||
action="action_stock_shipment_traceability_report_wizard"
|
||||
parent="stock.menu_warehouse_report"
|
||||
/>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user