[14.0][ADD] mrp_unbuild_subcontracting: unbuild created when is returned a product subcontracted

This commit is contained in:
ThiagoMForgeFlow
2022-12-15 11:28:56 +01:00
parent f4dfcb893a
commit 653340f020
15 changed files with 555 additions and 0 deletions

View File

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,15 @@
# Copyright 2022 ForgeFlow S.L. (https://www.forgeflow.com)
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
{
"name": "Unbuild orders with return subcontracting",
"version": "14.0.1.0.0",
"license": "LGPL-3",
"category": "Manufacture",
"summary": "Unbuild orders are created automatically "
"when is returned a product subcontracted",
"author": "ForgeFlow, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/manufacture",
"depends": ["mrp_account", "mrp_subcontracting"],
"data": ["views/mrp_unbuild_views.xml"],
"installable": True,
}

View File

@@ -0,0 +1,3 @@
from . import stock_picking
from . import mrp_unbuild
from . import stock_move

View File

@@ -0,0 +1,8 @@
from odoo import fields, models
class MrpUnbuild(models.Model):
_inherit = "mrp.unbuild"
picking_id = fields.Many2one("stock.picking", "Transfer", readonly=True)
is_subcontracted = fields.Boolean("Is Subcontracted", readonly=True)

View File

@@ -0,0 +1,57 @@
from collections import defaultdict
from odoo import _, models
from odoo.exceptions import UserError
from odoo.tools.float_utils import float_is_zero
class StockMove(models.Model):
_inherit = "stock.move"
def _action_confirm(self, merge=True, merge_into=False):
if self.origin_returned_move_id:
subcontract_details_per_picking = defaultdict(list)
move_to_not_merge = self.env["stock.move"]
for move in self:
if (
move.location_dest_id.usage == "supplier"
and move.location_id
== self.picking_id.picking_type_id.default_location_src_id
):
continue
if move.move_orig_ids.production_id:
continue
bom = move._get_subcontract_bom()
if not bom:
continue
if (
float_is_zero(
move.product_qty, precision_rounding=move.product_uom.rounding
)
and move.picking_id.immediate_transfer is True
):
raise UserError(_("To subcontract, use a planned transfer."))
subcontract_details_per_picking[move.picking_id].append((move, bom))
move.write(
{
"is_subcontract": True,
}
)
move_to_not_merge |= move
for picking, subcontract_details in subcontract_details_per_picking.items():
picking._subcontracted_produce_unbuild(subcontract_details)
# We avoid merging move due to complication with stock.rule.
res = super(StockMove, move_to_not_merge)._action_confirm(merge=False)
res |= super(StockMove, self - move_to_not_merge)._action_confirm(
merge=merge, merge_into=merge_into
)
if subcontract_details_per_picking:
self.env["stock.picking"].concat(
*list(subcontract_details_per_picking.keys())
).action_assign()
return res
result = super(StockMove, self)._action_confirm(
merge=merge, merge_into=merge_into
)
return result

View File

@@ -0,0 +1,96 @@
from datetime import timedelta
from odoo import fields, models
from odoo.osv.expression import OR
class StockPicking(models.Model):
_inherit = "stock.picking"
subcontracted_unbuild_ids = fields.One2many(
"mrp.unbuild", "picking_id", readonly=True, string="Suncontracted unbuilds"
)
def _prepare_subcontract_unbuild_vals(self, subcontract_move, bom):
subcontract_move.ensure_one()
product = subcontract_move.product_id
vals = {
"company_id": subcontract_move.company_id.id,
"product_id": product.id,
"product_uom_id": subcontract_move.product_uom.id,
"bom_id": bom.id,
"location_id": subcontract_move.picking_id.partner_id.with_company(
subcontract_move.company_id
).property_stock_subcontractor.id,
"location_dest_id": subcontract_move.picking_id.partner_id.with_company(
subcontract_move.company_id
).property_stock_subcontractor.id,
"product_qty": subcontract_move.product_uom_qty,
"picking_id": self.id,
"is_subcontracted": True,
"mo_id": subcontract_move.move_orig_ids.move_orig_ids.production_id.id,
"lot_id": subcontract_move.move_orig_ids.lot_ids.id,
}
return vals
def _subcontracted_produce_unbuild(self, subcontract_details):
self.ensure_one()
for move, bom in subcontract_details:
unbuild = (
self.env["mrp.unbuild"]
.with_company(move.company_id)
.create(self._prepare_subcontract_unbuild_vals(move, bom))
)
self.subcontracted_unbuild_ids |= unbuild
def _action_done(self):
res = super(StockPicking, self)._action_done()
for picking in self:
unbuilds_to_done = picking.subcontracted_unbuild_ids.filtered(
lambda x: x.state == "draft"
)
if not unbuilds_to_done:
continue
unbuild_ids_backorder = []
if not self.env.context.get("cancel_backorder"):
unbuild_ids_backorder = unbuilds_to_done.filtered(
lambda u: u.state == "draft"
).ids
unbuilds_to_done.with_context(
subcontract_move_id=True, mo_ids_to_backorder=unbuild_ids_backorder
).action_validate()
move = self.move_lines.filtered(lambda move: move.is_subcontract)
finished_move = unbuilds_to_done.produce_line_ids.filtered(
lambda m: m.product_id == move.product_id
)
finished_move.write({"move_dest_ids": [(4, move.id, False)]})
# For concistency, set the date on production move before the date
# on picking. (Traceability report + Product Moves menu item)
minimum_date = min(picking.move_line_ids.mapped("date"))
unbuild_moves = (
unbuilds_to_done.produce_line_ids | unbuilds_to_done.consume_line_ids
)
unbuild_moves.write({"date": minimum_date - timedelta(seconds=1)})
unbuild_moves.move_line_ids.write(
{"date": minimum_date - timedelta(seconds=1)}
)
return res
def action_view_stock_valuation_layers(self):
action = super(StockPicking, self).action_view_stock_valuation_layers()
subcontracted_unbuilds = self.subcontracted_unbuild_ids
if not subcontracted_unbuilds:
return action
domain = action["domain"]
domain_subcontracting = [
(
"id",
"in",
(
subcontracted_unbuilds.produce_line_ids
| subcontracted_unbuilds.consume_line_ids
).stock_valuation_layer_ids.ids,
)
]
domain = OR([domain, domain_subcontracting])
return dict(action, domain=domain)

View File

@@ -0,0 +1,3 @@
* `ForgeFlow <https://www.forgeflow.com>`_:
* Thiago Mulero <thiago.mulero@forgeflow.com>

View File

@@ -0,0 +1,2 @@
This module automatically creates a unbuild in draft state when a subcontracting picking return is created. In addition, when the picking is validated, the unbuild is also validated.
To view the unbuilds created, you have to select the operation Subcontracted Unbuild Orders in debug mode

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1 @@
from . import test_mrp_unbuild_subcontracting

View File

@@ -0,0 +1,332 @@
from odoo.tests import Form, TransactionCase
class TestSubcontractingPurchaseFlows(TransactionCase):
def setUp(self):
super().setUp()
self.subcontractor = self.env["res.partner"].create(
{"name": "SuperSubcontractor"}
)
self.finished, self.compo = self.env["product.product"].create(
[
{
"name": "SuperProduct",
"type": "product",
},
{
"name": "Component",
"type": "consu",
},
]
)
self.bom = self.env["mrp.bom"].create(
{
"product_tmpl_id": self.finished.product_tmpl_id.id,
"type": "subcontract",
"subcontractor_ids": [(6, 0, self.subcontractor.ids)],
"bom_line_ids": [
(
0,
0,
{
"product_id": self.compo.id,
"product_qty": 1,
},
)
],
}
)
def test_purchase_and_return(self):
"""
The user buys 10 x a subcontracted product P. He receives the 10
products and then does a return with 3 x P. The test ensures that
the unbuild is created with the correct quantities and states
"""
po = self.env["purchase.order"].create(
{
"partner_id": self.subcontractor.id,
"order_line": [
(
0,
0,
{
"name": self.finished.name,
"product_id": self.finished.id,
"product_uom_qty": 10,
"product_uom": self.finished.uom_id.id,
"price_unit": 1,
},
)
],
}
)
po.button_confirm()
mo = self.env["mrp.production"].search([("bom_id", "=", self.bom.id)])
self.assertTrue(mo)
receipt = po.picking_ids
receipt.move_lines.quantity_done = 10
receipt.button_validate()
return_form = Form(
self.env["stock.return.picking"].with_context(
active_id=receipt.id, active_model="stock.picking"
)
)
with return_form.product_return_moves.edit(0) as line:
line.quantity = 3
line.to_refund = True
return_wizard = return_form.save()
return_id, _ = return_wizard._create_returns()
return_picking = self.env["stock.picking"].browse(return_id)
return_picking.move_lines.quantity_done = 3
subcontractor_location = self.subcontractor.property_stock_subcontractor
unbuild = self.env["mrp.unbuild"].search([("bom_id", "=", self.bom.id)])
self.assertTrue(unbuild)
self.assertEqual(
unbuild.state, "draft", "The state of the unbuild should be draft"
)
self.assertEqual(
unbuild.product_qty, 3, "The quantity of the unbuild should be 3"
)
self.assertEqual(
unbuild.location_id,
subcontractor_location,
"The source location of the unbuild should be the property stock "
"of the subcontractor",
)
self.assertEqual(
unbuild.location_dest_id,
subcontractor_location,
"The destination location of the unbuild should be the property "
"stock of the subcontractor",
)
return_picking.button_validate()
self.assertEqual(self.finished.qty_available, 7.0)
self.assertEqual(po.order_line.qty_received, 7.0)
self.assertEqual(
unbuild.state, "done", "The state of the unbuild should be done"
)
move = return_picking.move_lines
self.assertEqual(
move.location_id,
receipt.location_dest_id,
"The source location of the stock move should be the same as "
"destination location of the original purchase",
)
self.assertEqual(
move.location_dest_id,
subcontractor_location,
"The destination location of the stock move should be the property "
"stock of the subcontractor",
)
# Call the action to view the layers associated to the pickings
result1 = return_picking.action_view_stock_valuation_layers()
result2 = receipt.action_view_stock_valuation_layers()
layers1 = result1["domain"][2][2]
layers2 = result2["domain"][2][2]
self.assertTrue(
layers1,
)
self.assertTrue(
layers2,
)
class TestSubcontractingTracking(TransactionCase):
def setUp(self):
super(TestSubcontractingTracking, self).setUp()
# 1: Create a subcontracting partner
main_company_1 = self.env["res.partner"].create({"name": "main_partner"})
self.subcontractor_partner1 = self.env["res.partner"].create(
{
"name": "Subcontractor 1",
"parent_id": main_company_1.id,
"company_id": self.env.ref("base.main_company").id,
}
)
# 2. Create a BOM of subcontracting type
# 2.1. Comp1 has tracking by lot
self.comp1_sn = self.env["product.product"].create(
{
"name": "Component1",
"type": "product",
"categ_id": self.env.ref("product.product_category_all").id,
"tracking": "serial",
}
)
self.comp2 = self.env["product.product"].create(
{
"name": "Component2",
"type": "product",
"categ_id": self.env.ref("product.product_category_all").id,
}
)
# 2.2. Finished prodcut has tracking by serial number
self.finished_product = self.env["product.product"].create(
{
"name": "finished",
"type": "product",
"categ_id": self.env.ref("product.product_category_all").id,
"tracking": "lot",
}
)
bom_form = Form(self.env["mrp.bom"])
bom_form.type = "subcontract"
bom_form.subcontractor_ids.add(self.subcontractor_partner1)
bom_form.product_tmpl_id = self.finished_product.product_tmpl_id
with bom_form.bom_line_ids.new() as bom_line:
bom_line.product_id = self.comp1_sn
bom_line.product_qty = 1
with bom_form.bom_line_ids.new() as bom_line:
bom_line.product_id = self.comp2
bom_line.product_qty = 1
self.bom_tracked = bom_form.save()
def test_purchase_and_return_with_serial_numbers(self):
"""
The user buys one subcontracted product P with serial number.
Then does the return . The test ensures that the unbuild is
created with the correct quantities, serial number of the product and states
"""
# Create a receipt picking from the subcontractor
picking_form = Form(self.env["stock.picking"])
picking_form.picking_type_id = self.env.ref("stock.picking_type_in")
picking_form.partner_id = self.subcontractor_partner1
with picking_form.move_ids_without_package.new() as move:
move.product_id = self.finished_product
move.product_uom_qty = 1
picking_receipt = picking_form.save()
picking_receipt.action_confirm()
# We should be able to call the 'record_components' button
self.assertTrue(picking_receipt.display_action_record_components)
# Check the created manufacturing order
mo = self.env["mrp.production"].search([("bom_id", "=", self.bom_tracked.id)])
self.assertEqual(len(mo), 1)
self.assertEqual(len(mo.picking_ids), 0)
wh = picking_receipt.picking_type_id.warehouse_id
self.assertEqual(mo.picking_type_id, wh.subcontracting_type_id)
self.assertFalse(mo.picking_type_id.active)
# Create a RR
pg1 = self.env["procurement.group"].create({})
self.env["stock.warehouse.orderpoint"].create(
{
"name": "xxx",
"product_id": self.comp1_sn.id,
"product_min_qty": 0,
"product_max_qty": 0,
"location_id": self.env.user.company_id.subcontracting_location_id.id,
"group_id": pg1.id,
}
)
# Run the scheduler and check the created picking
self.env["procurement.group"].run_scheduler()
picking = self.env["stock.picking"].search([("group_id", "=", pg1.id)])
self.assertEqual(len(picking), 1)
self.assertEqual(picking.picking_type_id, wh.out_type_id)
lot_id = self.env["stock.production.lot"].create(
{
"name": "lot1",
"product_id": self.finished_product.id,
"company_id": self.env.company.id,
}
)
serial_id = self.env["stock.production.lot"].create(
{
"name": "lot1",
"product_id": self.comp1_sn.id,
"company_id": self.env.company.id,
}
)
action = picking_receipt.action_record_components()
mo = self.env["mrp.production"].browse(action["res_id"])
mo_form = Form(mo.with_context(**action["context"]), view=action["view_id"])
mo_form.qty_producing = 1
mo_form.lot_producing_id = lot_id
with mo_form.move_line_raw_ids.edit(0) as ml:
ml.lot_id = serial_id
mo = mo_form.save()
mo.subcontracting_record_component()
# We should not be able to call the 'record_components' button
self.assertFalse(picking_receipt.display_action_record_components)
picking_receipt.button_validate()
self.assertEqual(mo.state, "done")
return_form = Form(
self.env["stock.return.picking"].with_context(
active_id=picking_receipt.id, active_model="stock.picking"
)
)
with return_form.product_return_moves.edit(0) as line:
line.quantity = 1
line.to_refund = True
return_wizard = return_form.save()
return_id, _ = return_wizard._create_returns()
return_picking = self.env["stock.picking"].browse(return_id)
return_picking.move_lines.quantity_done = 1
subcontractor_location = (
self.subcontractor_partner1.property_stock_subcontractor
)
unbuild = self.env["mrp.unbuild"].search([("bom_id", "=", self.bom_tracked.id)])
self.assertTrue(unbuild)
self.assertEqual(
unbuild.state, "draft", "The state of the unbuild should be draft"
)
self.assertEqual(
unbuild.product_qty, 1, "The quantity of the unbuild should be 1"
)
self.assertEqual(
unbuild.location_id,
subcontractor_location,
"The source location of the unbuild should be the property stock "
"of the subcontractor",
)
self.assertEqual(
unbuild.location_dest_id,
subcontractor_location,
"The destination location of the unbuild should be the property "
"stock of the subcontractor",
)
return_picking.move_line_ids_without_package.lot_id = lot_id
return_picking.button_validate()
self.assertEqual(
unbuild.state, "done", "The state of the unbuild should be done"
)
move = return_picking.move_lines
self.assertEqual(
move.location_id,
picking_receipt.location_dest_id,
"The source location of the stock move should be the same as "
"destination location of the original purchase",
)
self.assertEqual(
move.location_dest_id,
subcontractor_location,
"The destination location of the stock move should be the property "
"stock of the subcontractor",
)

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record model="ir.actions.act_window" id="mrp.mrp_unbuild">
<field name="domain">[('is_subcontracted', '=', False)]</field>
</record>
<record model="ir.actions.act_window" id="mrp_unbuild_subcontracted">
<field name="name">Unbuild Orders - Subcontracted</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">mrp.unbuild</field>
<field name="view_mode">tree,kanban,form</field>
<field name="domain">[('is_subcontracted', '=', True)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No unbuild order found
</p><p>
An unbuild order is used to break down a finished product into its components.
</p>
</field>
</record>
<menuitem
id="menu_mrp_unbuild_subcontracted"
name="Subcontracted Unbuild Orders"
parent="mrp.menu_mrp_manufacturing"
action="mrp_unbuild_subcontracted"
sequence="21"
groups="base.group_no_one"
/>
</odoo>

View File

@@ -0,0 +1 @@
../../../../mrp_unbuild_subcontracting

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)