[FIX] stock_picking_batch_custom: prevent product_id null error on summary lines
- Use regular dict instead of defaultdict to avoid empty entries - Make summary_line_ids readonly=True to prevent UI from inserting empty lines - Add SQL constraint CHECK(product_id IS NOT NULL) as safeguard - Use boolean_toggle widget for is_collected field - Fix tests to use TransactionCase and invalidate_recordset - Add test for empty batch + add pickings + confirm flow
This commit is contained in:
parent
ad8b759643
commit
3eeca66551
5 changed files with 304 additions and 45 deletions
|
|
@ -1,13 +1,66 @@
|
||||||
===============================
|
============================
|
||||||
Stock Picking Batch Custom
|
Stock Picking Batch Custom
|
||||||
===============================
|
============================
|
||||||
|
|
||||||
.. contents::
|
Visión general
|
||||||
:local:
|
==============
|
||||||
|
Este módulo amplía las operaciones detalladas y añade un resumen por producto
|
||||||
|
en los lotes de picking:
|
||||||
|
|
||||||
.. include:: readme/DESCRIPTION.rst
|
- ``picking_partner_id`` (Partner del albarán) para identificar cliente/proveedor.
|
||||||
.. include:: readme/INSTALL.rst
|
- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar.
|
||||||
.. include:: readme/CONFIGURE.rst
|
- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se ha
|
||||||
.. include:: readme/USAGE.rst
|
recolectado.
|
||||||
.. include:: readme/CONTRIBUTORS.rst
|
- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho,
|
||||||
.. include:: readme/CREDITS.rst
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
# Copyright 2026 Criptomart
|
# Copyright 2026 Criptomart
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
from odoo import api
|
from odoo import api
|
||||||
from odoo import fields
|
from odoo import fields
|
||||||
from odoo import models
|
from odoo import models
|
||||||
|
|
@ -17,7 +15,7 @@ class StockPickingBatch(models.Model):
|
||||||
string="Product Summary",
|
string="Product Summary",
|
||||||
compute="_compute_summary_line_ids",
|
compute="_compute_summary_line_ids",
|
||||||
store=True,
|
store=True,
|
||||||
readonly=False,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.depends(
|
@api.depends(
|
||||||
|
|
@ -38,36 +36,57 @@ class StockPickingBatch(models.Model):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for batch in self:
|
for batch in self:
|
||||||
previous_collected = {
|
# Base: limpiar si no hay líneas
|
||||||
line.product_id.id: line.is_collected for line in batch.summary_line_ids
|
if not batch.move_line_ids:
|
||||||
}
|
batch.summary_line_ids = [fields.Command.clear()]
|
||||||
aggregates = defaultdict(
|
continue
|
||||||
lambda: {
|
|
||||||
"product_id": False,
|
|
||||||
"product_uom_id": False,
|
|
||||||
"qty_demanded": 0.0,
|
|
||||||
"qty_done": 0.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
previous_collected = {
|
||||||
|
line.product_id.id: line.is_collected
|
||||||
|
for line in batch.summary_line_ids
|
||||||
|
if line.product_id
|
||||||
|
}
|
||||||
|
# Use regular dict to avoid accidental creation of empty entries
|
||||||
|
aggregates = {}
|
||||||
|
|
||||||
|
# Demand per move (count once per move)
|
||||||
for move in batch.move_ids:
|
for move in batch.move_ids:
|
||||||
if move.state == "cancel" or not move.product_id:
|
if move.state == "cancel" or not move.product_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
product = move.product_id
|
product = move.product_id
|
||||||
product_uom = product.uom_id
|
product_uom = product.uom_id
|
||||||
demand = move.product_uom._compute_quantity(
|
demand = move.product_uom._compute_quantity(
|
||||||
move.product_uom_qty, product_uom, rounding_method="HALF-UP"
|
move.product_uom_qty, product_uom, rounding_method="HALF-UP"
|
||||||
)
|
)
|
||||||
done = move.product_uom._compute_quantity(
|
if product.id not in aggregates:
|
||||||
move.quantity, product_uom, rounding_method="HALF-UP"
|
aggregates[product.id] = {
|
||||||
)
|
"product_id": product.id,
|
||||||
|
"product_uom_id": product_uom.id,
|
||||||
|
"qty_demanded": 0.0,
|
||||||
|
"qty_done": 0.0,
|
||||||
|
}
|
||||||
|
aggregates[product.id]["qty_demanded"] += demand
|
||||||
|
|
||||||
entry = aggregates[product.id]
|
# Done per move line
|
||||||
entry["product_id"] = product.id
|
for line in batch.move_line_ids:
|
||||||
entry["product_uom_id"] = product_uom.id
|
move = line.move_id
|
||||||
entry["qty_demanded"] += demand
|
if move and move.state == "cancel":
|
||||||
entry["qty_done"] += done
|
continue
|
||||||
|
product = line.product_id
|
||||||
|
if not product:
|
||||||
|
continue
|
||||||
|
product_uom = product.uom_id
|
||||||
|
done = line.product_uom_id._compute_quantity(
|
||||||
|
line.quantity, product_uom, rounding_method="HALF-UP"
|
||||||
|
)
|
||||||
|
if product.id not in aggregates:
|
||||||
|
aggregates[product.id] = {
|
||||||
|
"product_id": product.id,
|
||||||
|
"product_uom_id": product_uom.id,
|
||||||
|
"qty_demanded": 0.0,
|
||||||
|
"qty_done": 0.0,
|
||||||
|
}
|
||||||
|
aggregates[product.id]["qty_done"] += done
|
||||||
|
|
||||||
commands = []
|
commands = []
|
||||||
products_in_totals = set()
|
products_in_totals = set()
|
||||||
|
|
@ -76,9 +95,12 @@ class StockPickingBatch(models.Model):
|
||||||
}
|
}
|
||||||
|
|
||||||
for product_id, values in aggregates.items():
|
for product_id, values in aggregates.items():
|
||||||
|
# Double-check: skip if product_id is not valid
|
||||||
|
if not product_id or not values.get("product_id"):
|
||||||
|
continue
|
||||||
products_in_totals.add(product_id)
|
products_in_totals.add(product_id)
|
||||||
existing_line = existing_by_product.get(product_id)
|
existing_line = existing_by_product.get(product_id)
|
||||||
values["is_collected"] = previous_collected.get(product_id, False)
|
is_collected = previous_collected.get(product_id, False)
|
||||||
if existing_line:
|
if existing_line:
|
||||||
commands.append(
|
commands.append(
|
||||||
fields.Command.update(
|
fields.Command.update(
|
||||||
|
|
@ -87,12 +109,24 @@ class StockPickingBatch(models.Model):
|
||||||
"product_uom_id": values["product_uom_id"],
|
"product_uom_id": values["product_uom_id"],
|
||||||
"qty_demanded": values["qty_demanded"],
|
"qty_demanded": values["qty_demanded"],
|
||||||
"qty_done": values["qty_done"],
|
"qty_done": values["qty_done"],
|
||||||
"is_collected": values["is_collected"],
|
"is_collected": is_collected,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
commands.append(fields.Command.create(values))
|
# Ensure all required fields are present before create
|
||||||
|
if values["product_id"] and values["product_uom_id"]:
|
||||||
|
commands.append(
|
||||||
|
fields.Command.create(
|
||||||
|
{
|
||||||
|
"product_id": values["product_id"],
|
||||||
|
"product_uom_id": values["product_uom_id"],
|
||||||
|
"qty_demanded": values["qty_demanded"],
|
||||||
|
"qty_done": values["qty_done"],
|
||||||
|
"is_collected": is_collected,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
obsolete_lines = [
|
obsolete_lines = [
|
||||||
fields.Command.unlink(line.id)
|
fields.Command.unlink(line.id)
|
||||||
|
|
@ -100,7 +134,10 @@ class StockPickingBatch(models.Model):
|
||||||
if line.product_id.id not in products_in_totals
|
if line.product_id.id not in products_in_totals
|
||||||
]
|
]
|
||||||
|
|
||||||
batch.summary_line_ids = commands + obsolete_lines
|
if commands or obsolete_lines:
|
||||||
|
batch.summary_line_ids = commands + obsolete_lines
|
||||||
|
else:
|
||||||
|
batch.summary_line_ids = [fields.Command.clear()]
|
||||||
|
|
||||||
|
|
||||||
class StockPickingBatchSummaryLine(models.Model):
|
class StockPickingBatchSummaryLine(models.Model):
|
||||||
|
|
@ -108,16 +145,22 @@ class StockPickingBatchSummaryLine(models.Model):
|
||||||
_description = "Batch Product Summary Line"
|
_description = "Batch Product Summary Line"
|
||||||
_order = "product_categ_id, product_id"
|
_order = "product_categ_id, product_id"
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
(
|
||||||
|
"product_required",
|
||||||
|
"CHECK(product_id IS NOT NULL)",
|
||||||
|
"Product is required for summary lines.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
batch_id = fields.Many2one(
|
batch_id = fields.Many2one(
|
||||||
comodel_name="stock.picking.batch",
|
comodel_name="stock.picking.batch",
|
||||||
string="Batch",
|
|
||||||
ondelete="cascade",
|
ondelete="cascade",
|
||||||
required=True,
|
required=True,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
product_id = fields.Many2one(
|
product_id = fields.Many2one(
|
||||||
comodel_name="product.product",
|
comodel_name="product.product",
|
||||||
string="Product",
|
|
||||||
required=True,
|
required=True,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from . import test_batch_summary # noqa: F401
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
# Copyright 2026 Criptomart
|
# Copyright 2026 Criptomart
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
|
|
||||||
from odoo.tests.common import SavepointCase
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
class TestBatchSummary(SavepointCase):
|
@tagged("-at_install", "post_install")
|
||||||
|
class TestBatchSummary(TransactionCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
|
|
@ -62,7 +64,7 @@ class TestBatchSummary(SavepointCase):
|
||||||
"move_id": move.id,
|
"move_id": move.id,
|
||||||
"picking_id": picking.id,
|
"picking_id": picking.id,
|
||||||
"product_id": self.product.id,
|
"product_id": self.product.id,
|
||||||
"product_uom_id": uom.id,
|
"product_uom_id": self.product.uom_id.id,
|
||||||
"location_id": self.location_src.id,
|
"location_id": self.location_src.id,
|
||||||
"location_dest_id": self.location_dest.id,
|
"location_dest_id": self.location_dest.id,
|
||||||
"quantity": qty_done,
|
"quantity": qty_done,
|
||||||
|
|
@ -71,9 +73,45 @@ class TestBatchSummary(SavepointCase):
|
||||||
return move
|
return move
|
||||||
|
|
||||||
def _recompute_batch(self):
|
def _recompute_batch(self):
|
||||||
self.batch.invalidate_cache()
|
self.batch.invalidate_recordset()
|
||||||
self.batch._compute_summary_line_ids()
|
self.batch._compute_summary_line_ids()
|
||||||
|
|
||||||
|
def _create_batch_with_pickings(self):
|
||||||
|
batch = self.env["stock.picking.batch"].create(
|
||||||
|
{"name": "Batch Flow", "picking_type_id": self.picking_type.id}
|
||||||
|
)
|
||||||
|
picking = self.env["stock.picking"].create(
|
||||||
|
{
|
||||||
|
"picking_type_id": self.picking_type.id,
|
||||||
|
"location_id": self.location_src.id,
|
||||||
|
"location_dest_id": self.location_dest.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
move = self.env["stock.move"].create(
|
||||||
|
{
|
||||||
|
"name": self.product.name,
|
||||||
|
"product_id": self.product.id,
|
||||||
|
"product_uom_qty": 2.0,
|
||||||
|
"product_uom": self.uom_unit.id,
|
||||||
|
"picking_id": picking.id,
|
||||||
|
"location_id": self.location_src.id,
|
||||||
|
"location_dest_id": self.location_dest.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.env["stock.move.line"].create(
|
||||||
|
{
|
||||||
|
"move_id": move.id,
|
||||||
|
"picking_id": picking.id,
|
||||||
|
"product_id": self.product.id,
|
||||||
|
"product_uom_id": self.uom_unit.id,
|
||||||
|
"location_id": self.location_src.id,
|
||||||
|
"location_dest_id": self.location_dest.id,
|
||||||
|
"quantity": 2.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
picking.batch_id = batch.id
|
||||||
|
return batch
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
def test_totals_and_pending_with_conversion(self):
|
def test_totals_and_pending_with_conversion(self):
|
||||||
"""Totals aggregate per product with UoM conversion and pending."""
|
"""Totals aggregate per product with UoM conversion and pending."""
|
||||||
|
|
@ -125,3 +163,127 @@ class TestBatchSummary(SavepointCase):
|
||||||
self._recompute_batch()
|
self._recompute_batch()
|
||||||
|
|
||||||
self.assertFalse(self.batch.summary_line_ids)
|
self.assertFalse(self.batch.summary_line_ids)
|
||||||
|
|
||||||
|
def test_no_required_product_error_on_confirm(self):
|
||||||
|
"""Confirming batch with pickings must not create summary lines without product."""
|
||||||
|
|
||||||
|
batch = self._create_batch_with_pickings()
|
||||||
|
|
||||||
|
# Trigger compute via confirm flow
|
||||||
|
batch.action_confirm()
|
||||||
|
|
||||||
|
self.assertTrue(batch.summary_line_ids)
|
||||||
|
self.assertFalse(
|
||||||
|
batch.summary_line_ids.filtered(lambda line: not line.product_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_batch_add_pickings_then_confirm(self):
|
||||||
|
"""Create empty draft batch, add multiple pickings, then confirm."""
|
||||||
|
|
||||||
|
# 1. Create empty batch in draft state
|
||||||
|
batch = self.env["stock.picking.batch"].create(
|
||||||
|
{"name": "Batch Empty Start", "picking_type_id": self.picking_type.id}
|
||||||
|
)
|
||||||
|
self.assertEqual(batch.state, "draft")
|
||||||
|
self.assertFalse(batch.picking_ids)
|
||||||
|
self.assertFalse(batch.summary_line_ids)
|
||||||
|
|
||||||
|
# 2. Create pickings with moves (without batch assignment)
|
||||||
|
product2 = self.env["product.product"].create(
|
||||||
|
{
|
||||||
|
"name": "Test Product 2",
|
||||||
|
"uom_id": self.uom_unit.id,
|
||||||
|
"uom_po_id": self.uom_unit.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
picking_a = self.env["stock.picking"].create(
|
||||||
|
{
|
||||||
|
"picking_type_id": self.picking_type.id,
|
||||||
|
"location_id": self.location_src.id,
|
||||||
|
"location_dest_id": self.location_dest.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.env["stock.move"].create(
|
||||||
|
{
|
||||||
|
"name": self.product.name,
|
||||||
|
"product_id": self.product.id,
|
||||||
|
"product_uom_qty": 5.0,
|
||||||
|
"product_uom": self.uom_unit.id,
|
||||||
|
"picking_id": picking_a.id,
|
||||||
|
"location_id": self.location_src.id,
|
||||||
|
"location_dest_id": self.location_dest.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
picking_b = self.env["stock.picking"].create(
|
||||||
|
{
|
||||||
|
"picking_type_id": self.picking_type.id,
|
||||||
|
"location_id": self.location_src.id,
|
||||||
|
"location_dest_id": self.location_dest.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.env["stock.move"].create(
|
||||||
|
{
|
||||||
|
"name": product2.name,
|
||||||
|
"product_id": product2.id,
|
||||||
|
"product_uom_qty": 10.0,
|
||||||
|
"product_uom": self.uom_unit.id,
|
||||||
|
"picking_id": picking_b.id,
|
||||||
|
"location_id": self.location_src.id,
|
||||||
|
"location_dest_id": self.location_dest.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
picking_c = self.env["stock.picking"].create(
|
||||||
|
{
|
||||||
|
"picking_type_id": self.picking_type.id,
|
||||||
|
"location_id": self.location_src.id,
|
||||||
|
"location_dest_id": self.location_dest.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.env["stock.move"].create(
|
||||||
|
{
|
||||||
|
"name": self.product.name,
|
||||||
|
"product_id": self.product.id,
|
||||||
|
"product_uom_qty": 3.0,
|
||||||
|
"product_uom": self.uom_unit.id,
|
||||||
|
"picking_id": picking_c.id,
|
||||||
|
"location_id": self.location_src.id,
|
||||||
|
"location_dest_id": self.location_dest.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Add pickings to the batch
|
||||||
|
picking_a.batch_id = batch
|
||||||
|
picking_b.batch_id = batch
|
||||||
|
picking_c.batch_id = batch
|
||||||
|
|
||||||
|
self.assertEqual(len(batch.picking_ids), 3)
|
||||||
|
|
||||||
|
# 4. Confirm the batch — this should not raise product_id required error
|
||||||
|
batch.action_confirm()
|
||||||
|
|
||||||
|
self.assertEqual(batch.state, "in_progress")
|
||||||
|
|
||||||
|
# 5. Verify summary lines are correct
|
||||||
|
self.assertTrue(batch.summary_line_ids)
|
||||||
|
self.assertFalse(
|
||||||
|
batch.summary_line_ids.filtered(lambda line: not line.product_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Two products expected
|
||||||
|
products_in_summary = batch.summary_line_ids.mapped("product_id")
|
||||||
|
self.assertIn(self.product, products_in_summary)
|
||||||
|
self.assertIn(product2, products_in_summary)
|
||||||
|
|
||||||
|
# Check aggregated quantities
|
||||||
|
line_product1 = batch.summary_line_ids.filtered(
|
||||||
|
lambda line: line.product_id == self.product
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(line_product1.qty_demanded, 8.0) # 5 + 3
|
||||||
|
|
||||||
|
line_product2 = batch.summary_line_ids.filtered(
|
||||||
|
lambda line: line.product_id == product2
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(line_product2.qty_demanded, 10.0)
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@
|
||||||
<field name="name">stock.picking.batch.summary.line.tree</field>
|
<field name="name">stock.picking.batch.summary.line.tree</field>
|
||||||
<field name="model">stock.picking.batch.summary.line</field>
|
<field name="model">stock.picking.batch.summary.line</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list create="0" delete="0" editable="bottom" default_order="product_categ_id,product_id">
|
<list create="0" delete="0" default_order="product_categ_id,product_id">
|
||||||
<field name="product_id" readonly="1"/>
|
<field name="product_id" readonly="1"/>
|
||||||
<field name="product_categ_id" readonly="1" optional="hide"/>
|
<field name="product_categ_id" readonly="1" optional="hide"/>
|
||||||
<field name="product_uom_id" readonly="1" optional="hide"/>
|
<field name="product_uom_id" readonly="1" optional="hide"/>
|
||||||
<field name="qty_demanded" readonly="1"/>
|
<field name="qty_demanded" readonly="1"/>
|
||||||
<field name="qty_done" readonly="1"/>
|
<field name="qty_done" readonly="1"/>
|
||||||
<field name="qty_pending" readonly="1"/>
|
<field name="qty_pending" readonly="1"/>
|
||||||
<field name="is_collected" widget="boolean_toggle"/>
|
<field name="is_collected" widget="boolean_toggle" nolabel="1"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue