Move 124 sale modules to oca-sale, create oca-project with 56 project modules from oca-workflow-process

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ernad Husremovic 2025-08-30 18:04:10 +02:00
parent 9eb7ae5807
commit 6094c218b2
2332 changed files with 125826 additions and 0 deletions

View file

@ -0,0 +1,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import account_analytic_line
from . import project_project
from . import project_task
from . import stock_move
from . import stock_scrap

View file

@ -0,0 +1,21 @@
# Copyright 2022 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from odoo import fields, models
class AccountAnalyticLine(models.Model):
_inherit = "account.analytic.line"
stock_task_id = fields.Many2one(
comodel_name="project.task", string="Project Task", ondelete="cascade"
)
def _timesheet_postprocess_values(self, values):
"""When hr_timesheet addon is installed, in the create() and write() methods,
the amount is recalculated according to the employee cost.
We need to force that in the records related to stock tasks the price is not
updated."""
res = super()._timesheet_postprocess_values(values)
for key in self.filtered(lambda x: x.stock_task_id).ids:
res[key].pop("amount", None)
return res

View file

@ -0,0 +1,46 @@
# Copyright 2022 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from odoo import api, fields, models
class ProjectProject(models.Model):
_inherit = "project.project"
picking_type_id = fields.Many2one(
comodel_name="stock.picking.type",
string="Operation Type",
readonly=False,
domain="[('company_id', '=', company_id)]",
index=True,
check_company=True,
)
location_id = fields.Many2one(
comodel_name="stock.location",
string="Source Location",
readonly=False,
check_company=True,
index=True,
help="Default location from which materials are consumed.",
)
location_dest_id = fields.Many2one(
comodel_name="stock.location",
string="Destination Location",
readonly=False,
index=True,
check_company=True,
help="Default location to which materials are consumed.",
)
stock_analytic_date = fields.Date(string="Analytic date")
@api.onchange("picking_type_id")
def _onchange_picking_type_id(self):
self.location_id = self.picking_type_id.default_location_src_id.id
self.location_dest_id = self.picking_type_id.default_location_dest_id.id
def write(self, vals):
"""Update location information on pending moves when changed."""
res = super().write(vals)
field_names = ("location_id", "location_dest_id")
if any(vals.get(field) for field in field_names):
self.task_ids._update_moves_info()
return res

View file

@ -0,0 +1,261 @@
# Copyright 2022-2025 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class ProjectTask(models.Model):
_name = "project.task"
_inherit = ["project.task", "analytic.mixin"]
scrap_ids = fields.One2many(
comodel_name="stock.scrap", inverse_name="task_id", string="Scraps"
)
scrap_count = fields.Integer(
compute="_compute_scrap_move_count", string="Scrap Move"
)
move_ids = fields.One2many(
comodel_name="stock.move",
inverse_name="raw_material_task_id",
string="Stock Moves",
copy=False,
domain=[("scrapped", "=", False)],
)
use_stock_moves = fields.Boolean(related="stage_id.use_stock_moves")
done_stock_moves = fields.Boolean(related="stage_id.done_stock_moves")
stock_moves_is_locked = fields.Boolean(default=True)
allow_moves_action_confirm = fields.Boolean(
compute="_compute_allow_moves_action_confirm"
)
allow_moves_action_assign = fields.Boolean(
compute="_compute_allow_moves_action_assign"
)
stock_state = fields.Selection(
selection=[
("pending", "Pending"),
("confirmed", "Confirmed"),
("assigned", "Assigned"),
("done", "Done"),
("cancel", "Cancel"),
],
compute="_compute_stock_state",
)
picking_type_id = fields.Many2one(
comodel_name="stock.picking.type",
string="Operation Type",
readonly=False,
domain="[('company_id', '=', company_id)]",
index=True,
check_company=True,
)
location_id = fields.Many2one(
comodel_name="stock.location",
string="Source Location",
readonly=False,
index=True,
check_company=True,
)
location_dest_id = fields.Many2one(
comodel_name="stock.location",
string="Destination Location",
readonly=False,
index=True,
check_company=True,
)
stock_analytic_date = fields.Date(string="Analytic date")
unreserve_visible = fields.Boolean(
string="Allowed to Unreserve Inventory",
compute="_compute_unreserve_visible",
help="Technical field to check when we can unreserve",
)
stock_analytic_account_id = fields.Many2one(
comodel_name="account.analytic.account",
string="Move Analytic Account",
help="Move created will be assigned to this analytic account",
)
stock_analytic_distribution = fields.Json(
"Analytic Distribution",
copy=True,
readonly=False,
)
stock_analytic_line_ids = fields.One2many(
comodel_name="account.analytic.line",
inverse_name="stock_task_id",
string="Analytic Lines",
)
group_id = fields.Many2one(
comodel_name="procurement.group",
)
def _compute_scrap_move_count(self):
data = self.env["stock.scrap"].read_group(
[("task_id", "in", self.ids)], ["task_id"], ["task_id"]
)
count_data = {item["task_id"][0]: item["task_id_count"] for item in data}
for item in self:
item.scrap_count = count_data.get(item.id, 0)
@api.depends("move_ids", "move_ids.state")
def _compute_allow_moves_action_confirm(self):
for item in self:
item.allow_moves_action_confirm = any(
move.state == "draft" for move in item.move_ids
)
@api.depends("move_ids", "move_ids.state")
def _compute_allow_moves_action_assign(self):
for item in self:
item.allow_moves_action_assign = any(
move.state in ("confirmed", "partially_available")
for move in item.move_ids
)
@api.depends("move_ids", "move_ids.state")
def _compute_stock_state(self):
for task in self:
task.stock_state = "pending"
if task.move_ids:
states = task.mapped("move_ids.state")
for state in ("confirmed", "assigned", "done", "cancel"):
if state in states:
task.stock_state = state
break
@api.depends("move_ids", "move_ids.quantity_done")
def _compute_unreserve_visible(self):
for item in self:
already_reserved = item.mapped("move_ids.move_line_ids")
any_quantity_done = any([m.quantity_done > 0 for m in item.move_ids])
item.unreserve_visible = not any_quantity_done and already_reserved
@api.onchange("picking_type_id")
def _onchange_picking_type_id(self):
self.location_id = self.picking_type_id.default_location_src_id.id
self.location_dest_id = self.picking_type_id.default_location_dest_id.id
def _check_tasks_with_pending_moves(self):
if self.move_ids and "assigned" in self.mapped("move_ids.state"):
raise UserError(
_("It is not possible to change this with reserved movements in tasks.")
)
def _update_moves_info(self):
for item in self:
item._check_tasks_with_pending_moves()
picking_type = item.picking_type_id or item.project_id.picking_type_id
location = item.location_id or item.project_id.location_id
location_dest = item.location_dest_id or item.project_id.location_dest_id
moves = item.move_ids.filtered(
lambda x: x.state not in ("cancel", "done")
and (x.location_id != location or x.location_dest_id != location_dest)
)
moves.update(
{
"warehouse_id": location.warehouse_id.id,
"location_id": location.id,
"location_dest_id": location_dest.id,
"picking_type_id": picking_type.id,
}
)
self.action_assign()
@api.model
def _prepare_procurement_group_vals(self):
return {"name": "Task-ID: %s" % self.id}
def action_confirm(self):
self.mapped("move_ids")._action_confirm()
def action_assign(self):
self.action_confirm()
self.mapped("move_ids")._action_assign()
def button_scrap(self):
self.ensure_one()
move_items = self.move_ids.filtered(lambda x: x.state not in ("done", "cancel"))
return {
"name": _("Scrap"),
"view_mode": "form",
"res_model": "stock.scrap",
"view_id": self.env.ref("stock.stock_scrap_form_view2").id,
"type": "ir.actions.act_window",
"context": {
"default_task_id": self.id,
"product_ids": move_items.mapped("product_id").ids,
"default_company_id": self.company_id.id,
},
"target": "new",
}
def do_unreserve(self):
for item in self:
item.move_ids.filtered(
lambda x: x.state not in ("done", "cancel")
)._do_unreserve()
return True
def button_unreserve(self):
self.ensure_one()
self.do_unreserve()
return True
def action_cancel(self):
"""Cancel the stock moves and remove the analytic lines created from
stock moves when cancelling the task.
"""
self.mapped("move_ids.move_line_ids").write({"qty_done": 0})
# Use sudo to avoid error for users with no access to analytic
self.sudo().stock_analytic_line_ids.unlink()
self.stock_moves_is_locked = True
return True
def action_toggle_stock_moves_is_locked(self):
self.ensure_one()
self.stock_moves_is_locked = not self.stock_moves_is_locked
return True
def action_done(self):
# Filter valid stock moves (avoiding those done and cancelled).
for move in self.mapped("move_ids").filtered(
lambda x: x.state not in ("done", "cancel")
):
move.quantity_done = move.reserved_availability
self.move_ids._action_done()
def action_see_move_scrap(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("stock.action_stock_scrap")
action["domain"] = [("task_id", "=", self.id)]
action["context"] = dict(self._context, default_origin=self.name)
return action
def write(self, vals):
res = super().write(vals)
if "stage_id" in vals:
stage = self.env["project.task.type"].browse(vals.get("stage_id"))
if stage.done_stock_moves:
# Avoid permissions error if the user does not have access to stock.
self.sudo().action_assign()
# Update info
field_names = ("location_id", "location_dest_id")
if any(vals.get(field) for field in field_names):
self._update_moves_info()
return res
def unlink(self):
# Use sudo to avoid error to users with no access to analytic
# related to hr_timesheet addon
return super(ProjectTask, self.sudo()).unlink()
class ProjectTaskType(models.Model):
_inherit = "project.task.type"
use_stock_moves = fields.Boolean(
help="If you mark this check, when a task goes to this state, "
"it will use stock moves",
)
done_stock_moves = fields.Boolean(
help="If you check this box, when a task is in this state, you will not "
"be able to add more stock moves but they can be viewed."
)

View file

@ -0,0 +1,148 @@
# Copyright 2022-2025 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from odoo import api, fields, models
class StockMove(models.Model):
_inherit = "stock.move"
task_id = fields.Many2one(
comodel_name="project.task",
string="Related Task",
check_company=True,
)
raw_material_task_id = fields.Many2one(
comodel_name="project.task", string="Task for material", check_company=True
)
@api.onchange("product_id")
def _onchange_product_id(self):
"""It is necessary to overwrite the name to prevent set product name
from being auto-defined."""
res = super()._onchange_product_id()
if self.raw_material_task_id:
self.name = self.raw_material_task_id.name
return res
def _prepare_analytic_line_from_task(self):
product = self.product_id
company_id = self.env.company
task = self.task_id or self.raw_material_task_id
analytic_account = (
task.stock_analytic_account_id or task.project_id.analytic_account_id
)
if not analytic_account:
return False
# Apply sudo() in case there is any rule that does not allow access to
# the analytic account, for example with analytic_hr_department_restriction
analytic_account = analytic_account.sudo()
res = {
"date": (
task.stock_analytic_date
or task.project_id.stock_analytic_date
or fields.date.today()
),
"name": task.name + ": " + product.name,
"unit_amount": self.quantity_done,
"account_id": analytic_account.id,
"user_id": self._uid,
"product_uom_id": self.product_uom.id,
"company_id": analytic_account.company_id.id or self.env.company.id,
"partner_id": task.partner_id.id or task.project_id.partner_id.id or False,
"stock_task_id": task.id,
}
amount_unit = product.with_context(uom=self.product_uom.id).price_compute(
"standard_price"
)[product.id]
amount = amount_unit * self.quantity_done or 0.0
result = round(amount, company_id.currency_id.decimal_places) * -1
vals = {"amount": result}
analytic_line_fields = self.env["account.analytic.line"]._fields
# Extra fields added in account addon
if "ref" in analytic_line_fields:
vals["ref"] = task.name
if "product_id" in analytic_line_fields:
vals["product_id"] = product.id
# Prevent incoherence when hr_timesheet addon is installed.
if "project_id" in analytic_line_fields:
vals["project_id"] = False
# distributions
if task.stock_analytic_distribution:
new_amount = 0
for distribution in task.stock_analytic_distribution.values():
new_amount -= (amount / 100) * distribution
vals["amount"] = new_amount
res.update(vals)
return res
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
if self.env.context.get("default_raw_material_task_id"):
task = self.env["project.task"].browse(
self.env.context.get("default_raw_material_task_id")
)
if not task.group_id:
task.group_id = self.env["procurement.group"].create(
task._prepare_procurement_group_vals()
)
defaults.update(
{
"group_id": task.group_id.id,
"location_id": (
task.location_id.id or task.project_id.location_id.id
),
"location_dest_id": (
task.location_dest_id.id or task.project_id.location_dest_id.id
),
"picking_type_id": (
task.picking_type_id.id or task.project_id.picking_type_id.id
),
}
)
return defaults
def _action_done(self, cancel_backorder=False):
"""Create the analytical notes for stock movements linked to tasks."""
moves_todo = super()._action_done(cancel_backorder)
# Use sudo to avoid error for users with no access to analytic
analytic_line_model = self.env["account.analytic.line"].sudo()
for move in moves_todo.filtered(lambda x: x.raw_material_task_id):
vals = move._prepare_analytic_line_from_task()
if vals:
analytic_line_model.create(vals)
return moves_todo
def action_task_product_forecast_report(self):
self.ensure_one()
action = self.product_id.action_product_forecast_report()
action["context"] = {
"active_id": self.product_id.id,
"active_model": "product.product",
"move_to_match_ids": self.ids,
}
warehouse = self.warehouse_id
if warehouse:
action["context"]["warehouse"] = warehouse.id
return action
class StockMoveLine(models.Model):
_inherit = "stock.move.line"
task_id = fields.Many2one(
comodel_name="project.task",
string="Task",
compute="_compute_task_id",
store=True,
)
@api.depends("move_id.raw_material_task_id", "move_id.task_id")
def _compute_task_id(self):
for item in self:
task = (
item.move_id.raw_material_task_id
if item.move_id.raw_material_task_id
else item.move_id.task_id
)
item.task_id = task if task else False

View file

@ -0,0 +1,25 @@
# Copyright 2022 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
from odoo import api, fields, models
class StockMove(models.Model):
_inherit = "stock.scrap"
task_id = fields.Many2one(
comodel_name="project.task", string="Task", check_company=True
)
@api.onchange("task_id")
def _onchange_task_id(self):
if self.task_id:
self.location_id = self.task_id.move_raw_ids.filtered(
lambda x: x.state not in ("done", "cancel")
) and (self.task_id.location_src_id.id or self.task_id.location_dest_id.id)
def _prepare_move_values(self):
vals = super()._prepare_move_values()
if self.task_id:
vals["origin"] = vals["origin"] or self.task_id.name
vals.update({"raw_material_task_id": self.task_id.id})
return vals