Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions stock_voucher_ux/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
##############################################################################
from . import models
from . import controllers
from . import wizards
31 changes: 28 additions & 3 deletions stock_voucher_ux/models/stock_picking.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,40 @@ def do_print_and_assign(self):
self.assign_numbers(1, self.book_id)
return self.do_print_voucher()

def button_validate(self):
# Imprime el remito al validar sólo si el tipo de operación lo pide
# (``auto_print_delivery_slip``). Autoimpreso: ya numerado en
# ``_action_done``, sólo imprime. Preimpreso: numera al imprimir por
# páginas reales (mismo camino que "Imprimir Remito").
res = super().button_validate()
if (
len(self) == 1
and self.state == "done"
and self.book_required
and self.book_id
and self.picking_type_id.auto_print_delivery_slip
):
if self.autoprinted:
return self.do_print_voucher()
return self.do_print_and_assign()
return res

def _action_done(self):
# Los talonarios preimpresos (``autoprinted=False``) se numeran al
# IMPRIMIR según las páginas reales del reporte, no en la validación por
# la estimación ``lines_per_voucher``. Evitamos que la base los
# pre-asigne acá; los autoimpresos siguen numerándose como antes.
# la estimación ``lines_per_voucher``. Los autoimpresos se numeran acá al
# validar, pero sólo si el tipo de operación pide imprimir el remito al
# validar (``auto_print_delivery_slip``) — el remito reemplaza al recibo
# de entrega nativo. Sin ese flag el número se asigna al IMPRIMIR a mano.
res = super(StockPicking, self.with_context(do_not_assign_numbers=True))._action_done()
if self._context.get("do_not_assign_numbers"):
return res
for picking in self.filtered(lambda p: p.book_required and p.book_id and p.book_id.autoprinted):
for picking in self.filtered(
lambda p: p.book_required
and p.book_id
and p.book_id.autoprinted
and p.picking_type_id.auto_print_delivery_slip
):
picking.assign_numbers(picking.get_estimated_number_of_pages(), picking.book_id)
return res

Expand Down
36 changes: 29 additions & 7 deletions stock_voucher_ux/tests/test_remito_preimpreso.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ class TestRemitoPreimpresoNumbering(TransactionCase):
estimación ``lines_per_voucher`` (subnumera). La cantidad se determina al
imprimir, según las páginas reales del reporte (controller).

Autoimpreso (``autoprinted=True``): conserva el comportamiento previo
(asigna en la validación).
Autoimpreso (``autoprinted=True``): se numera en la validación sólo si el
tipo de operación pide imprimir el remito al validar
(``auto_print_delivery_slip``) — el remito reemplaza al recibo de entrega
nativo. Sin ese flag no se numera al validar (se asigna al imprimir a mano).
"""

@classmethod
Expand Down Expand Up @@ -54,9 +56,16 @@ def setUpClass(cls):
cls.src = cls.env.ref("stock.stock_location_stock")
cls.dest = cls.env.ref("stock.stock_location_customers")

def _make_done_picking(self, book):
def _make_done_picking(self, book, auto_print=False):
picking_type = self.env.ref("stock.picking_type_out")
picking_type.write({"book_required": True, "book_id": book.id, "voucher_required": False})
picking_type.write(
{
"book_required": True,
"book_id": book.id,
"voucher_required": False,
"auto_print_delivery_slip": auto_print,
}
)
picking = self.env["stock.picking"].create(
{
"picking_type_id": picking_type.id,
Expand Down Expand Up @@ -94,11 +103,24 @@ def test_preprinted_not_preassigned_on_validation(self):
"la numeración se hace al imprimir según páginas reales.",
)

def test_autoprinted_assigned_on_validation(self):
picking = self._make_done_picking(self.book_auto)
def test_autoprinted_assigned_on_validation_with_flag(self):
# Con auto_print_delivery_slip el remito reemplaza al recibo de entrega
# y el autoimpreso se numera al validar.
picking = self._make_done_picking(self.book_auto, auto_print=True)
self.assertEqual(picking.state, "done")
self.assertEqual(
len(picking.voucher_ids),
1,
"Un talonario autoimpreso debe asignar un único remito en la validación.",
"Un talonario autoimpreso debe asignar un único remito en la validación "
"cuando el tipo de operación tiene auto_print_delivery_slip.",
)

def test_autoprinted_not_assigned_without_flag(self):
# Sin el flag, validar no numera: el número se asigna al imprimir a mano.
picking = self._make_done_picking(self.book_auto, auto_print=False)
self.assertEqual(picking.state, "done")
self.assertFalse(
picking.voucher_ids,
"Sin auto_print_delivery_slip, un talonario autoimpreso no debe numerarse "
"en la validación; el número se asigna al imprimir.",
)
5 changes: 5 additions & 0 deletions stock_voucher_ux/wizards/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
##############################################################################
# For copyright and license notices, see __manifest__.py file in module root
# directory
##############################################################################
from . import stock_backorder_confirmation
24 changes: 24 additions & 0 deletions stock_voucher_ux/wizards/stock_backorder_confirmation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
##############################################################################
# For copyright and license notices, see __manifest__.py file in module root
# directory
##############################################################################
from odoo import models
from odoo.addons.stock_voucher.wizards.stock_backorder_confirmation import (
StockBackorderConfirmation as VoucherBackorderConfirmation,
)


class StockBackorderConfirmation(models.TransientModel):
_inherit = "stock.backorder.confirmation"

def process(self):
# En Odoo 18 el core re-ejecuta ``button_validate`` sobre los pickings al
# confirmar el backorder, y ese camino ya imprime el remito (respetando
# ``auto_print_delivery_slip`` y numerando el preimpreso con assign=True).
# Saltamos el override de ``stock_voucher``, que reimprimía con
# ``do_print_voucher`` sin assign (dejaba el preimpreso sin numerar) y
# devolvía una tupla que el cliente no ejecuta.
return super(VoucherBackorderConfirmation, self).process()

def process_cancel_backorder(self):
return super(VoucherBackorderConfirmation, self).process_cancel_backorder()
77 changes: 77 additions & 0 deletions stock_voucher_ux_iot/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
======================
Stock Voucher UX - IoT
======================

Assign preprinted voucher (remito) numbers when the report is printed through an
IoT printer.

Características
===============

- Cuando el remito preimpreso se imprime a través de una impresora IoT, asigna
automáticamente los números de remito al picking, igual que ocurre al
descargar el PDF desde el navegador.
- La asignación se basa en la cantidad real de páginas con productos del reporte
renderizado (dividida por la cantidad de copias del reporte), no en una
estimación.
- Solo aplica a talonarios preimpresos (``autoprinted = False``); los talonarios
autoimpresos siguen numerándose en la validación.
- Es idempotente: si el picking ya tiene números asignados, un reintento de
impresión no consume números de secuencia adicionales.

Detalles Técnicos
=================

- Modelos nuevos: ninguno.
- Modelos heredados:

- ``ir.actions.report``: sobrescribe ``render_and_send`` (método provisto por
el módulo ``iot``) para asignar los números de remito antes de delegar en
``super()``, de modo que el documento que se renderiza y se envía a la
impresora ya los incluye. Métodos auxiliares:
``_is_preprinted_voucher_report`` (detecta el reporte aeroo de remito sobre
``stock.picking``), ``_assign_preprinted_voucher_numbers``,
``_count_voucher_pages`` y ``_count_pages_with_products``.

- Vistas incluidas: ninguna.
- Datos / seguridad: ninguno.

Uso
===

1. Configurar en el reporte aeroo del remito preimpreso uno o más dispositivos
IoT de tipo impresora (campo ``IoT Devices`` de ``ir.actions.report``).
2. Asegurarse de que el picking tenga asignado un talonario preimpreso
(``stock.book`` con ``autoprinted = False``).
3. Imprimir el remito. Al enviarse a la impresora IoT, los números de remito se
asignan automáticamente al picking y aparecen en el documento impreso.

Arquitectura
============

Módulo puente entre ``stock_voucher_ux`` e ``iot``.

En el flujo de descarga por navegador, ``stock_voucher_ux`` asigna los números
de remito en su override del controller ``/report/download``. El camino de
impresión por IoT nunca pasa por ese controller: el handler del cliente
(``iot/static/src/iot_report_action.js``) llama a
``ir.actions.report.render_and_send``, que renderiza el documento del lado del
servidor y lo envía directo a la impresora, cortocircuitando la acción. Este
módulo cubre ese hueco enganchándose en ``render_and_send``, de forma que la
asignación de números ocurre independientemente del canal de impresión.

Dependencias
============

- ``stock_voucher_ux``
- ``iot``

Autor
=====

ADHOC SA

Licencia
========

AGPL-3
5 changes: 5 additions & 0 deletions stock_voucher_ux_iot/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
##############################################################################
# For copyright and license notices, see __manifest__.py file in module root
# directory
##############################################################################
from . import models
39 changes: 39 additions & 0 deletions stock_voucher_ux_iot/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
##############################################################################
#
# Copyright (C) 2015 ADHOC SA (http://www.adhoc.com.ar)
# All Rights Reserved.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
{
"name": "Stock Voucher UX - IoT",
"version": "18.0.1.0.0",
"category": "Warehouse Management",
"sequence": 14,
"summary": "Assign preprinted voucher numbers when the remito is printed " "through an IoT printer",
"author": "ADHOC SA",
"website": "www.adhoc.com.ar",
"license": "AGPL-3",
"images": [],
"depends": [
"stock_voucher_ux",
"iot",
],
"data": [],
"demo": [],
"installable": True,
"auto_install": False,
"application": False,
}
5 changes: 5 additions & 0 deletions stock_voucher_ux_iot/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
##############################################################################
# For copyright and license notices, see __manifest__.py file in module root
# directory
##############################################################################
from . import ir_actions_report
118 changes: 118 additions & 0 deletions stock_voucher_ux_iot/models/ir_actions_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
##############################################################################
# For copyright and license notices, see __manifest__.py file in module root
# directory
##############################################################################
import io
import logging
import re

from odoo import models

_logger = logging.getLogger(__name__)

try:
from PyPDF2 import PdfFileReader
except ImportError: # pragma: no cover
_logger.debug("PyPDF2 could not be imported; voucher page counting will fall back to 1.")
PdfFileReader = None


class IrActionsReport(models.Model):
_inherit = "ir.actions.report"

def render_and_send(self, devices, res_ids, data=None, print_id=0, websocket=True):
"""Assign the preprinted voucher numbers when the remito is printed
through an IoT printer.

On the regular (browser download) flow the numbers are assigned by
``stock_voucher_ux``'s ``/report/download`` controller. The IoT path
never reaches that controller: the client handler
(``iot/static/src/iot_report_action.js``) calls this method and
short-circuits the action, so the document is rendered server-side and
streamed straight to the printer. We assign the numbers *before*
delegating to ``super()`` so the render it performs already carries
them, mirroring the download flow.
"""
if self._is_preprinted_voucher_report():
self._assign_preprinted_voucher_numbers(res_ids, data=data)
return super().render_and_send(devices, res_ids, data=data, print_id=print_id, websocket=websocket)

def _is_preprinted_voucher_report(self):
"""The remito preimpreso is an aeroo report on ``stock.picking`` whose
``report_name`` contains ``remito`` (same guard the download controller
uses)."""
self.ensure_one()
return self.model == "stock.picking" and self.report_type == "aeroo" and "remito" in (self.report_name or "")

def _assign_preprinted_voucher_numbers(self, res_ids, data=None):
"""Assign voucher numbers to every preprinted picking in ``res_ids``
that still has none, based on the real number of rendered pages."""
self.ensure_one()
for picking in self.env["stock.picking"].browse(res_ids):
book = picking.book_id
# Only preprinted books (autoprinted=False) are numbered at print
# time; autoprinted books are numbered on validation. Skip pickings
# that already have numbers to keep this idempotent (a retry of the
# print must not burn extra sequence numbers).
if not book or book.autoprinted or picking.voucher_ids:
continue
number_of_pages = self._count_voucher_pages(picking, data=data)
picking.assign_numbers(number_of_pages, book)
picking.env.flush_all()

def _count_voucher_pages(self, picking, data=None):
"""Render the report once (numbers not assigned yet) and count the real
number of pages that contain products, capped by the page count without
copies. Falls back to a single voucher when the output cannot be parsed
as a multi-page PDF (e.g. ``.doc`` output)."""
self.ensure_one()
if PdfFileReader is None:
return 1
try:
content = self._render(self.report_name, picking.ids, data=data)[0]
reader = PdfFileReader(io.BytesIO(content))
pages_with_products = self._count_pages_with_products(reader, picking)
copies = self.copies or 0
if copies:
total_pages = int(len(reader.pages) / copies)
return max(1, min(pages_with_products, total_pages))
return max(1, pages_with_products)
except Exception: # noqa: BLE001 - any render/parse issue -> single voucher
return 1

def _count_pages_with_products(self, pdf_reader, picking):
"""Count the pages that actually contain products by matching product
identifiers (internal reference / barcode) in the page text, in a
language independent way. Mirrors the logic used by the download
controller in ``stock_voucher_ux``."""
move_lines = picking.move_line_ids or picking.move_ids

product_identifiers = set()
for line in move_lines:
product = line.product_id
if not product:
continue
if product.default_code:
product_identifiers.add(product.default_code.lower().strip())
if product.barcode:
product_identifiers.add(product.barcode.lower().strip())

pages_with_products = 0
for page_num in range(len(pdf_reader.pages)):
try:
text = pdf_reader.pages[page_num].extract_text()
if not text:
continue
text_lower = text.lower()
if product_identifiers and any(pid in text_lower for pid in product_identifiers):
has_products = True
else:
# Fallback: generic numeric pattern (language independent).
has_products = bool(re.search(r"\b\d+[.,]\d+\b", text_lower))
if has_products:
pages_with_products += 1
except Exception: # noqa: BLE001 - if text can't be extracted assume it has products
pages_with_products += 1

# There is always at least one page with products.
return max(1, pages_with_products)
5 changes: 5 additions & 0 deletions stock_voucher_ux_iot/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
##############################################################################
# For copyright and license notices, see __manifest__.py file in module root
# directory
##############################################################################
from . import test_iot_voucher
Loading