feat: add setup wizard and product fetch wizard with SKU auto-match

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-03-31 20:43:36 -04:00
parent 116c0b03bf
commit 102fbd65f2
7 changed files with 513 additions and 1 deletions

View File

@@ -29,6 +29,8 @@
'views/res_config_settings.xml',
'views/woo_dashboard.xml',
'views/woo_menus.xml',
'wizard/woo_setup_wizard_views.xml',
'wizard/woo_product_fetch_views.xml',
],
'assets': {
'web.assets_backend': [

View File

@@ -23,3 +23,5 @@ access_woo_return_user,woo.return.user,model_woo_return,fusion_woocommerce.group
access_woo_return_manager,woo.return.manager,model_woo_return,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_return_line_user,woo.return.line.user,model_woo_return_line,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_return_line_manager,woo.return.line.manager,model_woo_return_line,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_setup_wizard_manager,woo.setup.wizard.manager,model_woo_setup_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_product_fetch_manager,woo.product.fetch.manager,model_woo_product_fetch,fusion_woocommerce.group_woo_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
23 access_woo_return_manager woo.return.manager model_woo_return fusion_woocommerce.group_woo_manager 1 1 1 1
24 access_woo_return_line_user woo.return.line.user model_woo_return_line fusion_woocommerce.group_woo_user 1 0 0 0
25 access_woo_return_line_manager woo.return.line.manager model_woo_return_line fusion_woocommerce.group_woo_manager 1 1 1 1
26 access_woo_setup_wizard_manager woo.setup.wizard.manager model_woo_setup_wizard fusion_woocommerce.group_woo_manager 1 1 1 1
27 access_woo_product_fetch_manager woo.product.fetch.manager model_woo_product_fetch fusion_woocommerce.group_woo_manager 1 1 1 1

View File

@@ -1 +1,2 @@
# Wizards will be imported here
from . import woo_setup_wizard
from . import woo_product_fetch

View File

@@ -0,0 +1,171 @@
import difflib
import logging
from odoo import fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class WooProductFetch(models.TransientModel):
_name = 'woo.product.fetch'
_description = 'Fetch WooCommerce Products'
instance_id = fields.Many2one('woo.instance', required=True, string='WooCommerce Instance')
state = fields.Selection([
('draft', 'Ready'),
('fetching', 'Fetching...'),
('matching', 'Matching...'),
('done', 'Complete'),
], default='draft')
total_fetched = fields.Integer(readonly=True)
auto_matched = fields.Integer(string='Auto-matched (SKU)', readonly=True)
suggested = fields.Integer(string='Name Suggestions', readonly=True)
unmatched = fields.Integer(readonly=True)
def action_fetch(self):
self.ensure_one()
instance = self.instance_id
if not instance:
raise UserError("Please select a WooCommerce instance.")
client = instance._get_client()
# --- Fetch all products (paginated) ---
self.state = 'fetching'
all_products = []
page = 1
while True:
batch = client.get_products(page=page, per_page=100)
if not batch:
break
all_products.extend(batch)
if len(batch) < 100:
break
page += 1
# Expand variable products with their variations
expanded = []
for product in all_products:
expanded.append(product)
if product.get('type') == 'variable':
var_page = 1
while True:
variations = client.get_product_variations(
product['id'], page=var_page, per_page=100
)
if not variations:
break
for var in variations:
var['_parent_id'] = product['id']
var['_is_variation'] = True
expanded.extend(variations)
if len(variations) < 100:
break
var_page += 1
total_fetched = len(expanded)
# --- Match products ---
self.state = 'matching'
ProductMap = self.env['woo.product.map']
ProductProduct = self.env['product.product']
# Pre-load existing maps for this instance to avoid repeated queries
existing_woo_ids = set(
ProductMap.search([('instance_id', '=', instance.id)]).mapped('woo_product_id')
)
# Pre-load all odoo products with a SKU for fast lookup
odoo_products_with_sku = ProductProduct.search([('default_code', '!=', False)])
sku_index = {p.default_code: p for p in odoo_products_with_sku}
# For name matching, load all product names
all_odoo_products = ProductProduct.search([])
odoo_name_list = [(p.name or '', p) for p in all_odoo_products]
auto_matched = 0
suggested_count = 0
unmatched = 0
for wc_product in expanded:
woo_id = wc_product.get('id')
if not woo_id:
continue
# Skip already-mapped
if woo_id in existing_woo_ids:
continue
woo_sku = wc_product.get('sku') or ''
woo_name = wc_product.get('name') or ''
woo_type = wc_product.get('type', 'simple')
is_variation = wc_product.get('_is_variation', False)
parent_id = wc_product.get('_parent_id') or wc_product.get('parent_id') or 0
map_vals = {
'instance_id': instance.id,
'woo_product_id': woo_id,
'woo_product_name': woo_name,
'woo_sku': woo_sku,
'woo_product_type': woo_type if not is_variation else 'variable',
'is_variation': is_variation,
'woo_parent_id': parent_id,
'company_id': instance.company_id.id,
}
# a) SKU match
matched_product = None
if woo_sku and woo_sku in sku_index:
matched_product = sku_index[woo_sku]
map_vals['product_id'] = matched_product.id
map_vals['state'] = 'mapped'
auto_matched += 1
else:
# b) Name similarity
if woo_name and odoo_name_list:
ratios = [
(difflib.SequenceMatcher(None, woo_name.lower(), name.lower()).ratio(), p)
for name, p in odoo_name_list
]
best_ratio, best_product = max(ratios, key=lambda x: x[0])
if best_ratio > 0.8:
map_vals['product_id'] = best_product.id
map_vals['state'] = 'unmapped'
suggested_count += 1
else:
map_vals['state'] = 'unmapped'
unmatched += 1
else:
map_vals['state'] = 'unmapped'
unmatched += 1
ProductMap.create(map_vals)
self.write({
'total_fetched': total_fetched,
'auto_matched': auto_matched,
'suggested': suggested_count,
'unmatched': unmatched,
'state': 'done',
})
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_open_mapping(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Product Mapping',
'res_model': 'woo.product.map',
'view_mode': 'list,form',
'domain': [('instance_id', '=', self.instance_id.id)],
'target': 'current',
}

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="woo_product_fetch_form_view" model="ir.ui.view">
<field name="name">woo.product.fetch.form</field>
<field name="model">woo.product.fetch</field>
<field name="arch" type="xml">
<form string="Fetch WooCommerce Products">
<sheet>
<group>
<field name="instance_id"
attrs="{'readonly': [('state', '!=', 'draft')]}"/>
<field name="state" readonly="1"/>
</group>
<group string="Results"
attrs="{'invisible': [('state', '=', 'draft')]}">
<group>
<field name="total_fetched"/>
<field name="auto_matched"/>
</group>
<group>
<field name="suggested"/>
<field name="unmatched"/>
</group>
</group>
<div class="alert alert-info" role="alert"
attrs="{'invisible': [('state', '!=', 'draft')]}">
Select a WooCommerce instance and click Fetch Products. All products and variations will be imported and matched to Odoo products by SKU or name similarity.
</div>
<div class="alert alert-success" role="alert"
attrs="{'invisible': [('state', '!=', 'done')]}">
Fetch complete! Review the product mapping to confirm or adjust suggestions.
</div>
</sheet>
<footer>
<button type="object" name="action_fetch"
string="Fetch Products"
class="btn-primary"
attrs="{'invisible': [('state', '!=', 'draft')]}"/>
<button type="object" name="action_open_mapping"
string="Open Product Mapping"
class="btn-primary"
attrs="{'invisible': [('state', '!=', 'done')]}"/>
<button string="Close" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_woo_product_fetch" model="ir.actions.act_window">
<field name="name">Fetch WooCommerce Products</field>
<field name="res_model">woo.product.fetch</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,145 @@
import secrets
import logging
from odoo import api, fields, models
from odoo.exceptions import UserError
from ..lib.woo_api_client import WooApiClient
_logger = logging.getLogger(__name__)
class WooSetupWizard(models.TransientModel):
_name = 'woo.setup.wizard'
_description = 'WooCommerce Setup Wizard'
# Step 1: Connection
name = fields.Char(string='Instance Name', required=True)
url = fields.Char(string='WooCommerce URL', required=True)
consumer_key = fields.Char(string='Consumer Key', required=True)
consumer_secret = fields.Char(string='Consumer Secret', required=True)
# Step 2: Configuration
default_warehouse_id = fields.Many2one('stock.warehouse', string='Default Warehouse')
sync_interval = fields.Selection([
('5', '5 Minutes'),
('15', '15 Minutes'),
('30', '30 Minutes'),
('60', '1 Hour'),
], string='Sync Interval', default='15')
# State tracking
step = fields.Selection([
('connection', 'Connection'),
('config', 'Configuration'),
('done', 'Done'),
], default='connection')
connection_tested = fields.Boolean()
instance_id = fields.Many2one('woo.instance')
api_key = fields.Char(string='Generated API Key', readonly=True)
def action_test_connection(self):
self.ensure_one()
try:
client = WooApiClient(
url=self.url,
consumer_key=self.consumer_key,
consumer_secret=self.consumer_secret,
)
success, info = client.test_connection()
if success:
self.connection_tested = True
else:
raise UserError(f"Connection failed: {info}")
except UserError:
raise
except Exception as exc:
raise UserError(f"Connection error: {exc}") from exc
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_next_step(self):
self.ensure_one()
if self.step == 'connection':
if not self.connection_tested:
raise UserError("Please test the connection before proceeding.")
self.step = 'config'
elif self.step == 'config':
self.step = 'done'
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_back(self):
self.ensure_one()
if self.step == 'config':
self.step = 'connection'
elif self.step == 'done':
self.step = 'config'
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_complete(self):
self.ensure_one()
api_key = secrets.token_urlsafe(32)
instance = self.env['woo.instance'].create({
'name': self.name,
'url': self.url,
'consumer_key': self.consumer_key,
'consumer_secret': self.consumer_secret,
'default_warehouse_id': self.default_warehouse_id.id,
'sync_interval': self.sync_interval,
'odoo_api_key': api_key,
'state': 'connected',
})
self.instance_id = instance
self.api_key = api_key
self.step = 'done'
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_open_instance(self):
self.ensure_one()
if not self.instance_id:
raise UserError("No instance created yet.")
return {
'type': 'ir.actions.act_window',
'res_model': 'woo.instance',
'res_id': self.instance_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_fetch_products(self):
self.ensure_one()
if not self.instance_id:
raise UserError("No instance created yet.")
wizard = self.env['woo.product.fetch'].create({
'instance_id': self.instance_id.id,
})
return {
'type': 'ir.actions.act_window',
'res_model': 'woo.product.fetch',
'res_id': wizard.id,
'view_mode': 'form',
'target': 'new',
}

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="woo_setup_wizard_form_view" model="ir.ui.view">
<field name="name">woo.setup.wizard.form</field>
<field name="model">woo.setup.wizard</field>
<field name="arch" type="xml">
<form string="WooCommerce Setup Wizard">
<sheet>
<!-- Step indicator -->
<div class="o_statusbar_status mb-3">
<button type="object" name="action_back"
class="btn btn-secondary me-1"
attrs="{'invisible': [('step', '=', 'connection')]}">
&#8592; Back
</button>
<span class="badge bg-primary me-1"
attrs="{'invisible': [('step', '!=', 'connection')]}">
Step 1: Connection
</span>
<span class="badge bg-secondary me-1"
attrs="{'invisible': [('step', '=', 'connection')]}">
Step 1: Connection &#10003;
</span>
<span class="badge bg-primary me-1"
attrs="{'invisible': [('step', '!=', 'config')]}">
Step 2: Configuration
</span>
<span class="badge bg-secondary me-1"
attrs="{'invisible': [('step', 'in', ('connection', 'config'))]}">
Step 2: Configuration &#10003;
</span>
<span class="badge bg-muted me-1"
attrs="{'invisible': [('step', 'in', ('config', 'done'))]}">
Step 2: Configuration
</span>
<span class="badge bg-primary me-1"
attrs="{'invisible': [('step', '!=', 'done')]}">
Step 3: Done
</span>
<span class="badge bg-muted me-1"
attrs="{'invisible': [('step', '=', 'done')]}">
Step 3: Done
</span>
</div>
<!-- Step 1: Connection -->
<group attrs="{'invisible': [('step', '!=', 'connection')]}">
<group string="WooCommerce Connection">
<field name="name" placeholder="e.g. My WooCommerce Store"/>
<field name="url" placeholder="https://mystore.com"/>
<field name="consumer_key" password="True"/>
<field name="consumer_secret" password="True"/>
</group>
<group>
<div class="alert alert-info" role="alert"
attrs="{'invisible': [('connection_tested', '=', True)]}">
Enter your WooCommerce store URL and REST API credentials, then test the connection.
</div>
<div class="alert alert-success" role="alert"
attrs="{'invisible': [('connection_tested', '=', False)]}">
Connection successful! Click Next to continue.
</div>
<field name="connection_tested" invisible="1"/>
<field name="step" invisible="1"/>
</group>
</group>
<!-- Step 2: Configuration -->
<group attrs="{'invisible': [('step', '!=', 'config')]}">
<group string="Sync Configuration">
<field name="default_warehouse_id"/>
<field name="sync_interval"/>
</group>
</group>
<!-- Step 3: Done -->
<group attrs="{'invisible': [('step', '!=', 'done')]}">
<group string="Setup Complete">
<div class="alert alert-success" role="alert">
<strong>Your WooCommerce instance has been created successfully!</strong>
<br/>Save the API key below — it is used to authenticate incoming webhooks from WooCommerce.
</div>
<field name="api_key" readonly="1"/>
<field name="instance_id" readonly="1"/>
</group>
</group>
</sheet>
<footer>
<!-- Connection step buttons -->
<button type="object" name="action_test_connection"
string="Test Connection"
class="btn-primary"
attrs="{'invisible': [('step', '!=', 'connection')]}"/>
<button type="object" name="action_next_step"
string="Next"
class="btn-primary"
attrs="{'invisible': [('step', '!=', 'connection')]}"/>
<!-- Config step buttons -->
<button type="object" name="action_complete"
string="Complete Setup"
class="btn-primary"
attrs="{'invisible': [('step', '!=', 'config')]}"/>
<!-- Done step buttons -->
<button type="object" name="action_fetch_products"
string="Fetch Products"
class="btn-primary"
attrs="{'invisible': [('step', '!=', 'done')]}"/>
<button type="object" name="action_open_instance"
string="Open Instance"
class="btn-secondary"
attrs="{'invisible': [('step', '!=', 'done')]}"/>
<button string="Close" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_woo_setup_wizard" model="ir.actions.act_window">
<field name="name">WooCommerce Setup Wizard</field>
<field name="res_model">woo.setup.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>