[ADD] product_public_category_from_internal: mirror internal categories into eCommerce ones

Contextual server action on product.category that reproduces the internal
category tree as product.public.category records and tags every product
template with the public category matching its own categ_id.

Ancestors of the selected categories are pulled in so a branch is never
orphaned at the root, and categories are walked in parent_path order so the
public parent always exists before its children. Matching existing public
categories by name + parent keeps the action idempotent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-08-17 19:42:01 +02:00
parent e625b0c2f3
commit 5766406b6f
4 changed files with 144 additions and 0 deletions

View file

@ -0,0 +1,42 @@
# eCommerce Categories From Internal Categories
Adds a contextual server action **Create eCommerce categories** to the Product
Categories (`product.category`) list and form views.
It mirrors the internal category tree into the eCommerce categories
(`product.public.category`) and tags each product template with the public
category matching its internal one.
## Usage
1. Go to *Inventory → Configuration → Product Categories* (list view).
2. Select the categories you want, or select none to process them all.
3. *Actions → Create eCommerce categories*.
For each internal category the action:
- pulls in its ancestors, so the whole branch is reproduced;
- looks for an eCommerce category with the same name under the same
(already mirrored) parent, and creates it if it does not exist;
- adds that eCommerce category to every product template whose `categ_id`
is the internal one.
A notification reports categories processed / created / reused and how many
products were tagged.
## Notes
- **Idempotent**: running it again matches the existing eCommerce categories by
name + parent and only tags the products that are still missing the link.
- Products keep any eCommerce category they already had (`Command.link`). To
make the internal category the *only* eCommerce category of the product,
swap that line in `data/server_action.xml` for `Command.set(public.ids)`.
- Only active products are tagged (`search` filters archived records out).
- The created categories have no `website_id`, so they are shared by every
website. Set it afterwards if you run a multi-website setup.
- The action is data, not code: once installed you can edit it live from
*Settings → Technical → Actions → Server Actions* without touching the addon.
## Requirements
Depends on `website_sale` (which defines `product.public.category`).

View file

@ -0,0 +1,14 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "eCommerce Categories From Internal Categories",
"summary": "Contextual action that mirrors the internal product categories "
"into the eCommerce categories and tags each product with its match.",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"author": "Criptomart",
"depends": ["website_sale"],
"data": [
"data/server_action.xml",
],
}

View file

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="action_public_category_from_internal" model="ir.actions.server">
<field name="name">Create eCommerce categories</field>
<field name="model_id" ref="product.model_product_category" />
<field name="binding_model_id" ref="product.model_product_category" />
<field name="binding_view_types">list,form</field>
<field name="state">code</field>
<field name="code"><![CDATA[
# Mirror the internal product categories (product.category) into the eCommerce
# categories (product.public.category), keeping the same tree, and tag every
# product template with the public category matching its internal one.
# Run it from Inventory > Configuration > Product Categories: select the
# categories you want, or select none to process them all.
InternalCategory = env["product.category"]
PublicCategory = env["product.public.category"]
Template = env["product.template"]
categories = records or record or InternalCategory.search([])
# Pull in the ancestors too, otherwise a selected child category would end up
# at the root of the eCommerce tree instead of under its own parent.
while True:
missing = categories.parent_id - categories
if not missing:
break
categories |= missing
# parent_path ("1/5/9/") sorts every parent before its children, so the public
# parent always exists by the time we create its children.
categories = categories.sorted(lambda category: category.parent_path or "")
public_by_internal = {}
created = 0
reused = 0
for category in categories:
parent = public_by_internal.get(category.parent_id.id)
parent_id = parent.id if parent else False
public = PublicCategory.search(
[("name", "=", category.name), ("parent_id", "=", parent_id)],
limit=1,
)
if public:
reused += 1
else:
public = PublicCategory.create({
"name": category.name,
"parent_id": parent_id,
})
created += 1
public_by_internal[category.id] = public
tagged = 0
for internal_id, public in public_by_internal.items():
templates = Template.search([
("categ_id", "=", internal_id),
("public_categ_ids", "not in", public.ids),
])
if not templates:
continue
# Command.link adds the category and keeps the ones already set. Replace it
# with Command.set(public.ids) if you want the internal category to be the
# only eCommerce category of the product.
templates.write({"public_categ_ids": [Command.link(public.id)]})
tagged += len(templates)
message = (
"Categories processed: %s\n"
"eCommerce categories created: %s\n"
"eCommerce categories already existing: %s\n"
"Products tagged: %s"
) % (len(categories), created, reused, tagged)
action = {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": "eCommerce categories",
"message": message,
"type": "success" if created or tagged else "warning",
"sticky": False,
},
}
]]></field>
</record>
</odoo>