[IMP] stock_picking_batch_collect: collected checks on transfers, ordered quantity, delivery slips report
- The collected checks also apply to transfers outside a batch, opt-in per
operation type ("Require Collected Lines"), with the batch restrictions of
the settings. A per-product toggle in the transfer's Operations tab collects
all its lines.
- Read-only ordered quantity from the sale line on every batch and transfer
view, since the weighed demand overwrites it. Adds a dependency on
sale_stock; a migration fills it in the summary of open batches.
- "Delivery Slips" report on the batch: all its delivery slips in one PDF.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
76dc355c6c
commit
9c81cb986d
24 changed files with 630 additions and 39 deletions
|
|
@ -1,6 +1,8 @@
|
|||
from . import res_company # noqa: F401
|
||||
from . import res_config_settings # noqa: F401
|
||||
from . import stock_backorder_confirmation # noqa: F401
|
||||
from . import stock_move # noqa: F401
|
||||
from . import stock_move_line # noqa: F401
|
||||
from . import stock_picking # noqa: F401
|
||||
from . import stock_picking_batch # noqa: F401
|
||||
from . import stock_picking_type # noqa: F401
|
||||
|
|
|
|||
|
|
@ -7,17 +7,15 @@ from odoo import models
|
|||
class StockBackorderConfirmation(models.TransientModel):
|
||||
_inherit = "stock.backorder.confirmation"
|
||||
|
||||
def _check_batch_summary_lines(self):
|
||||
pickings = self.env["stock.picking"].browse(
|
||||
def _check_collected_products(self):
|
||||
self.env["stock.picking"].browse(
|
||||
self.env.context.get("button_validate_picking_ids", [])
|
||||
)
|
||||
for batch in pickings.batch_id:
|
||||
batch._check_all_products_collected(pickings)
|
||||
)._check_collected_products()
|
||||
|
||||
def process(self):
|
||||
self._check_batch_summary_lines()
|
||||
self._check_collected_products()
|
||||
return super().process()
|
||||
|
||||
def process_cancel_backorder(self):
|
||||
self._check_batch_summary_lines()
|
||||
self._check_collected_products()
|
||||
return super().process_cancel_backorder()
|
||||
|
|
|
|||
55
stock_picking_batch_collect/models/stock_move.py
Normal file
55
stock_picking_batch_collect/models/stock_move.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# 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
|
||||
|
||||
|
||||
class StockMove(models.Model):
|
||||
_inherit = "stock.move"
|
||||
|
||||
is_collected = fields.Boolean(
|
||||
string="Collected",
|
||||
compute="_compute_is_collected",
|
||||
inverse="_inverse_is_collected",
|
||||
)
|
||||
sale_ordered_qty = fields.Float(
|
||||
string="Ordered Quantity",
|
||||
digits="Product Unit of Measure",
|
||||
compute="_compute_sale_ordered_qty",
|
||||
)
|
||||
|
||||
@api.depends("move_line_ids.is_collected")
|
||||
def _compute_is_collected(self):
|
||||
for move in self:
|
||||
lines = move.move_line_ids
|
||||
move.is_collected = bool(lines) and all(lines.mapped("is_collected"))
|
||||
|
||||
def _inverse_is_collected(self):
|
||||
collected = self.filtered("is_collected")
|
||||
collected.move_line_ids.filtered(
|
||||
lambda line: not line.is_collected
|
||||
).is_collected = True
|
||||
(self - collected).move_line_ids.filtered("is_collected").is_collected = False
|
||||
|
||||
@api.depends(
|
||||
"product_id",
|
||||
"product_uom",
|
||||
"sale_line_id.product_id",
|
||||
"sale_line_id.product_uom",
|
||||
"sale_line_id.product_uom_qty",
|
||||
)
|
||||
def _compute_sale_ordered_qty(self):
|
||||
"""What the customer ordered, which the weighed demand overwrites.
|
||||
|
||||
Kit components carry the kit's sale line, whose quantity is not theirs.
|
||||
"""
|
||||
for move in self:
|
||||
line = move.sale_line_id
|
||||
if line and line.product_id == move.product_id and move.product_uom:
|
||||
move.sale_ordered_qty = line.product_uom._compute_quantity(
|
||||
line.product_uom_qty, move.product_uom, round=False
|
||||
)
|
||||
else:
|
||||
move.sale_ordered_qty = 0.0
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -35,6 +36,23 @@ class StockMoveLine(models.Model):
|
|||
copy=False,
|
||||
)
|
||||
|
||||
sale_ordered_qty = fields.Float(
|
||||
string="Ordered Quantity",
|
||||
digits="Product Unit of Measure",
|
||||
compute="_compute_sale_ordered_qty",
|
||||
)
|
||||
|
||||
@api.depends("move_id.sale_ordered_qty", "move_id.product_uom", "product_uom_id")
|
||||
def _compute_sale_ordered_qty(self):
|
||||
for line in self:
|
||||
move = line.move_id
|
||||
if move.sale_ordered_qty and line.product_uom_id:
|
||||
line.sale_ordered_qty = move.product_uom._compute_quantity(
|
||||
move.sale_ordered_qty, line.product_uom_id, round=False
|
||||
)
|
||||
else:
|
||||
line.sale_ordered_qty = 0.0
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if vals.get("is_collected"):
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
|
|
@ -19,21 +20,68 @@ class StockPicking(models.Model):
|
|||
if res is not True:
|
||||
return res
|
||||
|
||||
self._check_batch_collected_products()
|
||||
self._check_collected_products()
|
||||
return res
|
||||
|
||||
def _check_batch_collected_products(self):
|
||||
def _get_open_move_lines(self, scope):
|
||||
"""Move lines still to process; ``processed`` keeps those with quantity."""
|
||||
lines = self.move_line_ids.filtered(
|
||||
lambda line: line.move_id.state not in ("cancel", "done")
|
||||
and line.product_id
|
||||
)
|
||||
if scope == "processed":
|
||||
lines = lines.filtered(lambda line: line.move_id.quantity > 0)
|
||||
return lines
|
||||
|
||||
def _get_not_collected_move_lines(self, scope):
|
||||
return self._get_open_move_lines(scope).filtered(
|
||||
lambda line: not line.is_collected
|
||||
)
|
||||
|
||||
def _check_collected_products(self):
|
||||
for batch in self.batch_id:
|
||||
batch_pickings = self.filtered(
|
||||
lambda picking, batch=batch: picking.batch_id == batch
|
||||
)
|
||||
processed_move_lines = 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
|
||||
)
|
||||
if batch_pickings._get_open_move_lines("processed"):
|
||||
batch._check_all_products_collected(batch_pickings)
|
||||
|
||||
loose_pickings = self.filtered(
|
||||
lambda picking: not picking.batch_id
|
||||
and picking.picking_type_id.collected_check_required
|
||||
)
|
||||
for picking in loose_pickings:
|
||||
if picking._get_open_move_lines("processed"):
|
||||
picking._check_picking_collected()
|
||||
|
||||
def _check_picking_collected(self):
|
||||
"""Apply the batch restrictions to a transfer outside a batch.
|
||||
|
||||
A single transfer has no product summary: the toggle of its Operations
|
||||
tab collects every line of the product, so both restrictions check its
|
||||
move lines, with the widest scope enabled.
|
||||
"""
|
||||
self.ensure_one()
|
||||
company = self.company_id
|
||||
scopes = set()
|
||||
if company.batch_detailed_restriction_enabled:
|
||||
scopes.add(company.batch_detailed_restriction_scope)
|
||||
if company.batch_summary_restriction_enabled:
|
||||
scopes.add(company.batch_summary_restriction_scope)
|
||||
if not scopes:
|
||||
return
|
||||
scope = "all" if "all" in scopes else "processed"
|
||||
not_collected = self._get_not_collected_move_lines(scope)
|
||||
if not not_collected:
|
||||
return
|
||||
product_names = ", ".join(
|
||||
sorted(set(not_collected.mapped("product_id.display_name")))
|
||||
)
|
||||
raise UserError(
|
||||
self.env._(
|
||||
"You must mark the operations of %(picking)s as collected "
|
||||
"before validating it.\nPending products: %(products)s",
|
||||
picking=self.name,
|
||||
products=product_names,
|
||||
)
|
||||
if not processed_move_lines:
|
||||
continue
|
||||
batch._check_all_products_collected(batch_pickings)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,10 +25,12 @@ class StockPickingBatch(models.Model):
|
|||
"move_line_ids.move_id.quantity",
|
||||
"move_line_ids.move_id.product_uom",
|
||||
"move_line_ids.move_id.state",
|
||||
"move_line_ids.move_id.sale_line_id.product_uom_qty",
|
||||
)
|
||||
def _compute_summary_line_ids(self):
|
||||
"""Aggregate move quantities per product and keep collected flag.
|
||||
|
||||
- Ordered: sale line quantity converted to product UoM, once per line.
|
||||
- Demand: move.product_uom_qty converted to product UoM.
|
||||
- Done: move.quantity converted to product UoM.
|
||||
- Pending: demand - done.
|
||||
|
|
@ -49,6 +51,7 @@ class StockPickingBatch(models.Model):
|
|||
}
|
||||
# Use regular dict to avoid accidental creation of empty entries
|
||||
aggregates = {}
|
||||
counted_sale_lines = set()
|
||||
|
||||
# Demand per move (count once per move)
|
||||
for move in batch.move_ids:
|
||||
|
|
@ -63,10 +66,22 @@ class StockPickingBatch(models.Model):
|
|||
aggregates[product.id] = {
|
||||
"product_id": product.id,
|
||||
"product_uom_id": product_uom.id,
|
||||
"qty_ordered": 0.0,
|
||||
"qty_demanded": 0.0,
|
||||
"qty_done": 0.0,
|
||||
}
|
||||
aggregates[product.id]["qty_demanded"] += demand
|
||||
if move.sale_ordered_qty and move.sale_line_id.id not in (
|
||||
counted_sale_lines
|
||||
):
|
||||
counted_sale_lines.add(move.sale_line_id.id)
|
||||
aggregates[product.id][
|
||||
"qty_ordered"
|
||||
] += move.product_uom._compute_quantity(
|
||||
move.sale_ordered_qty,
|
||||
product_uom,
|
||||
rounding_method="HALF-UP",
|
||||
)
|
||||
|
||||
# Done per move line
|
||||
for line in batch.move_line_ids:
|
||||
|
|
@ -84,6 +99,7 @@ class StockPickingBatch(models.Model):
|
|||
aggregates[product.id] = {
|
||||
"product_id": product.id,
|
||||
"product_uom_id": product_uom.id,
|
||||
"qty_ordered": 0.0,
|
||||
"qty_demanded": 0.0,
|
||||
"qty_done": 0.0,
|
||||
}
|
||||
|
|
@ -108,6 +124,7 @@ class StockPickingBatch(models.Model):
|
|||
existing_line.id,
|
||||
{
|
||||
"product_uom_id": values["product_uom_id"],
|
||||
"qty_ordered": values["qty_ordered"],
|
||||
"qty_demanded": values["qty_demanded"],
|
||||
"qty_done": values["qty_done"],
|
||||
"is_collected": is_collected,
|
||||
|
|
@ -122,6 +139,7 @@ class StockPickingBatch(models.Model):
|
|||
{
|
||||
"product_id": values["product_id"],
|
||||
"product_uom_id": values["product_uom_id"],
|
||||
"qty_ordered": values["qty_ordered"],
|
||||
"qty_demanded": values["qty_demanded"],
|
||||
"qty_done": values["qty_done"],
|
||||
"is_collected": is_collected,
|
||||
|
|
@ -151,30 +169,16 @@ class StockPickingBatch(models.Model):
|
|||
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")
|
||||
)
|
||||
processed_products = batch_pickings._get_open_move_lines(
|
||||
"processed"
|
||||
).product_id
|
||||
summary_lines = summary_lines.filtered(
|
||||
lambda line: line.product_id.id in processed_product_ids
|
||||
lambda line: line.product_id in processed_products
|
||||
)
|
||||
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)
|
||||
return batch_pickings._get_not_collected_move_lines(scope)
|
||||
|
||||
def _check_all_products_collected(self, pickings=None):
|
||||
"""Validate collected restrictions based on tab configuration."""
|
||||
|
|
@ -285,6 +289,11 @@ class StockPickingBatchSummaryLine(models.Model):
|
|||
string="Unit of Measure",
|
||||
required=True,
|
||||
)
|
||||
qty_ordered = fields.Float(
|
||||
string="Ordered Quantity",
|
||||
digits="Product Unit of Measure",
|
||||
default=0.0,
|
||||
)
|
||||
qty_demanded = fields.Float(
|
||||
string="Demanded Quantity",
|
||||
digits="Product Unit of Measure",
|
||||
|
|
|
|||
16
stock_picking_batch_collect/models/stock_picking_type.py
Normal file
16
stock_picking_batch_collect/models/stock_picking_type.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import fields
|
||||
from odoo import models
|
||||
|
||||
|
||||
class StockPickingType(models.Model):
|
||||
_inherit = "stock.picking.type"
|
||||
|
||||
collected_check_required = fields.Boolean(
|
||||
string="Require Collected Lines",
|
||||
help="Transfers of this type that are not in a batch can only be "
|
||||
"validated once their lines are marked as collected, following the "
|
||||
"batch validation restrictions of the inventory settings.",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue