Compare commits

...

5 commits

Author SHA1 Message Date
GitHub Copilot
6a59d9f6ad [IMP] website_sale_aplicoop: compact mobile layout for the Eskaera shop
Reorder cart/tags/category/search/products into one flex row with
Bootstrap order utilities, so mobile shows cart, tags, category, search,
then products, while desktop keeps its current layout. Product cards
become a two-column grid of compact tiles from the smallest phones, with
a 4:3 photo instead of a tall rectangle. Origin, supplier and tags move
behind a per-product "i" toggle instead of always reserving their own
row, which also drops the now-unused any_product_has_tags plumbing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 15:24:34 +02:00
GitHub Copilot
ef1283be7c [REF] stock_picking_batch_collect: rename and drop the aplicoop dependency
"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>
2026-08-27 14:26:47 +02:00
GitHub Copilot
704f0def1b [ADD] web_list_striped: zebra striping for all backend list views
The zebra rows and the softer hover used to be shipped by
stock_picking_batch_custom, but its selector (`table.o_list_table`) carried no
module scope, so a warehouse addon was quietly restyling every list view in the
database. The scope was lost in 07bd632, while porting the selectors from Odoo
16 to 18.

The effect itself is wanted, so keep it and make it explicit: an asset-only
addon whose documented purpose is the global reach. Nothing depends on it, so
the striping can be turned off by uninstalling a single module.

An Odoo theme would not work here: `theme_*` addons only govern the website
frontend, and these rules live in `web.assets_backend`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:26:28 +02:00
GitHub Copilot
2e7549f857 [IMP] stock_picking_batch_custom: keep the weighed quantity on the operator view
Basket Assembly is where the reverting quantity was noticed, so it depends
on stock_move_manual_quantity now and its two lists load `picked`: the web
client only saves the fields present in the arch, and without it the
onchange that freezes a hand-typed quantity never reaches the server.

Collecting a line does the same, minus the demand. It is the operator saying
the goods are in the basket, so the quantity has to survive the reservation
engine even when it was never retyped -- but only a quantity typed by hand
says what the demand should become, and a line collected at zero would
otherwise lose its demand and be cancelled on validation.

Drops views/stock_move_line_views.xml on the way. It declared a second
record under the id stock_picking_batch_views.xml already uses, so it was
loaded first and immediately overwritten: dead weight that would have turned
into a duplicate-field view had anyone renamed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:26:56 +02:00
GitHub Copilot
d433e50f2f [ADD] stock_move_manual_quantity: a hand-typed quantity is the picked one
An operator weighing a basket types 0.87 kg where the member ordered 1 kg,
and a few hours later the line is back at 1 kg. Raising the quantity never
reverts. The asymmetry is the reservation engine: below the demand the move
goes back to `partially_available` (`_recompute_state`), which is exactly
the state `_action_assign` looks for, while above it stays `assigned` and
matches no domain.

Two things call `_action_assign` on their own: the `Procurement: run
scheduler` cron, and `_trigger_assign`, which runs every time another
transfer of the same product is validated -- the second explains the "a few
hours" better than a daily cron. Both respect one flag only, `picked`, and
nobody was setting it: the checkbox is `optional="hide"` on the transfer
form and absent from the detailed operation lists.

So an onchange on `quantity` marks the record as picked. Onchanges only run
from the interface, never from the reservation engine, so a manual edit is
told apart from a reservation without guessing at contexts, and if the
onchange ever stops firing the behaviour degrades to today's instead of
freezing reservations across the system. `picked` also keeps
`_free_reservation` from stealing the quantity when stock runs short
elsewhere. The three lists where a quantity can be typed now load the field,
since the client only sends back what the arch declares.

When the typed quantity is below the demand, the demand follows it down. The
move stays `assigned` and no backorder is asked for a weight that will never
be completed. Only downwards: above the demand nothing needs adjusting. A
line left at zero keeps its demand, so a transfer still offers its usual
backorder choice for what was not delivered, and chained moves are only
frozen -- lowering their demand would leave the next step of the route
asking for more than this one delivers.

`_pre_action_done_hook` needs the counterpart. Core auto-picks a transfer
only when *no* move is picked yet, so freezing one weighed line would push
the untouched ones to a backorder, or cancel them if the user answers "no
backorder", with the goods already in the basket. Picking everything that
carries a quantity keeps validation exactly as operators know it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:26:44 +02:00
77 changed files with 1864 additions and 930 deletions

View file

@ -189,9 +189,11 @@ addons-cm/
├── membership_monthly_invoicing/ # Factura mensual de membresía por socio (cron)
├── membership_expiry_reminder/ # Email recordatorio de renovación próxima
├── # --- Logística y contabilidad ---
├── stock_picking_batch_custom/ # Batch picking: columnas extra + vista operario (Basket Assembly)
├── stock_picking_batch_collect/ # Batch picking: columnas extra + vista operario (Basket Assembly)
├── account_banking_mandate_batch/ # Crear/validar mandatos SEPA en bloque desde contactos
└── l10n_es_edi_tbai_reagyp_recibidas/ # Fix TicketBAI REAGYP facturas recibidas (19 → 02)
├── l10n_es_edi_tbai_reagyp_recibidas/ # Fix TicketBAI REAGYP facturas recibidas (19 → 02)
├── # --- UI de backend ---
└── web_list_striped/ # Efecto cebra en todas las listas del backend (sólo CSS)
````
@ -233,7 +235,8 @@ addons-cm/
**Logística y contabilidad**
- [stock_picking_batch_custom](../stock_picking_batch_custom/README.rst) - Columnas extra y vista operario para batch picking
- [stock_picking_batch_collect](../stock_picking_batch_collect/README.rst) - Columnas extra, resumen por producto y vista operario para batch picking
- [web_list_striped](../web_list_striped/README.rst) - Efecto cebra en todas las listas del backend (sólo CSS)
- [account_banking_mandate_batch](../account_banking_mandate_batch/README.rst) - Creación masiva de mandatos SEPA desde contactos
- [l10n_es_edi_tbai_reagyp_recibidas](../l10n_es_edi_tbai_reagyp_recibidas/README.rst) - Fix TicketBAI REAGYP en facturas recibidas (clave régimen 19 → 02)
@ -758,6 +761,10 @@ last_purchase_price_compute_type != "manual_update" # Para auto-cálculo
`product_pricelist_total_margin`, `product_origin_char`, `l10n_es_edi_tbai_reagyp_recibidas`.
`product_origin` (OCA) sustituido por `product_origin_char` (custom).
- **2026-06-04**: `product_sale_price_from_pricelist` aplica descuentos de proveedor desde `supplierinfo`.
- **2026-08-27**: `stock_picking_batch_custom` renombrado a `stock_picking_batch_collect`
(v18.0.2.0.0). Se invierte la dependencia: ya no depende de `website_sale_aplicoop`; es este
el que depende de él e inyecta las columnas de grupo de consumo. El efecto cebra global se
extrae al nuevo addon `web_list_striped`. El renombrado en BD lo hace un `pre_init_hook`.
- **2026-06**: `stock_picking_batch_custom` — vista operario a pantalla completa (Basket Assembly),
ordenación de operaciones por categoría/producto/partner.
- **2026-02-18**: Refactor `product_main_seller` - Remover alias innecesario `default_supplier_id`

View file

@ -27,8 +27,9 @@ Addons desarrollados por Criptomart para necesidades específicas del proyecto.
| [product_price_category_supplier](product_price_category_supplier/) | 18.0.1.0.0 | Categoría de precio por defecto en proveedor + actualización masiva de productos |
| [product_pricelist_total_margin](product_pricelist_total_margin/) | 18.0.1.2.0 | Margen aditivo (no compuesto) en tarifas encadenadas, con límites globales |
| [product_sale_price_from_pricelist](product_sale_price_from_pricelist/) | 18.0.2.7.0 | Calcula precio de venta desde último precio de compra vía tarifa configurable |
| [stock_picking_batch_custom](stock_picking_batch_custom/) | 18.0.1.0.0 | Columnas extra en operaciones detalladas de lotes: partner, categoría, recogido |
| [website_sale_aplicoop](website_sale_aplicoop/) | 18.0.1.9.0 | Sistema de pedidos colaborativos para grupos de consumo (reemplazo de Aplicoop) |
| [stock_picking_batch_collect](stock_picking_batch_collect/) | 18.0.2.0.0 | Montaje de cestas en lotes: columnas extra, resumen por producto y vista operario |
| [web_list_striped](web_list_striped/) | 18.0.1.0.0 | Efecto cebra y hover suave en todas las listas del backend (sólo CSS) |
| [website_sale_aplicoop](website_sale_aplicoop/) | 18.0.1.17.0 | Sistema de pedidos colaborativos para grupos de consumo (reemplazo de Aplicoop) |
## Dependencias entre addons custom
@ -40,6 +41,12 @@ website_sale_aplicoop
└── product_main_seller
└── product_price_category_supplier
└── product_price_category
└── stock_picking_batch_collect
└── stock_move_manual_quantity
stock_picking_batch_collect
└── stock_move_manual_quantity
└── stock_picking_batch (Odoo core)
account_invoice_triple_discount_readonly
└── account_invoice_triple_discount

View file

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

View file

@ -0,0 +1,16 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018
"name": "Stock Move Manual Quantity",
"version": "18.0.1.0.0",
"category": "Warehouse",
"summary": "A hand-typed quantity is final: never re-reserved automatically",
"license": "AGPL-3",
"author": "Odoo Community Association (OCA), Criptomart",
"maintainers": ["Criptomart"],
"website": "https://git.criptomart.net/criptomart/addons-cm",
"depends": ["stock"],
"data": [
"views/stock_move_line_views.xml",
],
}

View file

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

View file

@ -0,0 +1,60 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api
from odoo import models
from odoo.tools import float_compare
from odoo.tools import float_is_zero
class StockMove(models.Model):
_inherit = "stock.move"
@api.onchange("quantity")
def _onchange_quantity_manual(self):
"""Mark the move as picked when a user types its quantity.
Covers the ``Operations`` tab of a transfer, where the quantity is
edited on the move and ``_set_quantity`` spreads it to the lines.
"""
for move in self:
if move.state not in ("done", "cancel"):
move.picked = True
def write(self, vals):
res = super().write(vals)
if "quantity" in vals and vals.get("picked"):
self._align_demand_to_manual_quantity()
return res
def _align_demand_to_manual_quantity(self):
"""Lower the demand of a move to the quantity a user typed by hand.
A move whose quantity is below its demand goes back to
``partially_available`` (``_recompute_state``), and that is precisely
what makes the scheduler reserve the difference again. Aligning the
demand keeps the move ``assigned`` and drops the backorder question for
a quantity that is final: a weighed product is never completed later.
The demand is only ever lowered. Above it the move is already
``assigned``, so nothing has to be touched.
"""
for move in self:
if move.state in ("draft", "done", "cancel") or move.is_inventory:
continue
if move.move_dest_ids:
# Chained moves: lowering the demand here would leave the next
# step of the route asking for more than this one delivers.
continue
rounding = move.product_uom.rounding
if float_is_zero(move.quantity, precision_rounding=rounding):
# Nothing was picked: leave the demand alone so the transfer
# still offers its usual backorder choice.
continue
if (
float_compare(
move.quantity, move.product_uom_qty, precision_rounding=rounding
)
< 0
):
move.product_uom_qty = move.quantity

View file

@ -0,0 +1,50 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api
from odoo import models
class StockMoveLine(models.Model):
_inherit = "stock.move.line"
@api.onchange("quantity")
def _onchange_quantity_manual(self):
"""A quantity typed by a user is the quantity physically handled.
Onchanges only run from the user interface, never from the reservation
engine, so this marks the lines a person edited without freezing the
ones ``_action_assign`` reserves on its own.
"""
for line in self:
if line.state not in ("done", "cancel"):
line.picked = True
def write(self, vals):
res = super().write(vals)
# `_onchange_quantity_manual` makes the interface send both fields in
# the same write, which is what tells a manual edit from a reservation.
if "quantity" in vals and vals.get("picked"):
self._freeze_manual_quantity()
return res
def _freeze_manual_quantity(self, align_demand=True):
"""Protect a hand-typed quantity from the reservation engine.
``_action_assign`` (run by the ``Procurement: run scheduler`` cron and
by ``_trigger_assign`` whenever another transfer of the same product is
validated) tops up every move that is not picked until it reaches its
demand, and ``_free_reservation`` steals quantities from move lines
that are not picked. Marking the whole move keeps both away from it.
"""
lines = self.filtered(lambda ml: ml.state not in ("done", "cancel"))
if not lines:
return
siblings = (lines.move_id.move_line_ids - lines).filtered(
lambda ml: not ml.picked and ml.state not in ("done", "cancel")
)
if siblings:
# `_action_done` unlinks the non picked lines of a picked move.
siblings.picked = True
if align_demand:
lines.move_id._align_demand_to_manual_quantity()

View file

@ -0,0 +1,31 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
from odoo.tools import float_is_zero
class StockPicking(models.Model):
_inherit = "stock.picking"
def _pre_action_done_hook(self):
"""Validate every line holding a quantity, picked by hand or not.
Core only auto-picks a transfer when *no* move is picked yet, so as
soon as an operator freezes one weighed line the untouched ones would
be pushed to a backorder -- or cancelled, if the user answers "no
backorder" -- even though their goods are in the basket. Picking
everything that carries a quantity keeps the behaviour operators know.
"""
for picking in self.filtered(lambda p: p.state not in ("done", "cancel")):
moves = picking.move_ids.filtered(
lambda move: not move.picked
and not move.scrapped
and move.state not in ("done", "cancel")
and not float_is_zero(
move.quantity, precision_rounding=move.product_uom.rounding
)
)
if moves:
moves.picked = True
return super()._pre_action_done_hook()

View file

@ -0,0 +1,2 @@
No configuration is required: the behaviour applies to every transfer as soon
as the module is installed.

View file

@ -0,0 +1,3 @@
* `Criptomart <https://criptomart.net>`_:
* Analysis of the reservation engine and implementation

View file

@ -0,0 +1,3 @@
**Authors:**
* Criptomart

View file

@ -0,0 +1,22 @@
When an operator types a quantity on a transfer line, that quantity is the one
that was physically handled: a bag of apples ordered as 1 kg that weighs
0.87 kg on the scale.
Odoo, however, treats a quantity below the demand as an incomplete
*reservation*. The move goes back to ``partially_available`` and
``_action_assign`` tops it up again to the demanded quantity, either from the
``Procurement: run scheduler`` cron or from ``_trigger_assign``, which runs
every time another transfer of the same product is validated. A few hours
later the weighed 0.87 kg is back to 1 kg. Raising the quantity is never
reverted, because the move stays ``assigned`` and no longer matches those
domains.
This module makes a hand-typed quantity final:
* Editing a quantity from the interface marks the move as **picked**, which is
what keeps ``_action_assign`` and ``_free_reservation`` away from it.
* When the typed quantity is below the demand, the demand is lowered to match,
so the move stays ``assigned`` and no backorder is asked for a quantity that
will never be completed.
* Transfers still validate every line that carries a quantity, picked by hand
or not, so freezing one line never pushes the untouched ones to a backorder.

View file

@ -0,0 +1,8 @@
To install this module, you need to:
#. Update the Apps list
#. Install *Stock Move Manual Quantity*
::
docker-compose run --rm odoo odoo -d odoo --stop-after-init -i stock_move_manual_quantity

View file

@ -0,0 +1,23 @@
Nothing to configure: the behaviour applies to every transfer as soon as the
module is installed.
**Weighing a line**
#. Open the transfer (or the batch operator view) and type the real quantity,
for example 0.87 instead of the demanded 1.
#. The move is marked as picked and, because 0.87 is below the demand, the
demand becomes 0.87 too. The move stays *Ready*.
#. Neither the scheduler nor the validation of another transfer of the same
product will change that quantity again.
**Notes**
* The demand is only ever lowered, never raised: above the demand the move is
already fully reserved and nothing needs to be adjusted.
* A line left at zero keeps the standard behaviour, so the transfer still
offers its usual backorder choice for what was not delivered.
* Chained moves (multi-step routes) are only frozen, their demand is left
untouched so the next step of the route stays consistent.
* Lowering the demand is logged in the transfer chatter, and it is what the
sale order uses to know how much is still to deliver while the transfer is
not validated.

View file

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

View file

@ -0,0 +1,184 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import Command
from odoo.tests import Form
from odoo.tests import TransactionCase
from odoo.tests import tagged
@tagged("-at_install", "post_install")
class TestManualQuantity(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.uom_kg = cls.env.ref("uom.product_uom_kgm")
cls.stock_location = cls.env.ref("stock.stock_location_stock")
cls.customer_location = cls.env.ref("stock.stock_location_customers")
cls.picking_type_out = cls.env.ref("stock.picking_type_out")
cls.apples = cls.env["product.product"].create(
{
"name": "Apples",
"is_storable": True,
"uom_id": cls.uom_kg.id,
"uom_po_id": cls.uom_kg.id,
}
)
cls.bread = cls.env["product.product"].create(
{
"name": "Bread",
"is_storable": True,
"uom_id": cls.uom_kg.id,
"uom_po_id": cls.uom_kg.id,
}
)
for product in cls.apples | cls.bread:
cls.env["stock.quant"]._update_available_quantity(
product, cls.stock_location, 100.0
)
# Helpers
def _create_picking(self, products_qty):
"""Create a confirmed and reserved delivery for ``{product: demand}``."""
picking = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type_out.id,
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
"move_ids": [
Command.create(
{
"name": product.name,
"product_id": product.id,
"product_uom": product.uom_id.id,
"product_uom_qty": qty,
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
}
)
for product, qty in products_qty.items()
],
}
)
picking.action_confirm()
picking.action_assign()
return picking
def _type_quantity(self, line, quantity):
"""Reproduce what the web client sends when a user types a quantity.
The onchange marks the line as picked, so both fields travel together
in the same write.
"""
edited = line.new(origin=line)
edited.quantity = quantity
edited._onchange_quantity_manual()
line.write({"quantity": quantity, "picked": edited.picked})
# Tests
def test_onchange_marks_the_line_as_picked(self):
picking = self._create_picking({self.apples: 1.0})
line = picking.move_ids.move_line_ids
edited = line.new(origin=line)
edited.quantity = 0.87
edited._onchange_quantity_manual()
self.assertTrue(edited.picked)
def test_reservation_does_not_restore_a_lowered_quantity(self):
picking = self._create_picking({self.apples: 1.0})
move = picking.move_ids
self.assertEqual(move.quantity, 1.0)
self._type_quantity(move.move_line_ids, 0.87)
self.assertTrue(move.picked)
self.assertEqual(move.product_uom_qty, 0.87)
self.assertEqual(move.state, "assigned")
# The two entry points that used to top the move back up to its demand.
move._action_assign()
self.env["procurement.group"].run_scheduler()
self.assertEqual(move.quantity, 0.87)
self.assertEqual(move.move_line_ids.quantity, 0.87)
def test_a_raised_quantity_keeps_its_demand(self):
picking = self._create_picking({self.apples: 1.0})
move = picking.move_ids
self._type_quantity(move.move_line_ids, 1.2)
self.assertEqual(move.product_uom_qty, 1.0)
self.assertEqual(move.state, "assigned")
move._action_assign()
self.assertEqual(move.quantity, 1.2)
def test_an_empty_line_keeps_its_demand(self):
"""A line left at zero still offers the standard backorder choice."""
picking = self._create_picking({self.apples: 1.0})
move = picking.move_ids
self._type_quantity(move.move_line_ids, 0.0)
self.assertEqual(move.product_uom_qty, 1.0)
def test_reserving_does_not_mark_moves_as_picked(self):
"""Only a person marks a move as picked, never the reservation."""
picking = self._create_picking({self.apples: 1.0})
picking.move_ids._action_assign()
self.env["procurement.group"].run_scheduler()
self.assertFalse(picking.move_ids.picked)
self.assertFalse(picking.move_ids.move_line_ids.picked)
def test_editing_the_move_quantity_freezes_it_too(self):
"""The Operations tab of a transfer edits the quantity on the move."""
picking = self._create_picking({self.apples: 1.0})
move = picking.move_ids
with Form(picking) as picking_form:
with picking_form.move_ids_without_package.edit(0) as move_form:
move_form.quantity = 0.87
self.assertTrue(move.picked)
self.assertEqual(move.product_uom_qty, 0.87)
move._action_assign()
self.assertEqual(move.quantity, 0.87)
def test_untouched_lines_are_still_validated(self):
"""Freezing one line must not push the others to a backorder."""
picking = self._create_picking({self.apples: 1.0, self.bread: 2.0})
apples_move = picking.move_ids.filtered(
lambda move: move.product_id == self.apples
)
bread_move = picking.move_ids - apples_move
self._type_quantity(apples_move.move_line_ids, 0.87)
picking.button_validate()
self.assertEqual(picking.state, "done")
self.assertEqual(apples_move.quantity, 0.87)
self.assertEqual(bread_move.state, "done")
self.assertEqual(bread_move.quantity, 2.0)
self.assertFalse(picking.backorder_ids)
def test_operation_views_load_the_picked_field(self):
"""The client only sends back the fields present in the arch."""
move_line = self.env["stock.move.line"]
for xml_id in (
"stock.view_stock_move_line_operation_tree",
"stock.view_stock_move_line_detailed_operation_tree",
):
with self.subTest(view=xml_id):
view = move_line.get_view(self.env.ref(xml_id).id, "list")
self.assertIn('name="picked"', view["arch"])

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
The web client only saves the fields present in the arch, so `picked`
has to be loaded wherever a quantity can be typed for
`_onchange_quantity_manual` to reach the server.
-->
<record id="view_stock_move_line_operation_tree_manual_quantity" model="ir.ui.view">
<field name="name">stock.move.line.operations.manual.quantity</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock.view_stock_move_line_operation_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='quantity']" position="after">
<field name="picked" column_invisible="True"/>
</xpath>
</field>
</record>
<record id="view_stock_move_line_detailed_operation_tree_manual_quantity" model="ir.ui.view">
<field name="name">stock.move.line.detailed.operations.manual.quantity</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock.view_stock_move_line_detailed_operation_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='quantity']" position="after">
<field name="picked" column_invisible="True"/>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,127 @@
===========================
Stock Picking Batch Collect
===========================
Visión general
==============
Este módulo convierte un lote de picking en una hoja de trabajo para el operario
que monta las cestas: amplía las operaciones detalladas, añade un resumen por
producto y una vista a pantalla completa para el almacén.
- ``picking_partner_id`` (Partner del albarán) para que el personal de almacén
identifique rápido el cliente/proveedor.
- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar. Las
líneas del lote se ordenan por categoría, producto y partner.
- ``product_default_code`` (Referencia interna) como columna opcional.
- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se
ha recolectado.
- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho,
pendiente), categoría y el check de recogido consolidado.
- Botón **Basket Assembly**: una vista de lista a pantalla completa, con
cantidades grandes y controles táctiles, pensada para tablet en el almacén.
- Restricciones de validación configurables por compañía: el lote no se puede
validar hasta que las líneas estén marcadas como recogidas.
Las columnas se añaden con ``optional="show"`` en la vista de líneas del lote
(salvo ``product_default_code``, oculta por defecto), de modo que el usuario
puede desactivarlas desde el selector de columnas sin recargar la vista.
Si además está instalado ``website_sale_aplicoop``, ese módulo añade a estas
mismas vistas las columnas de grupo de consumo (**Consumer Group** y **Home
Delivery**). Este módulo no depende de él: funciona igual sin la parte de
eCommerce.
Instalación
===========
Actualizar o instalar el módulo:
::
docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_collect
Este módulo sustituye a ``stock_picking_batch_custom``. Al instalarlo sobre una
base de datos que tuviera el módulo antiguo, un ``pre_init_hook`` adopta sus
datos (ver ``hooks.py``): se conservan las líneas recogidas, el resumen por
producto y la configuración por compañía.
Configuración
=============
Restricciones de validación
---------------------------
En **Inventory > Configuration > Settings**, bloque *Operations*, hay dos
ajustes por compañía:
- **Restricciones del resumen de productos**: exige que las líneas de la
pestaña *Product Summary* estén marcadas como recogidas para validar el lote.
Desactivado por defecto (el check es sólo informativo).
- **Restricciones de operaciones detalladas**: exige que las líneas de la
pestaña *Detailed Operations* estén marcadas como recogidas.
**Activado por defecto**: recién instalado el módulo, un lote no se puede
validar hasta marcar las líneas.
Cada restricción tiene un alcance:
- *Sólo procesadas*: se comprueban únicamente las líneas con cantidad hecha
mayor que cero (por defecto).
- *Todas*: se comprueban todas las líneas del lote, procesadas o no.
Columnas
--------
Las columnas están visibles por defecto. Para ajustarlas:
- Abrir un **Lote de picking**.
- Ir a la pestaña **Detailed Operations**.
- Abrir el **selector de columnas** y activar o desactivar *Partner*,
*Product Category*, *Product Code* o *Collected* según necesidad.
Uso
===
1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote.
2. Pestaña **Detailed Operations**: usa el selector de columnas para ajustar:
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
- **Collected** (``is_collected``) para marcar manualmente líneas recolectadas.
3. Pestaña **Product Summary**: consulta los totales por producto (demandado,
hecho y pendiente) y marca el check de recogido consolidado si corresponde.
4. Con el lote **en progreso**, el botón **Basket Assembly** abre la vista de
operario: la misma lista de líneas a pantalla completa, con la cantidad en
grande y el check *Collected* como interruptor táctil. Está pensada para
trabajar desde una tablet mientras se montan las cestas.
5. Ordena o agrupa por categoría en cualquiera de las vistas según convenga.
6. La cantidad que teclea el operario (el peso real de la balanza) queda fijada:
se marca como *Picked* y ni el planificador ni la validación de otros
albaranes vuelven a modificarla. Marcar **Collected** protege igualmente la
cantidad de la línea. Ver ``stock_move_manual_quantity``.
7. Al validar el lote, según la configuración de la compañía, se comprueba que
las líneas estén recogidas y se avisa con la lista de productos pendientes.
La comprobación se ejecuta después de resolver el backorder, de modo que
sólo bloquea sobre las líneas que realmente se van a procesar.
Contribuidores
==============
* Criptomart
Créditos
========
Autor
-----
* Criptomart
Financiador
-----------
* Elika Bilbo

View file

@ -0,0 +1,2 @@
from . import models # noqa: F401
from .hooks import pre_init_hook # noqa: F401

View file

@ -0,0 +1,31 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018
"name": "Stock Picking Batch Collect",
"version": "18.0.2.0.0",
"category": "Warehouse",
"summary": "Collect batch operations: operator view, extra columns and product summary",
"author": "Odoo Community Association (OCA), Criptomart",
"maintainers": ["Criptomart"],
"website": "https://github.com/Criptomart",
"license": "AGPL-3",
"depends": [
# A quantity typed by the operator is the weighed one: it must not be
# re-reserved by the scheduler.
"stock_move_manual_quantity",
"stock_picking_batch",
],
"data": [
"security/ir.model.access.csv",
"views/res_config_settings_views.xml",
"views/stock_picking_batch_views.xml",
],
"assets": {
"web.assets_backend": [
"stock_picking_batch_collect/static/src/css/stock_picking_batch.css",
],
},
# Adopts the data of the former `stock_picking_batch_custom` when upgrading
# a database where that module was installed. See `hooks.py`.
"pre_init_hook": "pre_init_hook",
}

View file

@ -0,0 +1,117 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
_logger = logging.getLogger(__name__)
OLD_MODULE = "stock_picking_batch_custom"
NEW_MODULE = "stock_picking_batch_collect"
# Fields handed over to `website_sale_aplicoop`, which declares the very same
# ones. Dropping just the xmlid is enough and is what we want: Odoo matches the
# existing row again by (model, name) and re-registers it under aplicoop's
# ownership later in the same run (aplicoop depends on us, so it loads after
# us). Both are non-stored, so no column is touched.
HANDED_OVER = (
"field_stock_move_line__home_delivery",
"field_stock_move_line__consumer_group_id",
)
# Artifacts that disappear for good, because aplicoop already stores the same
# value as `stock.picking.consumer_group_id`. Here the record itself has to go
# too, not only the xmlid: deleting an `ir_model_data` row does not cascade, so
# the field would survive as an orphan manual field and the view as a custom
# view still pointing at it. Mapped as table -> xmlids.
DROPPED = {
"ir_model_fields": ("field_stock_picking__batch_consumer_group_id",),
"ir_ui_view": ("view_stock_picking_batch_picking_tree_consumer_group",),
}
def pre_init_hook(env):
"""Adopt the database records of the former ``stock_picking_batch_custom``.
A renamed addon is a brand new module for Odoo, so a versioned
``migrations/`` script would never run. This hook does instead, on install,
after the Python is imported but before ``registry.load()`` and
``init_models()``: remapping ``ir_model_data`` in that window makes Odoo
reuse the existing tables and columns instead of creating new ones, so
``stock_move_line.is_collected``, the ``stock_picking_batch_summary_line``
table and the ``res_company.batch_*`` settings all survive untouched.
A no-op on a database where the old module was never installed.
"""
cr = env.cr
cr.execute("SELECT id FROM ir_module_module WHERE name = %s", (OLD_MODULE,))
old = cr.fetchone()
if not old:
return
old_id = old[0]
cr.execute("SELECT id FROM ir_module_module WHERE name = %s", (NEW_MODULE,))
new = cr.fetchone()
if not new:
# Should not happen: the module list is refreshed before installing.
_logger.warning("%s: no module row to adopt %s into", NEW_MODULE, OLD_MODULE)
return
new_id = new[0]
_logger.info(
"Adopting %s (id=%s) as %s (id=%s)", OLD_MODULE, old_id, NEW_MODULE, new_id
)
# 1. Hand every xmlid over to the new module name.
cr.execute(
"UPDATE ir_model_data SET module = %s WHERE module = %s",
(NEW_MODULE, OLD_MODULE),
)
_logger.info("Moved %s xmlids to %s", cr.rowcount, NEW_MODULE)
# 2. Give back what is no longer ours, so aplicoop can claim it.
cr.execute(
"DELETE FROM ir_model_data WHERE module = %s AND name IN %s",
(NEW_MODULE, HANDED_OVER),
)
# 3. Drop for good what neither module defines any more, record included.
for table, xmlids in DROPPED.items():
cr.execute(
"DELETE FROM %s WHERE id IN ("
" SELECT res_id FROM ir_model_data"
" WHERE module = %%s AND name IN %%s"
")" % table,
(NEW_MODULE, xmlids),
)
if cr.rowcount:
_logger.info("Dropped %s obsolete row(s) from %s", cr.rowcount, table)
cr.execute(
"DELETE FROM ir_model_data WHERE module = %s AND name IN %s",
(NEW_MODULE, tuple(name for names in DROPPED.values() for name in names)),
)
# 4. `ir_model_constraint.module` and `ir_model_relation.module` are integer
# FKs to `ir_module_module` with ON DELETE CASCADE: they must be
# repointed *before* the old row goes away, or the bookkeeping for our
# SQL constraints is silently destroyed with it.
cr.execute(
"UPDATE ir_model_constraint SET module = %s WHERE module = %s",
(new_id, old_id),
)
cr.execute(
"UPDATE ir_model_relation SET module = %s WHERE module = %s",
(new_id, old_id),
)
# 5. Drop the dependency rows, both ours and any pointing at the old name.
cr.execute(
"DELETE FROM ir_module_module_dependency WHERE module_id = %s OR name = %s",
(old_id, OLD_MODULE),
)
# 6. Finally the module row itself. We delete the *old* one and keep the
# new one: the loader already holds the new row's id in memory, so
# renaming the old row in place would leave Odoo writing to a record
# that no longer exists.
cr.execute("DELETE FROM ir_module_module WHERE id = %s", (old_id,))

View file

@ -0,0 +1,235 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * stock_picking_batch_collect
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-21 13:18+0000\n"
"PO-Revision-Date: 2026-05-21 13:18+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_detailed_restriction_scope__all
msgid "All detailed lines"
msgstr "Todas las líneas detalladas"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_summary_restriction_scope__all
msgid "All summary products"
msgstr "Todos los productos del resumen"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_backorder_confirmation
msgid "Backorder Confirmation"
msgstr "Confirmación de entrega parcial"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__batch_id
msgid "Batch"
msgstr "Lote"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking__batch_consumer_group_id
msgid "Batch Consumer Group"
msgstr "Grupo de consumidores del lote"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Detailed Operations Restriction"
msgstr "Restricción de operaciones detalladas del lote"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking_batch_summary_line
msgid "Batch Product Summary Line"
msgstr "Línea de resumen de productos del lote"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Product Summary Restriction"
msgstr "Restricción del resumen de productos del lote"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking_batch
msgid "Batch Transfer"
msgstr "Traslado por lote"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__is_collected
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__is_collected
msgid "Collected"
msgstr "Recogido"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_res_company
msgid "Companies"
msgstr "Compañías"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_res_config_settings
msgid "Config Settings"
msgstr "Ajustes de configuración"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Operaciones Detalladas."
msgstr ""
"Configuración de restricciones para la pestaña Operaciones Detalladas."
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Product Summary."
msgstr "Configuración de restricciones para la pestaña Resumen de productos."
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__consumer_group_id
msgid "Consumer Group"
msgstr "Grupo de consumidores"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__create_uid
msgid "Created by"
msgstr "Creado por"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__create_date
msgid "Created on"
msgstr "Creado el"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_demanded
msgid "Demanded Quantity"
msgstr "Cantidad demandada"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_detailed_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_detailed_restriction_scope
msgid "Detailed Operations Restriction Scope"
msgstr "Ámbito de restricción de operaciones detalladas"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__display_name
msgid "Display Name"
msgstr "Nombre mostrado"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_done
msgid "Done Quantity"
msgstr "Cantidad hecha"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_detailed_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_detailed_restriction_enabled
msgid "Enforce Detailed Operations Restriction"
msgstr "Aplicar restricción de operaciones detalladas"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_summary_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_summary_restriction_enabled
msgid "Enforce Product Summary Restriction"
msgstr "Aplicar restricción del resumen de productos"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__home_delivery
msgid "Home Delivery"
msgstr "Entrega a domicilio"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__id
msgid "ID"
msgstr "ID"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__write_uid
msgid "Last Updated by"
msgstr "Última actualización por"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__write_date
msgid "Last Updated on"
msgstr "Última actualización el"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_detailed_restriction_scope__processed
msgid "Only processed lines"
msgstr "Solo líneas procesadas"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_summary_restriction_scope__processed
msgid "Only processed products"
msgstr "Solo productos procesados"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_pending
msgid "Pending Quantity"
msgstr "Cantidad pendiente"
#. module: stock_picking_batch_collect
#. odoo-python
#: code:addons/stock_picking_batch_collect/models/stock_picking_batch.py:0
msgid "Pending products: %(products)s"
msgstr "Productos pendientes: %(products)s"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_id
msgid "Product"
msgstr "Producto"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_categ_id
msgid "Product Category"
msgstr "Categoría de producto"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__product_categ_id
msgid "Product Category (Batch)"
msgstr "Categoría de producto (lote)"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_move_line
msgid "Product Moves (Stock Move Line)"
msgstr "Movimientos de producto (línea de movimiento de stock)"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch__summary_line_ids
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary
msgid "Product Summary"
msgstr "Resumen de productos"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_summary_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_summary_restriction_scope
msgid "Product Summary Restriction Scope"
msgstr "Ámbito de restricción del resumen de productos"
#. module: stock_picking_batch_collect
#: model:ir.model.constraint,message:stock_picking_batch_collect.constraint_stock_picking_batch_summary_line_product_required
msgid "Product is required for summary lines."
msgstr "El producto es obligatorio en las líneas de resumen."
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking
msgid "Transfer"
msgstr "Traslado"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_uom_id
msgid "Unit of Measure"
msgstr "Unidad de medida"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,help:stock_picking_batch_collect.field_stock_move_line__home_delivery
msgid "Whether this picking includes home delivery (from sale order)"
msgstr "Si este albarán incluye entrega a domicilio (del pedido de venta)"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary
msgid "Basket Assembly"
msgstr "Montaje de Cestas"

View file

@ -0,0 +1,235 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * stock_picking_batch_collect
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-21 13:21+0000\n"
"PO-Revision-Date: 2026-05-21 13:21+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: eu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_detailed_restriction_scope__all
msgid "All detailed lines"
msgstr "Lerro xehatu guztiak"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_summary_restriction_scope__all
msgid "All summary products"
msgstr "Laburpeneko produktu guztiak"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_backorder_confirmation
msgid "Backorder Confirmation"
msgstr "Eskaeraren informazioa"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__batch_id
msgid "Batch"
msgstr "Lotea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking__batch_consumer_group_id
msgid "Batch Consumer Group"
msgstr "Loteko kontsumitzaile taldea"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Detailed Operations Restriction"
msgstr "Loteko eragiketa xehatuen murrizketa"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking_batch_summary_line
msgid "Batch Product Summary Line"
msgstr "Loteko produktuen laburpen-lerroa"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Product Summary Restriction"
msgstr "Loteko produktuen laburpenaren murrizketa"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking_batch
msgid "Batch Transfer"
msgstr "Lote-transferentzia"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__is_collected
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__is_collected
msgid "Collected"
msgstr "Jasota"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_res_company
msgid "Companies"
msgstr "Enpresak"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_res_config_settings
msgid "Config Settings"
msgstr "Konfigurazio ezarpenak"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Operaciones Detalladas."
msgstr "Eragiketa xehatuak fitxarako murrizketen konfigurazioa."
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Product Summary."
msgstr "Produktuen laburpena fitxarako murrizketen konfigurazioa."
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__consumer_group_id
msgid "Consumer Group"
msgstr "Kontsumitzaile taldea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__create_uid
msgid "Created by"
msgstr "Nork sortua"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__create_date
msgid "Created on"
msgstr "Noiz sortua"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_demanded
msgid "Demanded Quantity"
msgstr "Eskatutako kantitatea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_detailed_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_detailed_restriction_scope
msgid "Detailed Operations Restriction Scope"
msgstr "Eragiketa xehatuen murrizketaren irismena"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__display_name
msgid "Display Name"
msgstr "Bistaratzeko izena"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_done
msgid "Done Quantity"
msgstr "Egindako kantitatea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_detailed_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_detailed_restriction_enabled
msgid "Enforce Detailed Operations Restriction"
msgstr "Eragiketa xehatuen murrizketa aplikatu"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_summary_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_summary_restriction_enabled
msgid "Enforce Product Summary Restriction"
msgstr "Produktuen laburpenaren murrizketa aplikatu"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__home_delivery
msgid "Home Delivery"
msgstr "Etxez etxeko entrega"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__id
msgid "ID"
msgstr "ID"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__write_uid
msgid "Last Updated by"
msgstr "Azken eguneratzailea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__write_date
msgid "Last Updated on"
msgstr "Azken eguneratzea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_detailed_restriction_scope__processed
msgid "Only processed lines"
msgstr "Prozesatutako lerroak soilik"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_summary_restriction_scope__processed
msgid "Only processed products"
msgstr "Prozesatutako produktuak soilik"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_pending
msgid "Pending Quantity"
msgstr "Zain dagoen kantitatea"
#. module: stock_picking_batch_collect
#. odoo-python
#: code:addons/stock_picking_batch_collect/models/stock_picking_batch.py:0
msgid "Pending products: %(products)s"
msgstr "Zain dauden produktuak: %(products)s"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_id
msgid "Product"
msgstr "Produktua"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_categ_id
msgid "Product Category"
msgstr "Produktu-kategoria"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__product_categ_id
msgid "Product Category (Batch)"
msgstr "Produktu-kategoria (lotea)"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_move_line
msgid "Product Moves (Stock Move Line)"
msgstr "Produktuen mugimenduak (stock mugimenduaren lerroa)"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch__summary_line_ids
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary
msgid "Product Summary"
msgstr "Produktuen laburpena"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_summary_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_summary_restriction_scope
msgid "Product Summary Restriction Scope"
msgstr "Produktuen laburpenaren murrizketaren irismena"
#. module: stock_picking_batch_collect
#: model:ir.model.constraint,message:stock_picking_batch_collect.constraint_stock_picking_batch_summary_line_product_required
msgid "Product is required for summary lines."
msgstr "Produktua derrigorrezkoa da laburpen-lerroetan."
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking
msgid "Transfer"
msgstr "Transferentzia"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_uom_id
msgid "Unit of Measure"
msgstr "Neurri-unitatea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,help:stock_picking_batch_collect.field_stock_move_line__home_delivery
msgid "Whether this picking includes home delivery (from sale order)"
msgstr ""
"Albaran honek etxez etxeko entrega barne hartzen duen (salmenta-eskaeratik)"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary
msgid "Basket Assembly"
msgstr "Saskien Prestaketa"

View file

@ -1,6 +1,6 @@
from . import res_company # noqa: F401
from . import res_config_settings # noqa: F401
from . import stock_move_line # noqa: F401
from . import stock_backorder_confirmation # noqa: F401
from . import stock_move_line # noqa: F401
from . import stock_picking # noqa: F401
from . import stock_picking_batch # noqa: F401

View file

@ -1,7 +1,6 @@
# 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
@ -36,22 +35,17 @@ class StockMoveLine(models.Model):
copy=False,
)
home_delivery = fields.Boolean(
related="picking_id.home_delivery",
readonly=True,
)
consumer_group_id = fields.Many2one(
comodel_name="res.partner",
compute="_compute_consumer_group_id",
readonly=True,
)
@api.depends("picking_id")
def _compute_consumer_group_id(self):
for line in self:
picking = line.picking_id
if picking:
line.consumer_group_id = picking.batch_consumer_group_id
else:
line.consumer_group_id = False
def write(self, vals):
res = super().write(vals)
if vals.get("is_collected"):
# Collecting a line is the operator saying the goods are in the
# basket: its quantity must survive the reservation engine even if
# it was never retyped. The demand is left alone, only a quantity
# typed by hand adjusts it.
lines = self.filtered(
lambda line: not line.picked and line.state not in ("done", "cancel")
)
if lines:
lines.picked = True
lines._freeze_manual_quantity(align_demand=False)
return res

View file

@ -1,28 +1,12 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields
from odoo import models
class StockPicking(models.Model):
_inherit = "stock.picking"
batch_consumer_group_id = fields.Many2one(
comodel_name="res.partner",
compute="_compute_batch_consumer_group_id",
readonly=True,
string="Batch Consumer Group",
)
def _compute_batch_consumer_group_id(self):
for picking in self:
sale = picking.sale_id if "sale_id" in picking._fields else False
if sale and "consumer_group_id" in sale._fields:
picking.batch_consumer_group_id = sale.consumer_group_id
else:
picking.batch_consumer_group_id = False
def _pre_action_done_hook(self):
"""Run collected checks only after Odoo resolves backorders.

View file

@ -231,7 +231,7 @@ class StockPickingBatch(models.Model):
"views": [
(
self.env.ref(
"stock_picking_batch_custom.view_move_line_batch_operator"
"stock_picking_batch_collect.view_move_line_batch_operator"
).id,
"list",
)

View file

@ -0,0 +1,32 @@
Configuración
=============
Restricciones de validación
---------------------------
En **Inventory > Configuration > Settings**, bloque *Operations*, hay dos
ajustes por compañía:
- **Restricciones del resumen de productos**: exige que las líneas de la
pestaña *Product Summary* estén marcadas como recogidas para validar el lote.
Desactivado por defecto (el check es sólo informativo).
- **Restricciones de operaciones detalladas**: exige que las líneas de la
pestaña *Detailed Operations* estén marcadas como recogidas.
**Activado por defecto**: recién instalado el módulo, un lote no se puede
validar hasta marcar las líneas.
Cada restricción tiene un alcance:
- *Sólo procesadas*: se comprueban únicamente las líneas con cantidad hecha
mayor que cero (por defecto).
- *Todas*: se comprueban todas las líneas del lote, procesadas o no.
Columnas
--------
Las columnas están visibles por defecto. Para ajustarlas:
- Abrir un **Lote de picking**.
- Ir a la pestaña **Detailed Operations**.
- Abrir el **selector de columnas** y activar o desactivar *Partner*,
*Product Category*, *Product Code* o *Collected* según necesidad.

View file

@ -0,0 +1,26 @@
Este módulo convierte un lote de picking en una hoja de trabajo para el operario
que monta las cestas: amplía las operaciones detalladas, añade un resumen por
producto y una vista a pantalla completa para el almacén.
- ``picking_partner_id`` (Partner del albarán) para que el personal de almacén
identifique rápido el cliente/proveedor.
- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar. Las
líneas del lote se ordenan por categoría, producto y partner.
- ``product_default_code`` (Referencia interna) como columna opcional.
- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se
ha recolectado.
- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho,
pendiente), categoría y el check de recogido consolidado.
- Botón **Basket Assembly**: una vista de lista a pantalla completa, con
cantidades grandes y controles táctiles, pensada para tablet en el almacén.
- Restricciones de validación configurables por compañía: el lote no se puede
validar hasta que las líneas estén marcadas como recogidas.
Las columnas se añaden con ``optional="show"`` en la vista de líneas del lote
(salvo ``product_default_code``, oculta por defecto), de modo que el usuario
puede desactivarlas desde el selector de columnas sin recargar la vista.
Si además está instalado ``website_sale_aplicoop``, ese módulo añade a estas
mismas vistas las columnas de grupo de consumo (**Consumer Group** y **Home
Delivery**). Este módulo no depende de él: funciona igual sin la parte de
eCommerce.

View file

@ -5,4 +5,4 @@ Actualizar o instalar el módulo:
::
docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_custom
docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_collect

View file

@ -0,0 +1,29 @@
Uso
===
1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote.
2. Pestaña **Detailed Operations**: usa el selector de columnas para ajustar:
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
- **Collected** (``is_collected``) para marcar manualmente líneas recolectadas.
3. Pestaña **Product Summary**: consulta los totales por producto (demandado,
hecho y pendiente) y marca el check de recogido consolidado si corresponde.
4. Con el lote **en progreso**, el botón **Basket Assembly** abre la vista de
operario: la misma lista de líneas a pantalla completa, con la cantidad en
grande y el check *Collected* como interruptor táctil. Está pensada para
trabajar desde una tablet mientras se montan las cestas.
5. Ordena o agrupa por categoría en cualquiera de las vistas según convenga.
6. La cantidad que teclea el operario (el peso real de la balanza) queda fijada:
se marca como *Picked* y ni el planificador ni la validación de otros
albaranes vuelven a modificarla. Marcar **Collected** protege igualmente la
cantidad de la línea. Ver ``stock_move_manual_quantity``.
7. Al validar el lote, según la configuración de la compañía, se comprueba que
las líneas estén recogidas y se avisa con la lista de productos pendientes.
La comprobación se ejecuta después de resolver el backorder, de modo que
sólo bloquea sobre las líneas que realmente se van a procesar.

View file

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Before After
Before After

View file

@ -1,13 +1,3 @@
/* zebra striping for list views in this module (Odoo 18 selectors) */
table.o_list_table tbody tr.o_data_row:nth-child(even) td {
background-color: rgba(0, 0, 0, 0.03);
}
table.o_list_table tbody tr.o_data_row:hover td {
background-color: rgba(0, 0, 0, 0.045);
}
/* Widen the quantity column in detailed operations so it is easier to tap */
/* Note: arch class lands on the root <div> of the list controller, not <table> */
.o_batch_move_line_list th[data-name="quantity"],

View file

@ -0,0 +1,2 @@
from . import test_batch_summary # noqa: F401
from . import test_collected_picked # noqa: F401

View file

@ -0,0 +1,73 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import Command
from odoo.tests import TransactionCase
from odoo.tests import tagged
@tagged("-at_install", "post_install")
class TestCollectedPicked(TransactionCase):
"""Collecting a line protects its quantity from the reservation engine."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.stock_location = cls.env.ref("stock.stock_location_stock")
cls.customer_location = cls.env.ref("stock.stock_location_customers")
cls.picking_type = cls.env.ref("stock.picking_type_out")
cls.product = cls.env["product.product"].create(
{
"name": "Collected Apples",
"is_storable": True,
"uom_id": cls.env.ref("uom.product_uom_kgm").id,
"uom_po_id": cls.env.ref("uom.product_uom_kgm").id,
}
)
cls.env["stock.quant"]._update_available_quantity(
cls.product, cls.stock_location, 50.0
)
def _create_picking(self, demand):
picking = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
"move_ids": [
Command.create(
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"product_uom_qty": demand,
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
}
)
],
}
)
picking.action_confirm()
picking.action_assign()
return picking
def test_collecting_a_line_marks_it_picked(self):
picking = self._create_picking(2.0)
line = picking.move_ids.move_line_ids
line.is_collected = True
self.assertTrue(line.picked)
self.assertTrue(picking.move_ids.picked)
def test_collecting_a_line_keeps_its_demand(self):
"""Only a hand-typed quantity lowers the demand, collecting does not."""
picking = self._create_picking(2.0)
move = picking.move_ids
move.move_line_ids.write({"quantity": 1.5, "picked": True})
move.move_line_ids.is_collected = True
self.assertEqual(move.product_uom_qty, 1.5)
self.assertTrue(move.picked)

View file

@ -30,9 +30,9 @@
context="{'display_default_code': False}"/>
<field name="reference" readonly="1" optional="hide"/>
<field name="picking_partner_id" readonly="1" optional="show"/>
<field name="home_delivery" readonly="1" widget="boolean_toggle" optional="show"/>
<field name="consumer_group_id" readonly="1" optional="show"/>
<field name="quantity" string="Cant."/>
<!-- Loaded so `_onchange_quantity_manual` reaches the server. -->
<field name="picked" column_invisible="True"/>
<field name="is_collected" widget="boolean_toggle"/>
</list>
</field>
@ -52,23 +52,12 @@
</xpath>
<xpath expr="//notebook/page[@name='page_detailed_operations']" position="after">
<page string="Product Summary" name="page_product_summary">
<field name="summary_line_ids" context="{'tree_view_ref': 'stock_picking_batch_custom.view_stock_picking_batch_summary_line_tree'}"/>
<field name="summary_line_ids" context="{'tree_view_ref': 'stock_picking_batch_collect.view_stock_picking_batch_summary_line_tree'}"/>
</page>
</xpath>
</field>
</record>
<record id="view_stock_picking_batch_picking_tree_consumer_group" model="ir.ui.view">
<field name="name">stock.picking.batch.picking.tree.consumer.group</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock_picking_batch.stock_picking_view_batch_tree_ref"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="batch_consumer_group_id" optional="show"/>
</xpath>
</field>
</record>
<record id="view_move_line_tree_inherit_batch_custom" model="ir.ui.view">
<field name="name">stock.move.line.list.batch.custom</field>
<field name="model">stock.move.line</field>
@ -87,10 +76,12 @@
<xpath expr="//field[@name='product_id']" position="after">
<field name="picking_partner_id" readonly="1" optional="show"/>
<field name="product_default_code" readonly="1" optional="hide"/>
<field name="home_delivery" readonly="1" widget="boolean_toggle" optional="show"/>
<field name="consumer_group_id" readonly="1" optional="show"/>
<field name="is_collected" widget="boolean_toggle" optional="show"/>
</xpath>
<xpath expr="//field[@name='quantity']" position="after">
<!-- Loaded so `_onchange_quantity_manual` reaches the server. -->
<field name="picked" column_invisible="True"/>
</xpath>
<xpath expr="//field[@name='product_uom_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
@ -109,5 +100,4 @@
</field>
</record>
<!-- assets: moved to manifest 'assets' declaration -->
</odoo>

View file

@ -1,66 +0,0 @@
============================
Stock Picking Batch Custom
============================
Visión general
==============
Este módulo amplía las operaciones detalladas y añade un resumen por producto
en los lotes de picking:
- ``picking_partner_id`` (Partner del albarán) para identificar cliente/proveedor.
- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar.
- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se ha
recolectado.
- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho,
pendiente), categoría y el check de recogido consolidado.
Instalación
===========
Actualizar o instalar el módulo:
::
docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_custom
Configuración
=============
No requiere configuración adicional. Para usar las columnas:
- Abrir un **Lote de picking**.
- Ir a la pestaña **Detailed Operations**.
- Abrir el **selector de columnas** y activar *Partner*, *Product Category* y *Collected* según necesidad.
Uso
===
1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote.
2. Pestaña **Detailed Operations**: usa el selector de columnas para activar:
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
- **Collected** (``is_collected``) para marcar manualmente líneas recolectadas.
3. Pestaña **Product Summary**: consulta los totales por producto (demandado,
hecho y pendiente) y marca el check de recogido consolidado si corresponde.
4. Ordena o agrupa por categoría en cualquiera de las vistas según convenga.
Contribuidores
==============
* Criptomart
Créditos
========
Autor
-----
* Criptomart
Financiador
-----------
* Elika Bilbo

View file

@ -1 +0,0 @@
from . import models # noqa: F401

View file

@ -1,29 +0,0 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018
"name": "Stock Picking Batch Custom",
"version": "18.0.1.0.0",
"category": "Warehouse",
"summary": "Extra columns for batch detailed operations",
"author": "Odoo Community Association (OCA), Criptomart",
"maintainers": ["Criptomart"],
"website": "https://github.com/Criptomart",
"license": "AGPL-3",
"depends": [
"stock_picking_batch",
# Ensure our related fields to sale/picking (home_delivery, pickup_slot_label)
# are available by depending on the Aplicoop website_sale extension.
"website_sale_aplicoop",
],
"data": [
"security/ir.model.access.csv",
"views/res_config_settings_views.xml",
"views/stock_move_line_views.xml",
"views/stock_picking_batch_views.xml",
],
"assets": {
"web.assets_backend": [
"stock_picking_batch_custom/static/src/css/stock_picking_batch.css",
],
},
}

View file

@ -1,235 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * stock_picking_batch_custom
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-21 13:18+0000\n"
"PO-Revision-Date: 2026-05-21 13:18+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: stock_picking_batch_custom
#: model:ir.model.fields.selection,name:stock_picking_batch_custom.selection__res_company__batch_detailed_restriction_scope__all
msgid "All detailed lines"
msgstr "Todas las líneas detalladas"
#. module: stock_picking_batch_custom
#: model:ir.model.fields.selection,name:stock_picking_batch_custom.selection__res_company__batch_summary_restriction_scope__all
msgid "All summary products"
msgstr "Todos los productos del resumen"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_stock_backorder_confirmation
msgid "Backorder Confirmation"
msgstr "Confirmación de entrega parcial"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__batch_id
msgid "Batch"
msgstr "Lote"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking__batch_consumer_group_id
msgid "Batch Consumer Group"
msgstr "Grupo de consumidores del lote"
#. module: stock_picking_batch_custom
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Detailed Operations Restriction"
msgstr "Restricción de operaciones detalladas del lote"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_stock_picking_batch_summary_line
msgid "Batch Product Summary Line"
msgstr "Línea de resumen de productos del lote"
#. module: stock_picking_batch_custom
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Product Summary Restriction"
msgstr "Restricción del resumen de productos del lote"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_stock_picking_batch
msgid "Batch Transfer"
msgstr "Traslado por lote"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_move_line__is_collected
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__is_collected
msgid "Collected"
msgstr "Recogido"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_res_company
msgid "Companies"
msgstr "Compañías"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_res_config_settings
msgid "Config Settings"
msgstr "Ajustes de configuración"
#. module: stock_picking_batch_custom
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Operaciones Detalladas."
msgstr ""
"Configuración de restricciones para la pestaña Operaciones Detalladas."
#. module: stock_picking_batch_custom
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Product Summary."
msgstr "Configuración de restricciones para la pestaña Resumen de productos."
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_move_line__consumer_group_id
msgid "Consumer Group"
msgstr "Grupo de consumidores"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__create_uid
msgid "Created by"
msgstr "Creado por"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__create_date
msgid "Created on"
msgstr "Creado el"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__qty_demanded
msgid "Demanded Quantity"
msgstr "Cantidad demandada"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_company__batch_detailed_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_config_settings__batch_detailed_restriction_scope
msgid "Detailed Operations Restriction Scope"
msgstr "Ámbito de restricción de operaciones detalladas"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__display_name
msgid "Display Name"
msgstr "Nombre mostrado"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__qty_done
msgid "Done Quantity"
msgstr "Cantidad hecha"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_company__batch_detailed_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_config_settings__batch_detailed_restriction_enabled
msgid "Enforce Detailed Operations Restriction"
msgstr "Aplicar restricción de operaciones detalladas"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_company__batch_summary_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_config_settings__batch_summary_restriction_enabled
msgid "Enforce Product Summary Restriction"
msgstr "Aplicar restricción del resumen de productos"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_move_line__home_delivery
msgid "Home Delivery"
msgstr "Entrega a domicilio"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__id
msgid "ID"
msgstr "ID"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__write_uid
msgid "Last Updated by"
msgstr "Última actualización por"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__write_date
msgid "Last Updated on"
msgstr "Última actualización el"
#. module: stock_picking_batch_custom
#: model:ir.model.fields.selection,name:stock_picking_batch_custom.selection__res_company__batch_detailed_restriction_scope__processed
msgid "Only processed lines"
msgstr "Solo líneas procesadas"
#. module: stock_picking_batch_custom
#: model:ir.model.fields.selection,name:stock_picking_batch_custom.selection__res_company__batch_summary_restriction_scope__processed
msgid "Only processed products"
msgstr "Solo productos procesados"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__qty_pending
msgid "Pending Quantity"
msgstr "Cantidad pendiente"
#. module: stock_picking_batch_custom
#. odoo-python
#: code:addons/stock_picking_batch_custom/models/stock_picking_batch.py:0
msgid "Pending products: %(products)s"
msgstr "Productos pendientes: %(products)s"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__product_id
msgid "Product"
msgstr "Producto"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__product_categ_id
msgid "Product Category"
msgstr "Categoría de producto"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_move_line__product_categ_id
msgid "Product Category (Batch)"
msgstr "Categoría de producto (lote)"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_stock_move_line
msgid "Product Moves (Stock Move Line)"
msgstr "Movimientos de producto (línea de movimiento de stock)"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch__summary_line_ids
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.view_stock_picking_batch_form_inherit_summary
msgid "Product Summary"
msgstr "Resumen de productos"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_company__batch_summary_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_config_settings__batch_summary_restriction_scope
msgid "Product Summary Restriction Scope"
msgstr "Ámbito de restricción del resumen de productos"
#. module: stock_picking_batch_custom
#: model:ir.model.constraint,message:stock_picking_batch_custom.constraint_stock_picking_batch_summary_line_product_required
msgid "Product is required for summary lines."
msgstr "El producto es obligatorio en las líneas de resumen."
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_stock_picking
msgid "Transfer"
msgstr "Traslado"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__product_uom_id
msgid "Unit of Measure"
msgstr "Unidad de medida"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,help:stock_picking_batch_custom.field_stock_move_line__home_delivery
msgid "Whether this picking includes home delivery (from sale order)"
msgstr "Si este albarán incluye entrega a domicilio (del pedido de venta)"
#. module: stock_picking_batch_custom
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.view_stock_picking_batch_form_inherit_summary
msgid "Basket Assembly"
msgstr "Montaje de Cestas"

View file

@ -1,235 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * stock_picking_batch_custom
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-21 13:21+0000\n"
"PO-Revision-Date: 2026-05-21 13:21+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: eu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: stock_picking_batch_custom
#: model:ir.model.fields.selection,name:stock_picking_batch_custom.selection__res_company__batch_detailed_restriction_scope__all
msgid "All detailed lines"
msgstr "Lerro xehatu guztiak"
#. module: stock_picking_batch_custom
#: model:ir.model.fields.selection,name:stock_picking_batch_custom.selection__res_company__batch_summary_restriction_scope__all
msgid "All summary products"
msgstr "Laburpeneko produktu guztiak"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_stock_backorder_confirmation
msgid "Backorder Confirmation"
msgstr "Eskaeraren informazioa"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__batch_id
msgid "Batch"
msgstr "Lotea"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking__batch_consumer_group_id
msgid "Batch Consumer Group"
msgstr "Loteko kontsumitzaile taldea"
#. module: stock_picking_batch_custom
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Detailed Operations Restriction"
msgstr "Loteko eragiketa xehatuen murrizketa"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_stock_picking_batch_summary_line
msgid "Batch Product Summary Line"
msgstr "Loteko produktuen laburpen-lerroa"
#. module: stock_picking_batch_custom
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Product Summary Restriction"
msgstr "Loteko produktuen laburpenaren murrizketa"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_stock_picking_batch
msgid "Batch Transfer"
msgstr "Lote-transferentzia"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_move_line__is_collected
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__is_collected
msgid "Collected"
msgstr "Jasota"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_res_company
msgid "Companies"
msgstr "Enpresak"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_res_config_settings
msgid "Config Settings"
msgstr "Konfigurazio ezarpenak"
#. module: stock_picking_batch_custom
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Operaciones Detalladas."
msgstr "Eragiketa xehatuak fitxarako murrizketen konfigurazioa."
#. module: stock_picking_batch_custom
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Product Summary."
msgstr "Produktuen laburpena fitxarako murrizketen konfigurazioa."
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_move_line__consumer_group_id
msgid "Consumer Group"
msgstr "Kontsumitzaile taldea"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__create_uid
msgid "Created by"
msgstr "Nork sortua"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__create_date
msgid "Created on"
msgstr "Noiz sortua"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__qty_demanded
msgid "Demanded Quantity"
msgstr "Eskatutako kantitatea"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_company__batch_detailed_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_config_settings__batch_detailed_restriction_scope
msgid "Detailed Operations Restriction Scope"
msgstr "Eragiketa xehatuen murrizketaren irismena"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__display_name
msgid "Display Name"
msgstr "Bistaratzeko izena"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__qty_done
msgid "Done Quantity"
msgstr "Egindako kantitatea"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_company__batch_detailed_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_config_settings__batch_detailed_restriction_enabled
msgid "Enforce Detailed Operations Restriction"
msgstr "Eragiketa xehatuen murrizketa aplikatu"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_company__batch_summary_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_config_settings__batch_summary_restriction_enabled
msgid "Enforce Product Summary Restriction"
msgstr "Produktuen laburpenaren murrizketa aplikatu"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_move_line__home_delivery
msgid "Home Delivery"
msgstr "Etxez etxeko entrega"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__id
msgid "ID"
msgstr "ID"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__write_uid
msgid "Last Updated by"
msgstr "Azken eguneratzailea"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__write_date
msgid "Last Updated on"
msgstr "Azken eguneratzea"
#. module: stock_picking_batch_custom
#: model:ir.model.fields.selection,name:stock_picking_batch_custom.selection__res_company__batch_detailed_restriction_scope__processed
msgid "Only processed lines"
msgstr "Prozesatutako lerroak soilik"
#. module: stock_picking_batch_custom
#: model:ir.model.fields.selection,name:stock_picking_batch_custom.selection__res_company__batch_summary_restriction_scope__processed
msgid "Only processed products"
msgstr "Prozesatutako produktuak soilik"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__qty_pending
msgid "Pending Quantity"
msgstr "Zain dagoen kantitatea"
#. module: stock_picking_batch_custom
#. odoo-python
#: code:addons/stock_picking_batch_custom/models/stock_picking_batch.py:0
msgid "Pending products: %(products)s"
msgstr "Zain dauden produktuak: %(products)s"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__product_id
msgid "Product"
msgstr "Produktua"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__product_categ_id
msgid "Product Category"
msgstr "Produktu-kategoria"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_move_line__product_categ_id
msgid "Product Category (Batch)"
msgstr "Produktu-kategoria (lotea)"
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_stock_move_line
msgid "Product Moves (Stock Move Line)"
msgstr "Produktuen mugimenduak (stock mugimenduaren lerroa)"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch__summary_line_ids
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.view_stock_picking_batch_form_inherit_summary
msgid "Product Summary"
msgstr "Produktuen laburpena"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_company__batch_summary_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_res_config_settings__batch_summary_restriction_scope
msgid "Product Summary Restriction Scope"
msgstr "Produktuen laburpenaren murrizketaren irismena"
#. module: stock_picking_batch_custom
#: model:ir.model.constraint,message:stock_picking_batch_custom.constraint_stock_picking_batch_summary_line_product_required
msgid "Product is required for summary lines."
msgstr "Produktua derrigorrezkoa da laburpen-lerroetan."
#. module: stock_picking_batch_custom
#: model:ir.model,name:stock_picking_batch_custom.model_stock_picking
msgid "Transfer"
msgstr "Transferentzia"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,field_description:stock_picking_batch_custom.field_stock_picking_batch_summary_line__product_uom_id
msgid "Unit of Measure"
msgstr "Neurri-unitatea"
#. module: stock_picking_batch_custom
#: model:ir.model.fields,help:stock_picking_batch_custom.field_stock_move_line__home_delivery
msgid "Whether this picking includes home delivery (from sale order)"
msgstr ""
"Albaran honek etxez etxeko entrega barne hartzen duen (salmenta-eskaeratik)"
#. module: stock_picking_batch_custom
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_custom.view_stock_picking_batch_form_inherit_summary
msgid "Basket Assembly"
msgstr "Saskien Prestaketa"

View file

@ -1,8 +0,0 @@
Configuración
=============
No requiere configuración adicional. Para usar las columnas:
- Abrir un **Lote de picking**.
- Ir a la pestaña **Detailed Operations**.
- Abrir el **selector de columnas** y activar *Partner* y *Product Category* según necesidad.

View file

@ -1,14 +0,0 @@
Este módulo amplía las operaciones detalladas y añade un resumen por producto
en los lotes de picking:
- ``picking_partner_id`` (Partner del albarán) para que el personal de almacén
identifique rápido el cliente/proveedor.
- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar.
- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se
ha recolectado.
- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho,
pendiente), categoría y el check de recogido consolidado.
Las columnas se añaden como ``optional="hide"`` en la vista de líneas del lote,
de modo que el usuario puede activarlas desde el selector de columnas sin
recargar la vista por defecto.

View file

@ -1,14 +0,0 @@
Uso
===
1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote.
2. Pestaña **Detailed Operations**: usa el selector de columnas para activar:
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
- **Collected** (``is_collected``) para marcar manualmente líneas recolectadas.
3. Pestaña **Product Summary**: consulta los totales por producto (demandado,
hecho y pendiente) y marca el check de recogido consolidado si corresponde.
4. Ordena o agrupa por categoría en cualquiera de las vistas según convenga.

View file

@ -1 +0,0 @@
from . import test_batch_summary # noqa: F401

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_move_line_tree_inherit_batch_custom" model="ir.ui.view">
<field name="name">stock.move.line.list.batch.custom</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock_picking_batch.view_move_line_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='picking_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='lot_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='lot_name']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='location_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='location_dest_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='product_id']" position="after">
<field name="product_categ_id" optional="hide"/>
</xpath>
<xpath expr="//field[@name='picking_id']" position="after">
<field name="picking_partner_id" optional="hide"/>
<field name="consumer_group_id" optional="show"/>
<field name="home_delivery" optional="show" widget="boolean_toggle"/>
</xpath>
<xpath expr="//field[@name='quantity']" position="after">
<field name="is_collected" optional="show" widget="boolean_toggle"/>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,63 @@
================
Web List Striped
================
Visión general
==============
Aplica un efecto cebra (filas alternas sombreadas) y un resaltado de hover más
suave a **todas** las vistas de lista del backend de Odoo.
El alcance global es intencionado: es justamente lo que hace este módulo. Sirve
para listas largas de almacén, contabilidad o inventario, donde seguir una fila
con la vista es más fácil si las filas alternan de color.
Es un módulo de sólo CSS: no define modelos, campos ni vistas. Para volver al
aspecto estándar de Odoo basta con desinstalarlo.
Nota técnica: el selector usa ``:nth-child(even)``, que cuenta todas las ``tr``
del ``tbody`` y no sólo las filas de datos. En listas agrupadas o con fila de
*"Añadir línea"* la paridad de las bandas puede desajustarse.
``:nth-child(even of .o_data_row)`` lo resolvería, pero exige Chrome 111+ o
Firefox 113+, lo que no es una suposición segura en tablets de almacén.
Instalación
===========
Instalar el módulo:
::
docker-compose run --rm odoo odoo -d odoo --stop-after-init -i web_list_striped
No tiene más dependencias que ``web``, así que se puede instalar y desinstalar
en cualquier momento sin afectar a datos.
Configuración
=============
No requiere configuración. El efecto se aplica al instalar el módulo y
desaparece al desinstalarlo.
Para cambiar la intensidad de las bandas, edita los valores ``rgba()`` de
``static/src/css/web_list_striped.css``.
Uso
===
No hay nada que usar: una vez instalado, cualquier vista de lista del backend
(Contactos, Facturas, Albaranes, Operaciones detalladas de un lote...) muestra
las filas alternas sombreadas y un hover más suave.
Contribuidores
==============
* Criptomart
Créditos
========
Autor
-----
* Criptomart

View file

View file

@ -0,0 +1,18 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018
"name": "Web List Striped",
"version": "18.0.1.0.0",
"category": "Technical",
"summary": "Zebra striping and softer hover for all backend list views",
"author": "Odoo Community Association (OCA), Criptomart",
"maintainers": ["Criptomart"],
"website": "https://git.criptomart.net/criptomart/addons-cm",
"license": "AGPL-3",
"depends": ["web"],
"assets": {
"web.assets_backend": [
"web_list_striped/static/src/css/web_list_striped.css",
],
},
}

View file

@ -0,0 +1,8 @@
Configuración
=============
No requiere configuración. El efecto se aplica al instalar el módulo y
desaparece al desinstalarlo.
Para cambiar la intensidad de las bandas, edita los valores ``rgba()`` de
``static/src/css/web_list_striped.css``.

View file

@ -0,0 +1,4 @@
Contribuidores
==============
* Criptomart

View file

@ -0,0 +1,7 @@
Créditos
========
Autor
-----
* Criptomart

View file

@ -0,0 +1,15 @@
Aplica un efecto cebra (filas alternas sombreadas) y un resaltado de hover más
suave a **todas** las vistas de lista del backend de Odoo.
El alcance global es intencionado: es justamente lo que hace este módulo. Sirve
para listas largas de almacén, contabilidad o inventario, donde seguir una fila
con la vista es más fácil si las filas alternan de color.
Es un módulo de sólo CSS: no define modelos, campos ni vistas. Para volver al
aspecto estándar de Odoo basta con desinstalarlo.
Nota técnica: el selector usa ``:nth-child(even)``, que cuenta todas las ``tr``
del ``tbody`` y no sólo las filas de datos. En listas agrupadas o con fila de
*"Añadir línea"* la paridad de las bandas puede desajustarse.
``:nth-child(even of .o_data_row)`` lo resolvería, pero exige Chrome 111+ o
Firefox 113+, lo que no es una suposición segura en tablets de almacén.

View file

@ -0,0 +1,11 @@
Instalación
===========
Instalar el módulo:
::
docker-compose run --rm odoo odoo -d odoo --stop-after-init -i web_list_striped
No tiene más dependencias que ``web``, así que se puede instalar y desinstalar
en cualquier momento sin afectar a datos.

View file

@ -0,0 +1,6 @@
Uso
===
No hay nada que usar: una vez instalado, cualquier vista de lista del backend
(Contactos, Facturas, Albaranes, Operaciones detalladas de un lote...) muestra
las filas alternas sombreadas y un hover más suave.

View file

@ -0,0 +1,18 @@
/* Zebra striping for every backend list view.
*
* The reach is global on purpose: that is what this addon is for. Uninstall it
* to get the stock Odoo look back.
*
* Note: `:nth-child(even)` counts every `tr` in the `tbody`, not only the data
* rows, so parity can desync in grouped lists or lists carrying an "Add a line"
* row. `:nth-child(even of .o_data_row)` fixes that but needs Chrome 111+ /
* Firefox 113+, which is not a safe assumption for the warehouse tablets.
*/
table.o_list_table tbody tr.o_data_row:nth-child(even) td {
background-color: rgba(0, 0, 0, 0.03);
}
table.o_list_table tbody tr.o_data_row:hover td {
background-color: rgba(0, 0, 0, 0.045);
}

View file

@ -135,22 +135,36 @@ La página inicial renderiza la primera página server-side. El botón "Cargar m
| Cron | Frecuencia | Acción |
| ---- | ---------- | ------ |
| `_cron_confirm_group_orders` | Diario (medianoche) | Confirma sale.orders pasado el cutoff; crea lotes de picking agrupados por consumer_group + pickup_date |
| `_cron_confirm_group_orders` | Diario (medianoche) | Confirma sale.orders pasado el cutoff; crea lotes de picking por pedido de grupo, uno por tipo de operación (`_create_picking_batches`) |
| `_cron_update_dates` | Diario | Recalcula `delivery_date` y `pickup_date` en órdenes activas |
## Dependencias
Según `__manifest__.py`:
```text
website_sale_aplicoop
├── website_sale (Odoo core)
├── website_sale_stock (Odoo core)
├── payment (Odoo core)
├── product (Odoo core)
├── sale (Odoo core)
├── product_main_seller (OCA)
├── product_sale_price_from_pricelist (custom)
│ └── product_pricelist_total_margin (custom)
│ └── product_price_category (OCA)
└── stock_picking_batch_custom (custom)
├── stock (Odoo core)
├── account (Odoo core)
├── stock_picking_batch (Odoo core)
├── stock_picking_batch_collect (custom)
│ ├── stock_move_manual_quantity (custom)
│ └── stock_picking_batch (Odoo core)
├── product_get_price_helper (OCA)
└── l10n_es_partner (OCA)
```
La dependencia sobre `stock_picking_batch_collect` es deliberada y va en este
sentido: ese addon es genérico de almacén y no sabe nada de grupos de consumo.
Es `website_sale_aplicoop` quien inyecta en sus vistas las columnas
**Consumer Group** y **Home Delivery** (ver `views/stock_picking_batch_views.xml`
y `models/stock_move_line_extension.py`).
## Instalación y actualización
```bash

View file

@ -3,7 +3,7 @@
{ # noqa: B018
"name": "Website Sale - Aplicoop",
"version": "18.0.1.16.0",
"version": "18.0.1.17.0",
"category": "Website/Sale",
"summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders",
"author": "Odoo Community Association (OCA), Criptomart",
@ -18,6 +18,9 @@
"sale",
"stock",
"stock_picking_batch",
# Owns the batch collect views into which we inject the consumer group
# columns (home_delivery, consumer_group_id).
"stock_picking_batch_collect",
"account",
"product_get_price_helper",
"l10n_es_partner",
@ -43,6 +46,7 @@
"views/product_template_views.xml",
"views/sale_order_views.xml",
"views/stock_picking_views.xml",
"views/stock_picking_batch_views.xml",
"views/portal_templates.xml",
"views/load_from_history_templates.xml",
],

View file

@ -890,13 +890,6 @@ class AplicoopWebsiteSale(WebsiteSale):
product_display_info,
filtered_products_dict,
) = self._prepare_products_maps(products, pricelist)
# Whether the tags row needs to be reserved on every card: if nothing in
# this batch has a published tag, the row is skipped entirely instead of
# leaving an empty aria-hidden gap on every product (see product-card.css).
any_product_has_tags = any(
v["published_tags"] for v in filtered_products_dict.values()
)
# Inject draft sale demand context so ribbons/is_out_of_stock use forecasted net qty
products, product_max_qty = self._prepare_draft_stock_data(products)
@ -941,7 +934,6 @@ class AplicoopWebsiteSale(WebsiteSale):
"placed_order_url": placed_order_url,
"products": products,
"filtered_product_tags": filtered_products_dict,
"any_product_has_tags": any_product_has_tags,
"cart": cart,
"available_categories": available_categories,
"category_hierarchy": category_hierarchy,
@ -1053,11 +1045,6 @@ class AplicoopWebsiteSale(WebsiteSale):
"product": product,
"published_tags": published_tags,
}
# See eskaera_shop for why this decides whether to reserve the tags row.
any_product_has_tags = any(
v["published_tags"] for v in filtered_products_dict.values()
)
product_display_info = {}
for product in products_page:
product_display_info[product.id] = self._prepare_product_display_info(
@ -1075,7 +1062,6 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order": group_order,
"products": products_page,
"filtered_product_tags": filtered_products_dict,
"any_product_has_tags": any_product_has_tags,
"product_supplier_info": product_supplier_info,
"product_price_info": product_price_info,
"product_display_info": product_display_info,
@ -1185,11 +1171,6 @@ class AplicoopWebsiteSale(WebsiteSale):
}
for product in products_page
}
# See eskaera_shop for why this decides whether to reserve the tags row.
any_product_has_tags = any(
v["published_tags"] for v in filtered_products_dict.values()
)
# Inject draft demand context for ribbons/stock flags
products_page, product_max_qty = self._prepare_draft_stock_data(products_page)
@ -1203,7 +1184,6 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order": group_order,
"products": products_page,
"filtered_product_tags": filtered_products_dict,
"any_product_has_tags": any_product_has_tags,
"product_supplier_info": product_supplier_info,
"product_price_info": product_price_info,
"product_display_info": product_display_info,

View file

@ -1,11 +1,12 @@
from . import group_order # noqa: F401
from . import group_order_slot # noqa: F401
from . import js_translations # noqa: F401
from . import payment_transaction # noqa: F401
from . import product_category_extension # noqa: F401
from . import product_extension # noqa: F401
from . import res_config_settings # noqa: F401
from . import res_partner_extension # noqa: F401
from . import sale_order_extension # noqa: F401
from . import stock_move_line_extension # noqa: F401
from . import stock_picking_extension # noqa: F401
from . import website # noqa: F401
from . import js_translations # noqa: F401

View file

@ -0,0 +1,28 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import fields
from odoo import models
class StockMoveLine(models.Model):
"""Surface the consumer group data on the batch operation lines.
The picking already carries these values as stored related fields, so the
move line only has to hop one step further. Chaining off ``picking_id``
instead of re-deriving from ``sale_id`` keeps a single source of truth.
"""
_inherit = "stock.move.line"
home_delivery = fields.Boolean(
related="picking_id.home_delivery",
string="Home Delivery",
readonly=True,
)
consumer_group_id = fields.Many2one(
related="picking_id.consumer_group_id",
string="Consumer Group",
readonly=True,
)

View file

@ -152,7 +152,6 @@
/* ========== PRODUCT CARD MEDIA ========== */
--ac-card-media-ratio: 4 / 3;
--ac-card-media-height: clamp(5rem, 4rem + 6vw, 8.5rem);
/* ========== Z-INDEX ========== */
--ac-z-dropdown: 1000;

View file

@ -6,13 +6,23 @@
* Markup (eskaera_shop_products):
* .product-card-wrapper.product-card
* .card
* img.product-img-cover | .product-img-placeholder
* .card-body (title, tags, supplier, origin, price)
* .product-media
* img.product-img-cover | .product-img-placeholder
* button.product-info-toggle (only when the product has a
* tag, a supplier or an origin to show)
* .card-body (title, .product-info-panel, price)
* form.add-to-cart-form components/quantity-control.css
*
* The wrapper carries the chrome (border, radius, elevation); the inner
* Bootstrap .card is neutralised through its own custom properties so the two
* boxes stop drawing a double frame.
*
* Tags/supplier/origin used to sit unconditionally in the card body so every
* card's price row lined up regardless of which fields a product had. The
* compact grid has no room for that: they now live in .product-info-panel,
* collapsed by default and revealed by .product-info-toggle (see
* website_sale.js for the click handler, delegated on #products-grid same as
* the quantity stepper).
*/
.product-card {
@ -41,14 +51,23 @@
/* ---------- Media ---------- */
.product-media {
position: relative;
flex-shrink: 0;
}
.product-card .product-image,
.product-img-cover,
.product-img-fixed,
.product-img-placeholder {
display: block;
flex-shrink: 0;
width: 100%;
height: var(--ac-card-media-height);
/* 4:3, not square: a square photo ate too much vertical space on a
two-up mobile grid. aspect-ratio keeps it fluid across card widths
instead of a fixed height that over- or under-crops depending on how
many columns fit. */
aspect-ratio: var(--ac-card-media-ratio);
height: auto;
border-radius: var(--ac-radius-md) var(--ac-radius-md) 0 0;
object-fit: cover;
background-color: var(--ac-surface-muted);
@ -61,6 +80,68 @@
color: var(--ac-text-muted);
}
/* ---------- Info toggle & panel ---------- */
.product-info-toggle {
position: absolute;
top: var(--ac-space-2xs);
right: var(--ac-space-2xs);
display: flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
padding: 0;
border: 1px solid var(--ac-border-strong);
border-radius: var(--ac-radius-pill);
font-size: var(--ac-text-2xs);
color: var(--ac-text-secondary);
background-color: rgb(255 255 255 / 90%);
cursor: pointer;
transition: background-color var(--ac-transition-fast), color var(--ac-transition-fast);
}
.product-info-toggle[aria-expanded="true"] {
color: var(--ac-text-on-fill);
background-color: var(--ac-color-primary-strong);
border-color: var(--ac-color-primary-strong);
}
.product-info-panel {
display: flex;
flex-direction: column;
gap: var(--ac-space-3xs);
margin-bottom: var(--ac-space-3xs);
padding: var(--ac-space-2xs);
border: 1px solid var(--ac-border);
border-radius: var(--ac-radius-sm);
background-color: var(--ac-surface-sunken);
}
.product-info-panel[hidden] {
display: none;
}
@media (hover: hover) and (pointer: fine) {
.product-info-toggle:hover {
color: var(--ac-text-on-fill);
background-color: var(--ac-color-primary);
border-color: var(--ac-color-primary);
}
}
.product-info-toggle:focus-visible {
outline: var(--ac-focus-ring-width) solid var(--ac-focus-ring-color);
outline-offset: var(--ac-focus-ring-offset);
}
@media (pointer: coarse) {
.product-info-toggle {
width: var(--ac-touch-target);
height: var(--ac-touch-target);
}
}
/* ---------- Body ---------- */
.product-card .card-body {
@ -107,10 +188,6 @@
align-content: center;
gap: var(--ac-space-3xs);
justify-content: center;
/* Reserved even when a product has no tags (rendered empty, aria-hidden
see eskaera_shop_products), so the supplier/origin/price rows below line
up across every card in the grid row. Matches one row of .badge-km. */
min-height: calc(var(--ac-text-2xs) * var(--ac-leading-tight) + 2 * var(--ac-space-3xs) + 2px);
margin: 0;
padding: 0;
}
@ -131,14 +208,12 @@
/* ---------- Supplier & origin ---------- */
/* Both rows are reserved even when empty (rendered aria-hidden see
eskaera_shop_products) and clamped to a fixed line count, so the price row
below lines up across every card in the grid row regardless of which
products actually have a supplier name or an origin. */
/* Only ever rendered inside .product-info-panel now (see
eskaera_shop_products), so there is no longer a cross-card alignment to
protect no reserved space, no forced line clamp. */
.product-card .product-supplier,
.product-card .product-origin {
margin: 0;
overflow: hidden;
font-size: var(--ac-text-xs);
font-weight: var(--ac-weight-normal);
text-align: center;
@ -146,17 +221,11 @@
}
.product-card .product-supplier {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
min-height: calc(2em * var(--ac-leading-tight));
overflow-wrap: break-word;
}
.product-card .product-origin {
min-height: calc(1em * var(--ac-leading-tight));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -5,17 +5,18 @@
*
* The grid used to pin an exact column count at seven breakpoints (1 6). It
* now derives the count from the space available, which keeps the card width
* stable no matter how wide the sidebar or the container is. The single query
* left is the deliberate design decision: one card per row on phones, where a
* two-up grid would shrink names and controls below comfortable size.
* stable no matter how wide the sidebar or the container is.
*
* 9rem is the card's minimum width; at the widest container (1320px minus the
* category sidebar) it still resolves to six columns, as before.
* Two columns from the smallest phones: with the compact "cromo" card (4:3
* photo, no inline tags/supplier/origin see product-card.css) a single
* full-width column read as a tall row rather than a browsable grid. 9rem is
* the card's minimum width from 576px up; at the widest container (1320px
* minus the category sidebar) it still resolves to six columns, as before.
*/
.products-grid {
display: grid;
grid-template-columns: 1fr;
grid-template-columns: repeat(2, 1fr);
gap: var(--ac-space-md);
margin-bottom: var(--ac-space-xl);
}

View file

@ -1246,6 +1246,23 @@
}
});
// Product info toggle: shows/hides the origin/supplier/tags panel
// that the compact card no longer displays inline (via event
// delegation, same reasoning as the stepper above).
productsGrid.addEventListener("click", function (e) {
var infoBtn = e.target.closest(".product-info-toggle");
if (!infoBtn) return;
e.preventDefault();
var panelId = infoBtn.getAttribute("aria-controls");
var panel = panelId ? document.getElementById(panelId) : null;
if (!panel) return;
var isOpen = infoBtn.getAttribute("aria-expanded") === "true";
infoBtn.setAttribute("aria-expanded", isOpen ? "false" : "true");
panel.hidden = isOpen;
});
// Add to cart button (via event delegation on grid)
productsGrid.addEventListener("click", handleAddToCart);

View file

@ -14,39 +14,16 @@ class TestMultiCompanyGroupOrder(TransactionCase):
def setUp(self):
super().setUp()
# Crear dos compañías
# Crear dos compañías. `stock_picking_batch_collect` es dependencia del
# módulo, así que los campos de restricción de lote siempre existen.
company_model = self.env["res.company"]
# Compatibilidad con esquemas legacy: columna NOT NULL presente sin campo ORM.
self.env.cr.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'res_company'
AND column_name IN ('batch_summary_restriction_scope', 'batch_detailed_restriction_scope')
""")
existing_columns = {row[0] for row in self.env.cr.fetchall()}
if (
"batch_summary_restriction_scope" in existing_columns
and "batch_summary_restriction_scope" not in company_model._fields
):
self.env.cr.execute(
"ALTER TABLE res_company ALTER COLUMN batch_summary_restriction_scope SET DEFAULT 'processed'"
)
if (
"batch_detailed_restriction_scope" in existing_columns
and "batch_detailed_restriction_scope" not in company_model._fields
):
self.env.cr.execute(
"ALTER TABLE res_company ALTER COLUMN batch_detailed_restriction_scope SET DEFAULT 'processed'"
)
def _company_vals(name):
vals = {"name": name}
if "batch_summary_restriction_scope" in company_model._fields:
vals["batch_summary_restriction_scope"] = "processed"
if "batch_detailed_restriction_scope" in company_model._fields:
vals["batch_detailed_restriction_scope"] = "processed"
return vals
return {
"name": name,
"batch_summary_restriction_scope": "processed",
"batch_detailed_restriction_scope": "processed",
}
self.company1 = company_model.create(_company_vals("Company 1"))
self.company2 = company_model.create(_company_vals("Company 2"))

View file

@ -14,39 +14,16 @@ class TestGroupOrderRecordRules(TransactionCase):
def setUp(self):
super().setUp()
# Crear dos compañías
# Crear dos compañías. `stock_picking_batch_collect` es dependencia del
# módulo, así que los campos de restricción de lote siempre existen.
company_model = self.env["res.company"]
# Compatibilidad con esquemas legacy: columna NOT NULL presente sin campo ORM.
self.env.cr.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'res_company'
AND column_name IN ('batch_summary_restriction_scope', 'batch_detailed_restriction_scope')
""")
existing_columns = {row[0] for row in self.env.cr.fetchall()}
if (
"batch_summary_restriction_scope" in existing_columns
and "batch_summary_restriction_scope" not in company_model._fields
):
self.env.cr.execute(
"ALTER TABLE res_company ALTER COLUMN batch_summary_restriction_scope SET DEFAULT 'processed'"
)
if (
"batch_detailed_restriction_scope" in existing_columns
and "batch_detailed_restriction_scope" not in company_model._fields
):
self.env.cr.execute(
"ALTER TABLE res_company ALTER COLUMN batch_detailed_restriction_scope SET DEFAULT 'processed'"
)
def _company_vals(name):
vals = {"name": name}
if "batch_summary_restriction_scope" in company_model._fields:
vals["batch_summary_restriction_scope"] = "processed"
if "batch_detailed_restriction_scope" in company_model._fields:
vals["batch_detailed_restriction_scope"] = "processed"
return vals
return {
"name": name,
"batch_summary_restriction_scope": "processed",
"batch_detailed_restriction_scope": "processed",
}
self.company1 = company_model.create(_company_vals("Company 1"))
self.company2 = company_model.create(_company_vals("Company 2"))

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Consumer group columns on the batch operator screen (Basket Assembly) -->
<record id="view_move_line_batch_operator_consumer_group" model="ir.ui.view">
<field name="name">stock.move.line.batch.operator.consumer.group</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock_picking_batch_collect.view_move_line_batch_operator"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='picking_partner_id']" position="after">
<field name="home_delivery" readonly="1" widget="boolean_toggle" optional="show"/>
<field name="consumer_group_id" readonly="1" optional="show"/>
</xpath>
</field>
</record>
<!-- Consumer group columns on the batch detailed operations list -->
<record id="view_move_line_tree_batch_consumer_group" model="ir.ui.view">
<field name="name">stock.move.line.list.batch.consumer.group</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock_picking_batch_collect.view_move_line_tree_inherit_batch_custom"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='product_default_code']" position="after">
<field name="home_delivery" readonly="1" widget="boolean_toggle" optional="show"/>
<field name="consumer_group_id" readonly="1" optional="show"/>
</xpath>
</field>
</record>
<!-- Consumer group on the transfers list of a batch -->
<record id="view_stock_picking_batch_picking_tree_consumer_group" model="ir.ui.view">
<field name="name">stock.picking.batch.picking.tree.consumer.group</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock_picking_batch.stock_picking_view_batch_tree_ref"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="consumer_group_id" optional="show"/>
</xpath>
</field>
</record>
</odoo>

View file

@ -274,56 +274,15 @@
</div>
</div>
</div>
<div class="mb-3 eskaera-filters" id="realtimeSearch-filters">
<div class="row g-2">
<div class="col-md-7">
<div class="search-input-wrapper">
<label class="visually-hidden" for="realtime-search-input">Search products</label>
<input type="search" id="realtime-search-input" class="form-control eskaera-search-input" placeholder="Search products..." autocomplete="off" aria-describedby="realtime-search-status" />
<button type="button" id="clear-search-btn" class="search-clear-btn" aria-label="Clear search" title="Clear search">
×
</button>
</div>
<p id="realtime-search-status" class="visually-hidden" aria-live="polite" aria-atomic="true" />
</div>
<div class="col-md-5">
<select name="category" id="realtime-category-select" class="form-select">
<option value="">Browse Product Categories</option>
<t t-call="website_sale_aplicoop.category_hierarchy_options">
<t t-set="categories" t-value="category_hierarchy" />
<t t-set="depth" t-value="0" />
</t>
</select>
</div>
</div>
<t t-if="available_tags">
<div class="row mt-3">
<div class="col-12">
<div id="tag-filter-container" class="tag-filter-badges">
<t t-foreach="available_tags" t-as="tag">
<!-- Toggle buttons: aria-pressed carries the filter
state, which realtime_search.js keeps in sync
with the .is-selected / .is-dimmed classes.
The record colour rides in a custom property so
the stylesheet keeps owning the badge. -->
<t t-if="tag['color']">
<button type="button" class="badge tag-filter-badge" t-att-data-tag-id="tag['id']" t-att-data-tag-name="tag['name']" t-att-data-tag-color="tag['color']" t-attf-style="--ac-tag-color: {{ tag['color'] }};" data-toggle="tag-filter" aria-pressed="false">
<span t-esc="tag['name']" /> (<span class="tag-count" t-esc="tag['count']" />)
</button>
</t>
<t t-else="">
<button type="button" class="badge tag-filter-badge tag-use-theme-color" t-att-data-tag-id="tag['id']" t-att-data-tag-name="tag['name']" data-tag-color="" data-toggle="tag-filter" aria-pressed="false">
<span t-esc="tag['name']" /> (<span class="tag-count" t-esc="tag['count']" />)
</button>
</t>
</t>
</div>
</div>
</div>
</t>
</div>
<div class="row g-2">
<div class="col-lg-3 mb-4 mb-lg-0">
<!-- One flex row for cart/tags/category/search/products: Bootstrap's
order utilities need every reordered item to be a sibling of the
same flex container, so the four blocks that used to live in two
separate rows (filters above, cart+products below) are now columns
here. Unprefixed order-* puts the cart first on mobile (right below
the header, above the filters); order-lg-* restores the desktop
layout: search+category as one row, then tags, then cart+products. -->
<div class="row g-2 eskaera-filters" id="realtimeSearch-filters">
<div class="col-12 col-lg-3 order-1 order-lg-4 mb-4 mb-lg-0">
<div class="card sticky-top cart-sticky-position" aria-label="Cart Summary">
<div class="card-header d-flex justify-content-between align-items-center gap-1">
<h6 class="mb-0 cart-title-sm" id="cart-title">My Cart</h6>
@ -362,7 +321,49 @@
</div>
</div>
</div>
<div class="col-lg-9">
<t t-if="available_tags">
<div class="col-12 order-2 order-lg-3">
<div id="tag-filter-container" class="tag-filter-badges">
<t t-foreach="available_tags" t-as="tag">
<!-- Toggle buttons: aria-pressed carries the filter
state, which realtime_search.js keeps in sync
with the .is-selected / .is-dimmed classes.
The record colour rides in a custom property so
the stylesheet keeps owning the badge. -->
<t t-if="tag['color']">
<button type="button" class="badge tag-filter-badge" t-att-data-tag-id="tag['id']" t-att-data-tag-name="tag['name']" t-att-data-tag-color="tag['color']" t-attf-style="--ac-tag-color: {{ tag['color'] }};" data-toggle="tag-filter" aria-pressed="false">
<span t-esc="tag['name']" /> (<span class="tag-count" t-esc="tag['count']" />)
</button>
</t>
<t t-else="">
<button type="button" class="badge tag-filter-badge tag-use-theme-color" t-att-data-tag-id="tag['id']" t-att-data-tag-name="tag['name']" data-tag-color="" data-toggle="tag-filter" aria-pressed="false">
<span t-esc="tag['name']" /> (<span class="tag-count" t-esc="tag['count']" />)
</button>
</t>
</t>
</div>
</div>
</t>
<div class="col-12 col-lg-5 order-3 order-lg-2">
<select name="category" id="realtime-category-select" class="form-select">
<option value="">Browse Product Categories</option>
<t t-call="website_sale_aplicoop.category_hierarchy_options">
<t t-set="categories" t-value="category_hierarchy" />
<t t-set="depth" t-value="0" />
</t>
</select>
</div>
<div class="col-12 col-lg-7 order-4 order-lg-1">
<div class="search-input-wrapper">
<label class="visually-hidden" for="realtime-search-input">Search products</label>
<input type="search" id="realtime-search-input" class="form-control eskaera-search-input" placeholder="Search products..." autocomplete="off" aria-describedby="realtime-search-status" />
<button type="button" id="clear-search-btn" class="search-clear-btn" aria-label="Clear search" title="Clear search">
×
</button>
</div>
<p id="realtime-search-status" class="visually-hidden" aria-live="polite" aria-atomic="true" />
</div>
<div class="col-12 col-lg-9 order-5 order-lg-5">
<div class="oe_structure oe_empty" data-name="Before Products Filter" />
<t t-if="products">
<div class="products-grid" id="products-grid">
@ -763,16 +764,31 @@
</template>
<template id="eskaera_shop_products" name="Eskaera Shop Products">
<t t-foreach="products" t-as="product">
<t t-set="has_tags" t-value="bool(product.product_tag_ids)" />
<t t-set="supplier_text" t-value="product_supplier_info.get(product.id)" />
<t t-set="has_origin" t-value="bool(product.origin_text)" />
<t t-set="has_info" t-value="has_tags or bool(supplier_text) or has_origin" />
<div class="product-card-wrapper product-card" t-attf-data-product-name="{{ product.name }}" t-attf-data-category-id="{{ product.categ_id.id if product.categ_id else '' }}" t-attf-data-product-tags="{{ ','.join(str(t.id) for t in product.product_tag_ids) if product.product_tag_ids else '' }}">
<div class="card h-100">
<t t-if="product.image_128">
<img t-attf-src="data:image/png;base64,{{ product.image_128.decode() }}" class="card-img-top product-img-cover" t-attf-alt="{{ product.name }}" />
</t>
<t t-else="">
<div class="card-img-top bg-light d-flex align-items-center justify-content-center product-img-placeholder">
<i class="fa fa-image fa-3x text-muted" aria-hidden="true" />
</div>
</t>
<div class="product-media">
<t t-if="product.image_128">
<img t-attf-src="data:image/png;base64,{{ product.image_128.decode() }}" class="card-img-top product-img-cover" t-attf-alt="{{ product.name }}" />
</t>
<t t-else="">
<div class="card-img-top bg-light d-flex align-items-center justify-content-center product-img-placeholder">
<i class="fa fa-image fa-3x text-muted" aria-hidden="true" />
</div>
</t>
<t t-if="has_info">
<!-- Origin/supplier/tags used to sit unconditionally in the card
body; on the compact "cromo" tile there is no room to show
them inline, so they move behind this toggle (see
.product-info-panel below). -->
<button type="button" class="product-info-toggle" aria-expanded="false" t-attf-aria-controls="product-info-{{ product.id }}" aria-label="Show origin, supplier and tags" title="Origin, supplier and tags">
<i class="fa fa-info" aria-hidden="true" t-translation="off" />
</button>
</t>
</div>
<t t-if="product.dynamic_ribbon_id">
<t t-set="ribbon" t-value="product.dynamic_ribbon_id" />
<span t-attf-class="o_ribbon {{ ribbon._get_position_class() }} z-1" t-attf-style="color: {{ ribbon.text_color }}; background-color: {{ ribbon.bg_color }};" t-esc="ribbon.name" />
@ -783,53 +799,36 @@
</t>
<div class="card-body d-flex flex-column">
<h6 class="card-title" t-esc="product.name" />
<!-- Tags/supplier/origin always occupy their slot (empty and
aria-hidden when the product has no value) so that the price
row below lines up across every card in the grid row,
regardless of which optional fields a given product has. The
tags row itself is only reserved when at least one product in
this batch actually has a tag (any_product_has_tags, computed
controller-side) — otherwise it would add a permanent empty
gap to every card for a feature nobody in the list uses. -->
<t t-if="any_product_has_tags">
<t t-if="product.product_tag_ids">
<div class="product-tags">
<t t-foreach="filtered_product_tags.get(product.id, {}).get('published_tags', product.product_tag_ids)" t-as="tag">
<t t-if="tag.color">
<!-- Per-record colour: passed as a custom property so the
stylesheet keeps owning the badge, no !important needed. -->
<span class="badge badge-km" t-attf-style="--ac-tag-color: {{ tag.color }};" t-esc="tag.name" />
<t t-if="has_info">
<div t-attf-id="product-info-{{ product.id }}" class="product-info-panel" hidden="hidden">
<t t-if="has_tags">
<div class="product-tags">
<t t-foreach="filtered_product_tags.get(product.id, {}).get('published_tags', product.product_tag_ids)" t-as="tag">
<t t-if="tag.color">
<!-- Per-record colour: passed as a custom property so the
stylesheet keeps owning the badge, no !important needed. -->
<span class="badge badge-km" t-attf-style="--ac-tag-color: {{ tag.color }};" t-esc="tag.name" />
</t>
<t t-else="">
<span class="badge badge-km tag-use-theme-color" t-esc="tag.name" />
</t>
</t>
<t t-else="">
<span class="badge badge-km tag-use-theme-color" t-esc="tag.name" />
</t>
</t>
</div>
</t>
<t t-else="">
<div class="product-tags" aria-hidden="true" />
</t>
</t>
<t t-if="product_supplier_info.get(product.id)">
<p class="product-supplier">
<small>
<t t-esc="product_supplier_info[product.id]" />
</small>
</p>
</t>
<t t-else="">
<p class="product-supplier" aria-hidden="true" />
</t>
<t t-if="product.origin_text">
<p class="product-origin">
<small>
<i class="fa fa-map-marker" aria-hidden="true" />
<t t-out="product.origin_text" />
</small>
</p>
</t>
<t t-else="">
<p class="product-origin" aria-hidden="true" />
</div>
</t>
<t t-if="supplier_text">
<p class="product-supplier">
<small t-esc="supplier_text" />
</p>
</t>
<t t-if="has_origin">
<p class="product-origin">
<small>
<i class="fa fa-map-marker" aria-hidden="true" />
<t t-out="product.origin_text" />
</small>
</p>
</t>
</div>
</t>
<t t-set="price_info" t-value="product_price_info.get(product.id, {})" />
<t t-set="display_price" t-value="product_display_info.get(product.id, {}).get('display_price', 0.0)" />