"custom" said nothing about what the module does. It is really about collecting goods into baskets: the extra detailed-operation columns, the is_collected flag, the Product Summary tab, the per-company validation restrictions and the Basket Assembly operator view all serve that one job. Rename it accordingly. Invert the dependency while at it. A generic warehouse addon was dragging in an entire eCommerce application, and the whole coupling was a single field: stock.move.line.home_delivery, related to picking_id.home_delivery. Everything else was already duck-typed. website_sale_aplicoop now depends on this module and injects its own consumer group columns into these views. This removes duplicated logic rather than relocating it: stock.picking .batch_consumer_group_id re-derived from sale_id a value aplicoop already stored as stock.picking.consumer_group_id, and the duplicate carried no @api.depends, so it never recomputed reliably. The batch transfers list now shows the stored field, which is sortable and groupable. The two aplicoop tests that probed information_schema for the res_company batch_* columns can drop that guard: a real dependency guarantees them. Renaming an addon is not something a migrations/ script can do, since a renamed addon is a brand new module to Odoo and its migration scripts never run. A pre_init_hook does it instead: it fires on install after the Python is imported but before registry.load(), which is the window where remapping ir_model_data makes Odoo reuse the existing tables and columns. is_collected, the summary line table and the company settings all survive untouched. Two details the hook has to get right: - ir_model_constraint.module and ir_model_relation.module are integer FKs with ON DELETE CASCADE, so they must be repointed before the old module row is deleted or the bookkeeping goes with it. - Deleting an ir_model_data row does not cascade to the record it points at. Artifacts handed over to aplicoop only need the xmlid dropped, but artifacts that disappear need the record deleted too, or the field survives as an orphan manual field and the view as a custom view referencing it. Verified against a restored copy of the dev database: 50 xmlids moved, 19 summary lines and 5 collected move lines preserved, no orphans, both test suites green, and the module installs cleanly on a database without website_sale_aplicoop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
311 lines
11 KiB
Python
311 lines
11 KiB
Python
# Copyright 2026 Criptomart
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
|
|
|
from odoo import api
|
|
from odoo import fields
|
|
from odoo import models
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
class StockPickingBatch(models.Model):
|
|
_inherit = "stock.picking.batch"
|
|
|
|
summary_line_ids = fields.One2many(
|
|
comodel_name="stock.picking.batch.summary.line",
|
|
inverse_name="batch_id",
|
|
string="Product Summary",
|
|
compute="_compute_summary_line_ids",
|
|
store=True,
|
|
readonly=True,
|
|
)
|
|
|
|
@api.depends(
|
|
"move_line_ids.move_id.product_id",
|
|
"move_line_ids.move_id.product_uom_qty",
|
|
"move_line_ids.move_id.quantity",
|
|
"move_line_ids.move_id.product_uom",
|
|
"move_line_ids.move_id.state",
|
|
)
|
|
def _compute_summary_line_ids(self):
|
|
"""Aggregate move quantities per product and keep collected flag.
|
|
|
|
- Demand: move.product_uom_qty converted to product UoM.
|
|
- Done: move.quantity converted to product UoM.
|
|
- Pending: demand - done.
|
|
- Keep is_collected value if product already present.
|
|
- Skip cancelled moves.
|
|
"""
|
|
|
|
for batch in self:
|
|
# Base: limpiar si no hay líneas
|
|
if not batch.move_line_ids:
|
|
batch.summary_line_ids = [fields.Command.clear()]
|
|
continue
|
|
|
|
previous_collected = {
|
|
line.product_id.id: line.is_collected
|
|
for line in batch.summary_line_ids
|
|
if line.product_id
|
|
}
|
|
# Use regular dict to avoid accidental creation of empty entries
|
|
aggregates = {}
|
|
|
|
# Demand per move (count once per move)
|
|
for move in batch.move_ids:
|
|
if move.state == "cancel" or not move.product_id:
|
|
continue
|
|
product = move.product_id
|
|
product_uom = product.uom_id
|
|
demand = move.product_uom._compute_quantity(
|
|
move.product_uom_qty, product_uom, rounding_method="HALF-UP"
|
|
)
|
|
if product.id not in aggregates:
|
|
aggregates[product.id] = {
|
|
"product_id": product.id,
|
|
"product_uom_id": product_uom.id,
|
|
"qty_demanded": 0.0,
|
|
"qty_done": 0.0,
|
|
}
|
|
aggregates[product.id]["qty_demanded"] += demand
|
|
|
|
# Done per move line
|
|
for line in batch.move_line_ids:
|
|
move = line.move_id
|
|
if move and move.state == "cancel":
|
|
continue
|
|
product = line.product_id
|
|
if not product:
|
|
continue
|
|
product_uom = product.uom_id
|
|
done = line.product_uom_id._compute_quantity(
|
|
line.quantity, product_uom, rounding_method="HALF-UP"
|
|
)
|
|
if product.id not in aggregates:
|
|
aggregates[product.id] = {
|
|
"product_id": product.id,
|
|
"product_uom_id": product_uom.id,
|
|
"qty_demanded": 0.0,
|
|
"qty_done": 0.0,
|
|
}
|
|
aggregates[product.id]["qty_done"] += done
|
|
|
|
commands = []
|
|
products_in_totals = set()
|
|
existing_by_product = {
|
|
line.product_id.id: line for line in batch.summary_line_ids
|
|
}
|
|
|
|
for product_id, values in aggregates.items():
|
|
# Double-check: skip if product_id is not valid
|
|
if not product_id or not values.get("product_id"):
|
|
continue
|
|
products_in_totals.add(product_id)
|
|
existing_line = existing_by_product.get(product_id)
|
|
is_collected = previous_collected.get(product_id, False)
|
|
if existing_line:
|
|
commands.append(
|
|
fields.Command.update(
|
|
existing_line.id,
|
|
{
|
|
"product_uom_id": values["product_uom_id"],
|
|
"qty_demanded": values["qty_demanded"],
|
|
"qty_done": values["qty_done"],
|
|
"is_collected": is_collected,
|
|
},
|
|
)
|
|
)
|
|
else:
|
|
# Ensure all required fields are present before create
|
|
if values["product_id"] and values["product_uom_id"]:
|
|
commands.append(
|
|
fields.Command.create(
|
|
{
|
|
"product_id": values["product_id"],
|
|
"product_uom_id": values["product_uom_id"],
|
|
"qty_demanded": values["qty_demanded"],
|
|
"qty_done": values["qty_done"],
|
|
"is_collected": is_collected,
|
|
}
|
|
)
|
|
)
|
|
|
|
obsolete_lines = [
|
|
fields.Command.unlink(line.id)
|
|
for line in batch.summary_line_ids
|
|
if line.product_id.id not in products_in_totals
|
|
]
|
|
|
|
if commands or obsolete_lines:
|
|
batch.summary_line_ids = commands + obsolete_lines
|
|
else:
|
|
batch.summary_line_ids = [fields.Command.clear()]
|
|
|
|
def _raise_collected_restriction(self, base_message, product_names):
|
|
message = self.env._(base_message)
|
|
if product_names:
|
|
message += "\n" + self.env._(
|
|
"Pending products: %(products)s", products=product_names
|
|
)
|
|
raise UserError(message)
|
|
|
|
def _get_not_collected_summary_lines(self, batch_pickings, scope):
|
|
summary_lines = self.summary_line_ids
|
|
if scope == "processed":
|
|
processed_product_ids = set(
|
|
batch_pickings.move_line_ids.filtered(
|
|
lambda line: (
|
|
line.move_id.state not in ("cancel", "done")
|
|
and line.product_id
|
|
and line.move_id.quantity > 0
|
|
)
|
|
).mapped("product_id.id")
|
|
)
|
|
summary_lines = summary_lines.filtered(
|
|
lambda line: line.product_id.id in processed_product_ids
|
|
)
|
|
return summary_lines.filtered(lambda line: not line.is_collected)
|
|
|
|
def _get_not_collected_detailed_lines(self, batch_pickings, scope):
|
|
detailed_lines = batch_pickings.move_line_ids.filtered(
|
|
lambda line: line.move_id.state not in ("cancel", "done")
|
|
and line.product_id
|
|
)
|
|
if scope == "processed":
|
|
detailed_lines = detailed_lines.filtered(
|
|
lambda line: line.move_id.quantity > 0
|
|
)
|
|
return detailed_lines.filtered(lambda line: not line.is_collected)
|
|
|
|
def _check_all_products_collected(self, pickings=None):
|
|
"""Validate collected restrictions based on tab configuration."""
|
|
|
|
for batch in self:
|
|
company = batch.company_id or self.env.company
|
|
batch_pickings = (
|
|
pickings.filtered(lambda p, batch=batch: p.batch_id == batch)
|
|
if pickings
|
|
else batch.picking_ids
|
|
)
|
|
if not batch_pickings:
|
|
continue
|
|
|
|
if company.batch_detailed_restriction_enabled:
|
|
not_collected_detailed = batch._get_not_collected_detailed_lines(
|
|
batch_pickings, company.batch_detailed_restriction_scope
|
|
)
|
|
if not_collected_detailed:
|
|
product_names = ", ".join(
|
|
sorted(
|
|
set(
|
|
not_collected_detailed.mapped("product_id.display_name")
|
|
)
|
|
)
|
|
)
|
|
batch._raise_collected_restriction(
|
|
"You must mark detailed operation lines as collected before validating the batch.",
|
|
product_names,
|
|
)
|
|
|
|
if company.batch_summary_restriction_enabled:
|
|
not_collected_summary = batch._get_not_collected_summary_lines(
|
|
batch_pickings, company.batch_summary_restriction_scope
|
|
)
|
|
if not_collected_summary:
|
|
product_names = ", ".join(
|
|
sorted(
|
|
set(not_collected_summary.mapped("product_id.display_name"))
|
|
)
|
|
)
|
|
batch._raise_collected_restriction(
|
|
"You must mark product summary lines as collected before validating the batch.",
|
|
product_names,
|
|
)
|
|
|
|
def action_open_operator_view(self):
|
|
self.ensure_one()
|
|
return {
|
|
"type": "ir.actions.act_window",
|
|
"name": self.name,
|
|
"res_model": "stock.move.line",
|
|
"view_mode": "list",
|
|
"views": [
|
|
(
|
|
self.env.ref(
|
|
"stock_picking_batch_collect.view_move_line_batch_operator"
|
|
).id,
|
|
"list",
|
|
)
|
|
],
|
|
"domain": [("batch_id", "=", self.id)],
|
|
"context": {
|
|
"create": False,
|
|
"delete": True,
|
|
},
|
|
}
|
|
|
|
|
|
class StockPickingBatchSummaryLine(models.Model):
|
|
_name = "stock.picking.batch.summary.line"
|
|
_description = "Batch Product Summary Line"
|
|
_order = "product_categ_id, product_id"
|
|
|
|
_sql_constraints = [
|
|
(
|
|
"product_required",
|
|
"CHECK(product_id IS NOT NULL)",
|
|
"Product is required for summary lines.",
|
|
),
|
|
]
|
|
|
|
batch_id = fields.Many2one(
|
|
comodel_name="stock.picking.batch",
|
|
ondelete="cascade",
|
|
required=True,
|
|
index=True,
|
|
)
|
|
product_id = fields.Many2one(
|
|
comodel_name="product.product",
|
|
required=True,
|
|
index=True,
|
|
)
|
|
product_categ_id = fields.Many2one(
|
|
comodel_name="product.category",
|
|
string="Product Category",
|
|
related="product_id.categ_id",
|
|
store=True,
|
|
readonly=True,
|
|
)
|
|
product_default_code = fields.Char(
|
|
string="Product Code",
|
|
related="product_id.default_code",
|
|
readonly=True,
|
|
)
|
|
product_uom_id = fields.Many2one(
|
|
comodel_name="uom.uom",
|
|
string="Unit of Measure",
|
|
required=True,
|
|
)
|
|
qty_demanded = fields.Float(
|
|
string="Demanded Quantity",
|
|
digits="Product Unit of Measure",
|
|
required=True,
|
|
default=0.0,
|
|
)
|
|
qty_done = fields.Float(
|
|
string="Done Quantity",
|
|
digits="Product Unit of Measure",
|
|
required=True,
|
|
default=0.0,
|
|
)
|
|
qty_pending = fields.Float(
|
|
string="Pending Quantity",
|
|
digits="Product Unit of Measure",
|
|
compute="_compute_qty_pending",
|
|
store=True,
|
|
)
|
|
is_collected = fields.Boolean(string="Collected", default=False)
|
|
|
|
@api.depends("qty_demanded", "qty_done")
|
|
def _compute_qty_pending(self):
|
|
for line in self:
|
|
line.qty_pending = line.qty_demanded - line.qty_done
|