Search endpoint now queries product.template instead of product.product, so a product with 20 variants shows as 1 row with a "20 variants" badge instead of 20 duplicate rows. Category name shown in sub-text. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
240 lines
9.2 KiB
Python
240 lines
9.2 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
|
|
import logging
|
|
|
|
from odoo import http
|
|
from odoo.http import request
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WooProductSearchController(http.Controller):
|
|
"""AJAX search endpoints used by the product mapping UI."""
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Endpoints
|
|
# -------------------------------------------------------------------------
|
|
|
|
@http.route(
|
|
'/woo/search/odoo_products',
|
|
type='jsonrpc', auth='user', methods=['POST'],
|
|
)
|
|
def search_odoo_products(self, query='', instance_id=None, limit=20, offset=0,
|
|
category_id=None, exclude_category_ids=None,
|
|
apply_excluded=False, **kw):
|
|
"""
|
|
Search Odoo products by name or internal reference (SKU).
|
|
|
|
Params:
|
|
query (str): Search string matched against name and default_code.
|
|
instance_id (int): woo.instance ID (used for future per-instance filtering).
|
|
limit (int): Max results to return (default 20).
|
|
offset (int): Offset for pagination (default 0).
|
|
category_id (int): Filter by Odoo product category.
|
|
exclude_category_ids (list): Exclude these category IDs.
|
|
|
|
Returns:
|
|
dict with 'results' list and 'total' count
|
|
"""
|
|
limit = min(int(limit or 20), 100)
|
|
offset = int(offset or 0)
|
|
domain = []
|
|
|
|
if query:
|
|
domain = [
|
|
'|',
|
|
('name', 'ilike', query),
|
|
('default_code', 'ilike', query),
|
|
]
|
|
|
|
if category_id:
|
|
domain.append(('categ_id', '=', int(category_id)))
|
|
|
|
if exclude_category_ids:
|
|
if isinstance(exclude_category_ids, str):
|
|
import json as _json
|
|
try:
|
|
exclude_category_ids = _json.loads(exclude_category_ids)
|
|
except (ValueError, TypeError):
|
|
exclude_category_ids = []
|
|
if exclude_category_ids:
|
|
domain.append(('categ_id', 'not in', [int(x) for x in exclude_category_ids]))
|
|
|
|
# Apply instance-level excluded categories
|
|
if apply_excluded and instance_id:
|
|
instance = request.env['woo.instance'].browse(int(instance_id))
|
|
if instance.exists() and instance.excluded_category_ids:
|
|
domain.append(('categ_id', 'not in', instance.excluded_category_ids.ids))
|
|
|
|
# Search product.template to group variants together
|
|
tmpl_domain = []
|
|
if query:
|
|
tmpl_domain = [
|
|
'|',
|
|
('name', 'ilike', query),
|
|
('default_code', 'ilike', query),
|
|
]
|
|
if category_id:
|
|
tmpl_domain.append(('categ_id', '=', int(category_id)))
|
|
if exclude_category_ids:
|
|
if isinstance(exclude_category_ids, str):
|
|
import json as _json2
|
|
try:
|
|
exclude_category_ids = _json2.loads(exclude_category_ids)
|
|
except (ValueError, TypeError):
|
|
exclude_category_ids = []
|
|
if exclude_category_ids:
|
|
tmpl_domain.append(('categ_id', 'not in', [int(x) for x in exclude_category_ids]))
|
|
if apply_excluded and instance_id:
|
|
instance = request.env['woo.instance'].browse(int(instance_id))
|
|
if instance.exists() and instance.excluded_category_ids:
|
|
tmpl_domain.append(('categ_id', 'not in', instance.excluded_category_ids.ids))
|
|
|
|
total = request.env['product.template'].search_count(tmpl_domain)
|
|
templates = request.env['product.template'].search(tmpl_domain, limit=limit, offset=offset)
|
|
|
|
results = []
|
|
for tmpl in templates:
|
|
variant_count = len(tmpl.product_variant_ids)
|
|
# Use first variant as representative
|
|
first_variant = tmpl.product_variant_ids[:1]
|
|
results.append({
|
|
'id': first_variant.id if first_variant else tmpl.id,
|
|
'template_id': tmpl.id,
|
|
'name': tmpl.name,
|
|
'default_code': tmpl.default_code or '',
|
|
'list_price': tmpl.list_price,
|
|
'qty_available': sum(tmpl.product_variant_ids.mapped('qty_available')),
|
|
'categ_name': tmpl.categ_id.name if tmpl.categ_id else '',
|
|
'variant_count': variant_count,
|
|
'has_variants': variant_count > 1,
|
|
})
|
|
|
|
return {
|
|
'results': results,
|
|
'total': total,
|
|
}
|
|
|
|
@http.route(
|
|
'/woo/search/woo_products',
|
|
type='jsonrpc', auth='user', methods=['POST'],
|
|
)
|
|
def search_woo_products(self, query='', instance_id=None, limit=20, offset=0, **kw):
|
|
"""
|
|
Search unmapped WooCommerce products from the woo.product.map model.
|
|
|
|
Params:
|
|
query (str): Search string matched against woo_product_name and woo_sku.
|
|
instance_id (int): woo.instance ID — filters results to this instance.
|
|
limit (int): Max results to return (default 20).
|
|
offset (int): Offset for pagination (default 0).
|
|
|
|
Returns:
|
|
dict with 'results' list and 'total' count
|
|
"""
|
|
limit = min(int(limit or 20), 100)
|
|
offset = int(offset or 0)
|
|
domain = [('state', '=', 'unmapped')]
|
|
|
|
if instance_id:
|
|
domain.append(('instance_id', '=', int(instance_id)))
|
|
|
|
if query:
|
|
domain += [
|
|
'|',
|
|
('woo_product_name', 'ilike', query),
|
|
('woo_sku', 'ilike', query),
|
|
]
|
|
|
|
total = request.env['woo.product.map'].search_count(domain)
|
|
maps = request.env['woo.product.map'].search(domain, limit=limit, offset=offset)
|
|
|
|
return {
|
|
'results': [
|
|
{
|
|
'id': m.id,
|
|
'woo_product_id': m.woo_product_id,
|
|
'woo_product_name': m.woo_product_name or '',
|
|
'woo_sku': m.woo_sku or '',
|
|
'woo_product_type': m.woo_product_type or '',
|
|
}
|
|
for m in maps
|
|
],
|
|
'total': total,
|
|
}
|
|
|
|
@http.route(
|
|
'/woo/search/odoo_categories',
|
|
type='jsonrpc', auth='user', methods=['POST'],
|
|
)
|
|
def get_odoo_categories(self, **kw):
|
|
"""Return all Odoo product categories for filtering."""
|
|
categories = request.env['product.category'].search([], order='complete_name')
|
|
return [
|
|
{'id': c.id, 'name': c.name, 'complete_name': c.complete_name}
|
|
for c in categories
|
|
]
|
|
|
|
@http.route(
|
|
'/woo/search/mapped',
|
|
type='jsonrpc', auth='user', methods=['POST'],
|
|
)
|
|
def search_mapped(self, query='', instance_id=None, limit=20, offset=0, **kw):
|
|
"""
|
|
Search mapped WooCommerce ↔ Odoo product pairs.
|
|
|
|
Params:
|
|
query (str): Matched against woo_product_name, woo_sku, and linked product name.
|
|
instance_id (int): woo.instance ID — filters results to this instance.
|
|
limit (int): Max results to return (default 20).
|
|
offset (int): Offset for pagination (default 0).
|
|
|
|
Returns:
|
|
dict with 'results' list and 'total' count
|
|
"""
|
|
limit = min(int(limit or 20), 100)
|
|
offset = int(offset or 0)
|
|
domain = [('state', '=', 'mapped')]
|
|
|
|
if instance_id:
|
|
domain.append(('instance_id', '=', int(instance_id)))
|
|
|
|
if query:
|
|
domain += [
|
|
'|', '|',
|
|
('woo_product_name', 'ilike', query),
|
|
('woo_sku', 'ilike', query),
|
|
('product_id.name', 'ilike', query),
|
|
]
|
|
|
|
total = request.env['woo.product.map'].search_count(domain)
|
|
maps = request.env['woo.product.map'].search(domain, limit=limit, offset=offset)
|
|
|
|
return {
|
|
'results': [
|
|
{
|
|
'id': m.id,
|
|
'woo_product_id': m.woo_product_id,
|
|
'woo_product_name': m.woo_product_name or '',
|
|
'woo_sku': m.woo_sku or '',
|
|
'woo_product_type': m.woo_product_type or '',
|
|
'woo_permalink': m.woo_permalink or '',
|
|
'odoo_product_id': m.product_id.id if m.product_id else False,
|
|
'odoo_product_name': m.product_id.name if m.product_id else '',
|
|
'odoo_default_code': m.product_id.default_code or '' if m.product_id else '',
|
|
'odoo_price': m.product_id.list_price if m.product_id else 0.0,
|
|
'odoo_cost': m.product_id.standard_price if m.product_id else 0.0,
|
|
'woo_regular_price': m.woo_regular_price or 0.0,
|
|
'woo_sale_price': m.woo_sale_price or 0.0,
|
|
'sync_price': m.sync_price,
|
|
'sync_inventory': m.sync_inventory,
|
|
'instance_id': m.instance_id.id if m.instance_id else False,
|
|
'instance_name': m.instance_id.name if m.instance_id else '',
|
|
}
|
|
for m in maps
|
|
],
|
|
'total': total,
|
|
}
|