odoo-addon-project-task-stock 18.0.1.0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. odoo/addons/project_task_stock/README.rst +146 -0
  2. odoo/addons/project_task_stock/__init__.py +1 -0
  3. odoo/addons/project_task_stock/__manifest__.py +24 -0
  4. odoo/addons/project_task_stock/demo/project_data.xml +28 -0
  5. odoo/addons/project_task_stock/demo/stock_picking_type_data.xml +11 -0
  6. odoo/addons/project_task_stock/i18n/es.po +337 -0
  7. odoo/addons/project_task_stock/i18n/fr.po +337 -0
  8. odoo/addons/project_task_stock/i18n/hr.po +345 -0
  9. odoo/addons/project_task_stock/i18n/it.po +344 -0
  10. odoo/addons/project_task_stock/i18n/project_task_stock.pot +329 -0
  11. odoo/addons/project_task_stock/i18n/pt_BR.po +345 -0
  12. odoo/addons/project_task_stock/models/__init__.py +7 -0
  13. odoo/addons/project_task_stock/models/account_analytic_line.py +21 -0
  14. odoo/addons/project_task_stock/models/project_project.py +45 -0
  15. odoo/addons/project_task_stock/models/project_task.py +279 -0
  16. odoo/addons/project_task_stock/models/stock_move.py +135 -0
  17. odoo/addons/project_task_stock/models/stock_scrap.py +25 -0
  18. odoo/addons/project_task_stock/readme/CONFIGURE.md +25 -0
  19. odoo/addons/project_task_stock/readme/CONTRIBUTORS.md +3 -0
  20. odoo/addons/project_task_stock/readme/DESCRIPTION.md +1 -0
  21. odoo/addons/project_task_stock/readme/USAGE.md +21 -0
  22. odoo/addons/project_task_stock/static/description/icon.png +0 -0
  23. odoo/addons/project_task_stock/static/description/index.html +485 -0
  24. odoo/addons/project_task_stock/tests/__init__.py +4 -0
  25. odoo/addons/project_task_stock/tests/common.py +100 -0
  26. odoo/addons/project_task_stock/tests/test_project_task_stock.py +350 -0
  27. odoo/addons/project_task_stock/views/project_project_view.xml +24 -0
  28. odoo/addons/project_task_stock/views/project_task_type_view.xml +14 -0
  29. odoo/addons/project_task_stock/views/project_task_view.xml +142 -0
  30. odoo/addons/project_task_stock/views/stock_move_view.xml +65 -0
  31. odoo/addons/project_task_stock/views/stock_scrap_views.xml +13 -0
  32. odoo_addon_project_task_stock-18.0.1.0.0.2.dist-info/METADATA +161 -0
  33. odoo_addon_project_task_stock-18.0.1.0.0.2.dist-info/RECORD +35 -0
  34. odoo_addon_project_task_stock-18.0.1.0.0.2.dist-info/WHEEL +5 -0
  35. odoo_addon_project_task_stock-18.0.1.0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,279 @@
1
+ # Copyright 2022-2025 Tecnativa - Víctor Martínez
2
+ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3
+ from odoo import _, api, fields, models
4
+ from odoo.exceptions import UserError
5
+ from odoo.tools import float_is_zero
6
+
7
+
8
+ class ProjectTask(models.Model):
9
+ _name = "project.task"
10
+ _inherit = ["project.task", "analytic.mixin"]
11
+
12
+ scrap_ids = fields.One2many(
13
+ comodel_name="stock.scrap", inverse_name="task_id", string="Scraps"
14
+ )
15
+ scrap_count = fields.Integer(
16
+ compute="_compute_scrap_move_count", string="Scrap Move"
17
+ )
18
+ move_ids = fields.One2many(
19
+ comodel_name="stock.move",
20
+ inverse_name="raw_material_task_id",
21
+ string="Stock Moves",
22
+ copy=False,
23
+ domain=[("scrapped", "=", False)],
24
+ )
25
+ use_stock_moves = fields.Boolean(related="stage_id.use_stock_moves")
26
+ done_stock_moves = fields.Boolean(related="stage_id.done_stock_moves")
27
+ stock_moves_is_locked = fields.Boolean(default=True)
28
+ allow_moves_action_confirm = fields.Boolean(
29
+ compute="_compute_allow_moves_action_confirm"
30
+ )
31
+ allow_moves_action_assign = fields.Boolean(
32
+ compute="_compute_allow_moves_action_assign"
33
+ )
34
+ stock_state = fields.Selection(
35
+ selection=[
36
+ ("pending", "Pending"),
37
+ ("confirmed", "Confirmed"),
38
+ ("assigned", "Assigned"),
39
+ ("done", "Done"),
40
+ ("cancel", "Cancel"),
41
+ ],
42
+ compute="_compute_stock_state",
43
+ )
44
+ picking_type_id = fields.Many2one(
45
+ comodel_name="stock.picking.type",
46
+ string="Operation Type",
47
+ readonly=False,
48
+ domain="[('company_id', '=?', company_id)]",
49
+ index=True,
50
+ )
51
+ location_id = fields.Many2one(
52
+ comodel_name="stock.location",
53
+ string="Source Location",
54
+ readonly=False,
55
+ index=True,
56
+ check_company=True,
57
+ )
58
+ location_dest_id = fields.Many2one(
59
+ comodel_name="stock.location",
60
+ string="Destination Location",
61
+ readonly=False,
62
+ index=True,
63
+ check_company=True,
64
+ )
65
+ stock_analytic_date = fields.Date()
66
+ unreserve_visible = fields.Boolean(
67
+ string="Allowed to Unreserve Inventory",
68
+ compute="_compute_unreserve_visible",
69
+ help="Technical field to check when we can unreserve",
70
+ )
71
+ stock_analytic_account_id = fields.Many2one(
72
+ comodel_name="account.analytic.account",
73
+ string="Move Analytic Account",
74
+ help="Move created will be assigned to this analytic account",
75
+ )
76
+ stock_analytic_distribution = fields.Json(
77
+ copy=True,
78
+ readonly=False,
79
+ )
80
+ stock_analytic_line_ids = fields.One2many(
81
+ comodel_name="account.analytic.line",
82
+ inverse_name="stock_task_id",
83
+ string="Stock Analytic Lines",
84
+ )
85
+ group_id = fields.Many2one(
86
+ comodel_name="procurement.group",
87
+ )
88
+
89
+ def _compute_scrap_move_count(self):
90
+ data = self.env["stock.scrap"].read_group(
91
+ [("task_id", "in", self.ids)], ["task_id"], ["task_id"]
92
+ )
93
+ count_data = {item["task_id"][0]: item["task_id_count"] for item in data}
94
+ for item in self:
95
+ item.scrap_count = count_data.get(item.id, 0)
96
+
97
+ @api.depends("move_ids", "move_ids.state")
98
+ def _compute_allow_moves_action_confirm(self):
99
+ for item in self:
100
+ item.allow_moves_action_confirm = any(
101
+ move.state == "draft" for move in item.move_ids
102
+ )
103
+
104
+ @api.depends("move_ids", "move_ids.state")
105
+ def _compute_allow_moves_action_assign(self):
106
+ for item in self:
107
+ item.allow_moves_action_assign = any(
108
+ move.state in ("confirmed", "partially_available")
109
+ for move in item.move_ids
110
+ )
111
+
112
+ @api.depends("move_ids", "move_ids.state")
113
+ def _compute_stock_state(self):
114
+ for task in self:
115
+ task.stock_state = "pending"
116
+ if task.move_ids:
117
+ states = task.mapped("move_ids.state")
118
+ for state in ("confirmed", "assigned", "done", "cancel"):
119
+ if state in states:
120
+ task.stock_state = state
121
+ break
122
+
123
+ @api.depends("move_ids", "move_ids.quantity")
124
+ def _compute_unreserve_visible(self):
125
+ for item in self:
126
+ already_reserved = item.mapped("move_ids.move_line_ids")
127
+ any_quantity_done = any(
128
+ [
129
+ m.quantity > 0
130
+ for m in item.move_ids.filtered(lambda x: x.state == "done")
131
+ ]
132
+ )
133
+ item.unreserve_visible = not any_quantity_done and already_reserved
134
+
135
+ @api.onchange("picking_type_id")
136
+ def _onchange_picking_type_id(self):
137
+ self.location_id = self.picking_type_id.default_location_src_id.id
138
+ self.location_dest_id = self.picking_type_id.default_location_dest_id.id
139
+
140
+ def _check_tasks_with_pending_moves(self):
141
+ if self.move_ids and "assigned" in self.mapped("move_ids.state"):
142
+ raise UserError(
143
+ _("It is not possible to change this with reserved movements in tasks.")
144
+ )
145
+
146
+ def _update_moves_info(self):
147
+ for item in self:
148
+ item._check_tasks_with_pending_moves()
149
+ picking_type = item.picking_type_id or item.project_id.picking_type_id
150
+ location = item.location_id or item.project_id.location_id
151
+ location_dest = item.location_dest_id or item.project_id.location_dest_id
152
+ moves = item.move_ids.filtered(
153
+ lambda x, location=location, location_dest=location_dest: x.state
154
+ not in ("cancel", "done")
155
+ and (x.location_id != location or x.location_dest_id != location_dest)
156
+ )
157
+ moves.update(
158
+ {
159
+ "warehouse_id": location.warehouse_id.id,
160
+ "location_id": location.id,
161
+ "location_dest_id": location_dest.id,
162
+ "picking_type_id": picking_type.id,
163
+ }
164
+ )
165
+ self.action_assign()
166
+
167
+ @api.model
168
+ def _prepare_procurement_group_vals(self):
169
+ return {"name": f"Task-ID: {self.id}"}
170
+
171
+ def action_confirm(self):
172
+ self.mapped("move_ids")._action_confirm()
173
+
174
+ def action_assign(self):
175
+ self.action_confirm()
176
+ self.mapped("move_ids")._action_assign()
177
+
178
+ def button_scrap(self):
179
+ self.ensure_one()
180
+ move_items = self.move_ids.filtered(lambda x: x.state not in ("done", "cancel"))
181
+ return {
182
+ "name": _("Scrap"),
183
+ "view_mode": "form",
184
+ "res_model": "stock.scrap",
185
+ "view_id": self.env.ref("stock.stock_scrap_form_view2").id,
186
+ "type": "ir.actions.act_window",
187
+ "context": {
188
+ "default_task_id": self.id,
189
+ "product_ids": move_items.mapped("product_id").ids,
190
+ "default_company_id": self.company_id.id,
191
+ },
192
+ "target": "new",
193
+ }
194
+
195
+ def do_unreserve(self):
196
+ for item in self:
197
+ item.move_ids.filtered(
198
+ lambda x: x.state not in ("done", "cancel")
199
+ )._do_unreserve()
200
+ return True
201
+
202
+ def button_unreserve(self):
203
+ self.ensure_one()
204
+ self.do_unreserve()
205
+ return True
206
+
207
+ def action_cancel(self):
208
+ """Cancel the stock moves and remove the analytic lines created from
209
+ stock moves when cancelling the task.
210
+ """
211
+ self.mapped("move_ids.move_line_ids").write({"quantity": 0})
212
+ # Use sudo to avoid error for users with no access to analytic
213
+ self.sudo().stock_analytic_line_ids.unlink()
214
+ self.stock_moves_is_locked = True
215
+ return True
216
+
217
+ def action_toggle_stock_moves_is_locked(self):
218
+ self.ensure_one()
219
+ self.stock_moves_is_locked = not self.stock_moves_is_locked
220
+ return True
221
+
222
+ def action_done(self):
223
+ # Filter valid stock moves (avoiding those done and cancelled).
224
+ price_dp = self.env["decimal.precision"].precision_get(
225
+ "Product Unit of Measure"
226
+ )
227
+ moves_to_skip = self.move_ids.filtered(lambda x: x.state in ("done", "cancel"))
228
+ moves_to_do = self.move_ids - moves_to_skip
229
+ for move in moves_to_do.filtered(
230
+ lambda x: float_is_zero(x.quantity, precision_digits=price_dp)
231
+ ):
232
+ move.quantity = move.product_uom_qty
233
+ moves_to_do.picking_id.with_context(skip_sanity_check=True).button_validate()
234
+ moves_done = self.move_ids.filtered(lambda x: x.state == "done")
235
+ moves_todo = moves_done - moves_to_skip
236
+ # Use sudo to avoid error for users with no access to analytic
237
+ analytic_line_model = self.env["account.analytic.line"].sudo()
238
+ for move in moves_todo:
239
+ vals = move._prepare_analytic_line_from_task()
240
+ if vals:
241
+ analytic_line_model.create(vals)
242
+
243
+ def action_see_move_scrap(self):
244
+ self.ensure_one()
245
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.action_stock_scrap")
246
+ action["domain"] = [("task_id", "=", self.id)]
247
+ action["context"] = dict(self._context, default_origin=self.name)
248
+ return action
249
+
250
+ def write(self, vals):
251
+ res = super().write(vals)
252
+ if "stage_id" in vals:
253
+ stage = self.env["project.task.type"].browse(vals.get("stage_id"))
254
+ if stage.done_stock_moves:
255
+ # Avoid permissions error if the user does not have access to stock.
256
+ self.sudo().action_assign()
257
+ # Update info
258
+ field_names = ("location_id", "location_dest_id")
259
+ if any(vals.get(field) for field in field_names):
260
+ self._update_moves_info()
261
+ return res
262
+
263
+ def unlink(self):
264
+ # Use sudo to avoid error to users with no access to analytic
265
+ # related to hr_timesheet addon
266
+ return super(ProjectTask, self.sudo()).unlink()
267
+
268
+
269
+ class ProjectTaskType(models.Model):
270
+ _inherit = "project.task.type"
271
+
272
+ use_stock_moves = fields.Boolean(
273
+ help="If you mark this check, when a task goes to this state, "
274
+ "it will use stock moves",
275
+ )
276
+ done_stock_moves = fields.Boolean(
277
+ help="If you check this box, when a task is in this state, you will not "
278
+ "be able to add more stock moves but they can be viewed."
279
+ )
@@ -0,0 +1,135 @@
1
+ # Copyright 2022 Tecnativa - Víctor Martínez
2
+ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3
+ from odoo import api, fields, models
4
+
5
+
6
+ class StockMove(models.Model):
7
+ _inherit = "stock.move"
8
+
9
+ task_id = fields.Many2one(
10
+ comodel_name="project.task",
11
+ string="Related Task",
12
+ check_company=True,
13
+ )
14
+ raw_material_task_id = fields.Many2one(
15
+ comodel_name="project.task", string="Task for material", check_company=True
16
+ )
17
+
18
+ @api.onchange("product_id")
19
+ def _onchange_product_id(self):
20
+ """It is necessary to overwrite the name to prevent set product name
21
+ from being auto-defined."""
22
+ res = super()._onchange_product_id()
23
+ if self.raw_material_task_id:
24
+ self.name = self.raw_material_task_id.name
25
+ return res
26
+
27
+ def _prepare_analytic_line_from_task(self):
28
+ product = self.product_id
29
+ company_id = self.env.company
30
+ task = self.task_id or self.raw_material_task_id
31
+ analytic_account = task.stock_analytic_account_id or task.project_id.account_id
32
+ if not analytic_account:
33
+ return False
34
+ # Apply sudo() in case there is any rule that does not allow access to
35
+ # the analytic account, for example with analytic_hr_department_restriction
36
+ analytic_account = analytic_account.sudo()
37
+ res = {
38
+ "date": (
39
+ task.stock_analytic_date
40
+ or task.project_id.stock_analytic_date
41
+ or fields.date.today()
42
+ ),
43
+ "name": task.name + ": " + product.name,
44
+ "unit_amount": self.quantity,
45
+ "account_id": analytic_account.id,
46
+ "user_id": self.env.user.id,
47
+ "product_uom_id": self.product_uom.id,
48
+ "company_id": analytic_account.company_id.id or self.env.company.id,
49
+ "partner_id": task.partner_id.id or task.project_id.partner_id.id or False,
50
+ "stock_task_id": task.id,
51
+ }
52
+ amount_unit = product.with_context(uom=self.product_uom.id)._price_compute(
53
+ "standard_price"
54
+ )[product.id]
55
+ amount = amount_unit * self.quantity or 0.0
56
+ result = round(amount, company_id.currency_id.decimal_places) * -1
57
+ vals = {"amount": result}
58
+ analytic_line_fields = self.env["account.analytic.line"]._fields
59
+ # Extra fields added in account addon
60
+ if "ref" in analytic_line_fields:
61
+ vals["ref"] = task.name
62
+ if "product_id" in analytic_line_fields:
63
+ vals["product_id"] = product.id
64
+ # Prevent incoherence when hr_timesheet addon is installed.
65
+ if "project_id" in analytic_line_fields:
66
+ vals["project_id"] = False
67
+ # distributions
68
+ if task.stock_analytic_distribution:
69
+ new_amount = 0
70
+ for distribution in task.stock_analytic_distribution.values():
71
+ new_amount -= (amount / 100) * distribution
72
+ vals["amount"] = new_amount
73
+ res.update(vals)
74
+ return res
75
+
76
+ @api.model
77
+ def default_get(self, fields_list):
78
+ defaults = super().default_get(fields_list)
79
+ if self.env.context.get("default_raw_material_task_id"):
80
+ task = self.env["project.task"].browse(
81
+ self.env.context.get("default_raw_material_task_id")
82
+ )
83
+ if not task.group_id:
84
+ task.group_id = self.env["procurement.group"].create(
85
+ task._prepare_procurement_group_vals()
86
+ )
87
+ defaults.update(
88
+ {
89
+ "group_id": task.group_id.id,
90
+ "location_id": (
91
+ task.location_id.id or task.project_id.location_id.id
92
+ ),
93
+ "location_dest_id": (
94
+ task.location_dest_id.id or task.project_id.location_dest_id.id
95
+ ),
96
+ "picking_type_id": (
97
+ task.picking_type_id.id or task.project_id.picking_type_id.id
98
+ ),
99
+ }
100
+ )
101
+ return defaults
102
+
103
+ def action_task_product_forecast_report(self):
104
+ self.ensure_one()
105
+ action = self.product_id.action_product_forecast_report()
106
+ action["context"] = {
107
+ "active_id": self.product_id.id,
108
+ "active_model": "product.product",
109
+ "move_to_match_ids": self.ids,
110
+ }
111
+ warehouse = self.warehouse_id
112
+ if warehouse:
113
+ action["context"]["warehouse"] = warehouse.id
114
+ return action
115
+
116
+
117
+ class StockMoveLine(models.Model):
118
+ _inherit = "stock.move.line"
119
+
120
+ task_id = fields.Many2one(
121
+ comodel_name="project.task",
122
+ string="Task",
123
+ compute="_compute_task_id",
124
+ store=True,
125
+ )
126
+
127
+ @api.depends("move_id.raw_material_task_id", "move_id.task_id")
128
+ def _compute_task_id(self):
129
+ for item in self:
130
+ task = (
131
+ item.move_id.raw_material_task_id
132
+ if item.move_id.raw_material_task_id
133
+ else item.move_id.task_id
134
+ )
135
+ item.task_id = task if task else False
@@ -0,0 +1,25 @@
1
+ # Copyright 2022 Tecnativa - Víctor Martínez
2
+ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3
+ from odoo import api, fields, models
4
+
5
+
6
+ class StockMove(models.Model):
7
+ _inherit = "stock.scrap"
8
+
9
+ task_id = fields.Many2one(
10
+ comodel_name="project.task", string="Task", check_company=True
11
+ )
12
+
13
+ @api.onchange("task_id")
14
+ def _onchange_task_id(self):
15
+ if self.task_id:
16
+ self.location_id = self.task_id.move_raw_ids.filtered(
17
+ lambda x: x.state not in ("done", "cancel")
18
+ ) and (self.task_id.location_src_id or self.task_id.location_dest_id)
19
+
20
+ def _prepare_move_values(self):
21
+ vals = super()._prepare_move_values()
22
+ if self.task_id:
23
+ vals["origin"] = vals["origin"] or self.task_id.name
24
+ vals.update({"raw_material_task_id": self.task_id.id})
25
+ return vals
@@ -0,0 +1,25 @@
1
+ To configure this module, you need to:
2
+
3
+ 1. Go to *Inventory -\> Configuration -\> Settings* and check "Storage
4
+ Locations" option.
5
+
6
+ 2. Go to *Inventory -\> Configuration -\> Operation types*.
7
+
8
+ 3. Create a new operation type with the following options:
9
+ - \`Operation type\`: Task material
10
+ - \`Code\`: TM
11
+ - \`Type of operation\`: Delivery
12
+ - \`Default Source Location\`: WH/Stock
13
+ - \`Default Destination Location\`: WH/Stock/Shelf 1
14
+
15
+ 4. Go to *Project -\> Configuration -\> Projects*.
16
+
17
+ 5. Create a new project with the following options:
18
+ - \`Name\`: Task material
19
+ - \`Operation type\`: Task material
20
+
21
+ 6. Go to *Project -\> Configuration -\> Task Stages* and edit some records.
22
+ - \`In progress\`: Check Use Stock Moves option and add the created
23
+ project.
24
+ - \`Done\`: Check Use Stock Moves option + Done Stock Moves and add
25
+ the created project.
@@ -0,0 +1,3 @@
1
+ - [Tecnativa](https://www.tecnativa.com):
2
+ - Víctor Martínez
3
+ - Pedro M. Baeza
@@ -0,0 +1 @@
1
+ This module allows to consume products directly from a project task.
@@ -0,0 +1,21 @@
1
+ 1. Go to *Projects -\> Task material (project)* and create a task and
2
+ edit it.
3
+
4
+ 2. *Stock Info* is displayed in the *Extra info* tab with the same
5
+ project information, but it can be modified.
6
+
7
+ 3. Add some product to *Stock Info* tab and set some initial demand (1
8
+ for example).
9
+
10
+ 4. Click on the button *Confirm material* to confirm all moves.
11
+
12
+ 5. Change the stage to Done.
13
+
14
+ 6. *Stock Info* tab is readonly and some buttons show in header:
15
+ - \`Check availability materials\`: Product availability will be
16
+ checked.
17
+ - \`Transfer Materials\`: Stock moves are confirmed and moved from
18
+ one location to another.
19
+ - \`Unreserve\`: Remove the reservation stock of the products.
20
+ - \`Cancel Materials\`: Set the moves of the products as cancelled.
21
+ - \`Scrap\`: Allows the defined products to be scrapped.