Split 49 modules/suites into independent git repos; untrack from monorepo
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled

Each top-level module/suite folder is now its own private repo on GitHub
(gsinghpal/<name>) and gitea (admin/<name>), with a fresh single initial
commit. The monorepo no longer tracks them (added to .gitignore + git rm
--cached); working-tree files are retained on disk and managed in their
own repos. The monorepo keeps shared root files (CLAUDE.md, docs/, scripts/,
tools/, AGENTS.md, WIP/obsolete dirs) and full history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-07 01:54:34 -04:00
parent 2a7b315e98
commit a66cdefc01
6740 changed files with 51 additions and 1277207 deletions

View File

@@ -1,70 +0,0 @@
# Fusion WooCommerce — Odoo Module
## What This Is
Bidirectional Odoo 19 ↔ WooCommerce sync module. The "brain" — all sync logic, product mapping, scheduling, conflict resolution, and dashboards live here. Pairs with the `fusion-woodoo` WordPress plugin (thin display layer).
## Architecture
- **Odoo calls WC** via REST API v3 (consumer key/secret auth)
- **WC calls Odoo** via webhooks (HMAC signature auth) and the WP plugin calls custom endpoints (bearer token auth)
- **Hybrid sync**: real-time webhooks + scheduled cron fallback + manual "Sync Now"
## Critical Odoo 19 Rules
1. **Views**: `<list>` NOT `<tree>`. `view_mode` uses `list,form` NOT `tree,form`.
2. **Search views**: NO `<group expand="...">` wrapper — use bare `<filter>` with `<separator/>`.
3. **No `attrs`**: Use inline `invisible="..."` / `readonly="..."` / `required="..."` directly.
4. **No `states`**: Use `invisible="state != 'draft'"` instead.
5. **HTTP routes**: `type="jsonrpc"` for internal. `type="http"` with `csrf=False` for WC webhooks (WC sends raw JSON, not JSON-RPC).
6. **OWL**: standalone `rpc()` from `@web/core/network/rpc`. `static props = []`. No globals like `Number()` in templates — use component methods.
7. **res.groups**: NO `category_id` field.
8. **API client in `lib/`** NOT `models/` — plain Python class, not an Odoo model.
## Key Files
```
lib/woo_api_client.py — WC REST API wrapper (NOT an Odoo model)
models/woo_instance.py — Core: connection config, sync methods, cron entry points
models/woo_product_map.py — Product mapping + price sync methods
models/woo_order.py — Order tracking + status push methods
controllers/webhook.py — Receives WC webhooks (type="http", HMAC auth)
controllers/api.py — REST endpoints for WP plugin (type="jsonrpc", bearer auth)
controllers/product_search.py — AJAX search for OWL mapping UI
static/src/js/product_mapping.js — OWL product mapping client action
static/src/js/dashboard.js — OWL dashboard client action
static/src/js/theme_detect.js — Reads color_scheme cookie, sets data-woo-theme on <html>
static/src/css/woo_styles.css — Theme-aware CSS using var(--woo-*) custom properties
```
## Dark Mode
- Odoo 19 stores dark mode in `color_scheme` cookie ("dark" or "bright")
- `theme_detect.js` reads the cookie and sets `data-woo-theme="dark"` on `<html>`
- All CSS uses `var(--woo-*)` custom properties with dark overrides under `html[data-woo-theme="dark"]`
- NEVER use hardcoded colours in templates — use `woo-badge-*`, `woo-text-muted`, `woo-code` classes
- NEVER use Bootstrap colour classes (`bg-primary`, `text-dark`, etc.) in OWL templates
## WC Pricing Model
- WooCommerce has two prices: `regular_price` (standard) and `sale_price`
- Stored as `woo_regular_price` and `woo_sale_price` on `woo.product.map`
- Sync logic: if standard price is zero → sync sets regular_price. If standard exists → sync sets sale_price
- Standard price can never be less than sale price — validation enforced
## Deployment
```bash
# Deploy to westin
ssh odoo-westin "rm -rf /opt/odoo/custom-addons/fusion_woocommerce"
scp -r fusion-woo-odoo/fusion_woocommerce odoo-westin:/opt/odoo/custom-addons/fusion_woocommerce
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_woocommerce --stop-after-init --no-http"
# IMPORTANT: Clear asset cache after JS/CSS changes
ssh odoo-westin "docker exec odoo-dev-db psql -U odoo -d westin-v19 -c \"DELETE FROM ir_attachment WHERE name LIKE '%assets_backend%' OR url LIKE '%/web/assets%';\""
ssh odoo-westin "docker restart odoo-dev-app"
```
## Gotchas
- Health check cron only logs — never changes instance state (Docker can't reach external URLs)
- Product map state stays `mapped` even on sync errors/conflicts — conflicts tracked separately in `woo.conflict`
- Search endpoints return `{"results": [...], "total": count}` for pagination
- Existing products are skipped during fetch (dedup by `woo_product_id` + `instance_id`)
- After adding new DB fields, must run `-u fusion_woocommerce` before backfilling data
## Spec & Plan
- Design spec: `docs/superpowers/specs/2026-03-31-fusion-woo-odoo-design.md`
- Implementation plan: `docs/superpowers/plans/2026-03-31-fusion-woo-odoo-plan.md`

View File

@@ -1,4 +0,0 @@
from . import models
from . import controllers
from . import wizard
from . import lib

View File

@@ -1,53 +0,0 @@
{
'name': 'Fusion WooCommerce',
'version': '19.0.3.0.0',
'category': 'Sales',
'summary': 'Bidirectional WooCommerce \u2194 Odoo sync for products, orders, invoices, and inventory',
'description': 'Seamless integration between Odoo and WooCommerce. Sync products, prices, inventory, orders, invoices, customers, and documents bidirectionally.',
'author': 'Fusion Central',
'website': 'https://fusionsoft.ca',
'license': 'LGPL-3',
'depends': ['sale_management', 'stock', 'account', 'contacts', 'mail'],
'data': [
'security/woo_security.xml',
'security/ir.model.access.csv',
'data/shipping_carriers.xml',
'data/cron.xml',
'data/mail_template.xml',
'views/woo_instance_views.xml',
'views/woo_category_map_views.xml',
'views/woo_product_map_views.xml',
'views/woo_order_views.xml',
'views/woo_sync_log_views.xml',
'views/woo_conflict_views.xml',
'views/woo_customer_views.xml',
'views/woo_return_views.xml',
'views/woo_tax_map_views.xml',
'views/woo_pricelist_map_views.xml',
'views/woo_shipping_carrier_views.xml',
'views/sale_order_views.xml',
'views/stock_picking_views.xml',
'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',
'wizard/woo_product_create_views.xml',
'wizard/woo_category_filter_views.xml',
'wizard/woo_variant_push_views.xml',
],
'assets': {
'web.assets_backend': [
'fusion_woocommerce/static/src/css/woo_styles.css',
'fusion_woocommerce/static/src/js/ajax_search.js',
'fusion_woocommerce/static/src/js/product_mapping.js',
'fusion_woocommerce/static/src/js/dashboard.js',
'fusion_woocommerce/static/src/xml/product_mapping.xml',
'fusion_woocommerce/static/src/xml/dashboard.xml',
],
},
'images': ['static/description/icon.png'],
'installable': True,
'application': True,
'auto_install': False,
}

View File

@@ -1,3 +0,0 @@
from . import webhook
from . import api
from . import product_search

View File

@@ -1,344 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import logging
from odoo import http
from odoo.exceptions import AccessDenied
from odoo.http import request, Response
_logger = logging.getLogger(__name__)
class WooApiController(http.Controller):
"""REST endpoints consumed by the WooCommerce WordPress plugin."""
@http.route('/woo/image/<int:line_id>/<string:filename>',
type='http', auth='none', csrf=False, methods=['GET'])
def serve_variant_image(self, line_id, filename, **kw):
"""Serve a variant image from the transient wizard line.
Used by WC to download images during variant push."""
try:
line = request.env['woo.variant.push.line'].sudo().browse(line_id)
if not line.exists() or not line.image:
_logger.warning("Image endpoint: line %d not found or no image", line_id)
return request.not_found()
img_data = line.image
# Odoo Binary fields always return base64 string
if isinstance(img_data, (str, bytes)):
if isinstance(img_data, bytes):
img_data = img_data.decode('utf-8')
img_data = base64.b64decode(img_data)
elif isinstance(img_data, memoryview):
# Raw bytea from DB — still base64 encoded by ORM
raw = bytes(img_data)
try:
img_data = base64.b64decode(raw)
except Exception:
img_data = raw
# Detect content type from magic bytes
content_type = 'image/png'
if img_data[:2] == b'\xff\xd8':
content_type = 'image/jpeg'
elif img_data[:4] == b'\x89PNG':
content_type = 'image/png'
elif img_data[:4] == b'GIF8':
content_type = 'image/gif'
elif img_data[:4] == b'RIFF':
content_type = 'image/webp'
_logger.info("Serving image for line %d: %d bytes, %s", line_id, len(img_data), content_type)
# Set extension-appropriate filename
ext = content_type.split('/')[-1]
if ext == 'jpeg':
ext = 'jpg'
return Response(
img_data,
content_type=content_type,
status=200,
headers={
'Content-Disposition': f'inline; filename="{filename.rsplit(".", 1)[0]}.{ext}"',
'Cache-Control': 'no-cache',
},
)
except Exception as e:
_logger.error("Failed to serve variant image %d: %s", line_id, e)
return request.not_found()
def _authenticate_instance(self):
"""
Validate Bearer token from Authorization header against woo.instance.odoo_api_key.
Returns the matching woo.instance or raises AccessDenied.
"""
auth_header = request.httprequest.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
raise AccessDenied()
api_key = auth_header[len('Bearer '):]
if not api_key:
raise AccessDenied()
instance = request.env['woo.instance'].sudo().search([
('odoo_api_key', '=', api_key),
], limit=1)
if not instance:
raise AccessDenied()
return instance
def _find_woo_order(self, instance, order_id):
"""Look up a woo.order by WC order ID for a given instance."""
return request.env['woo.order'].sudo().search([
('instance_id', '=', instance.id),
('woo_order_id', '=', int(order_id)),
], limit=1)
# -------------------------------------------------------------------------
# Endpoints
# -------------------------------------------------------------------------
@http.route(
'/woo/api/order/documents',
type='jsonrpc', auth='none', csrf=False, methods=['POST'],
)
def order_documents(self, order_id=None, **kw):
"""
Fetch invoice and delivery PDF URLs for a WooCommerce order.
Expected payload: {"order_id": <woo_order_id>}
Returns: {"invoices": [...], "deliveries": [...]}
"""
try:
instance = self._authenticate_instance()
except AccessDenied:
return {'error': 'Unauthorized', 'code': 401}
if not order_id:
return {'error': 'order_id is required', 'code': 400}
woo_order = self._find_woo_order(instance, order_id)
if not woo_order:
return {'order_id': order_id, 'invoices': [], 'deliveries': []}
invoices = []
if woo_order.invoice_id:
inv = woo_order.invoice_id
invoices.append({
'id': inv.id,
'name': inv.name,
'state': inv.state,
'amount_total': inv.amount_total,
'date': str(inv.invoice_date) if inv.invoice_date else '',
})
deliveries = []
if woo_order.sale_order_id:
pickings = request.env['stock.picking'].sudo().search([
('origin', '=', woo_order.sale_order_id.name),
('picking_type_code', '=', 'outgoing'),
])
for picking in pickings:
deliveries.append({
'id': picking.id,
'name': picking.name,
'state': picking.state,
'tracking_number': picking.woo_tracking_number or '',
'scheduled_date': str(picking.scheduled_date) if picking.scheduled_date else '',
})
return {
'order_id': order_id,
'invoices': invoices,
'deliveries': deliveries,
}
@http.route(
'/woo/api/order/status',
type='jsonrpc', auth='none', csrf=False, methods=['POST'],
)
def order_status(self, order_id=None, **kw):
"""
Fetch order status and timeline data for a WooCommerce order.
Expected payload: {"order_id": <woo_order_id>}
Returns: {"status": ..., "timeline": [...]}
"""
try:
instance = self._authenticate_instance()
except AccessDenied:
return {'error': 'Unauthorized', 'code': 401}
if not order_id:
return {'error': 'order_id is required', 'code': 400}
woo_order = self._find_woo_order(instance, order_id)
if not woo_order:
return {
'order_id': order_id,
'status': None,
'odoo_state': None,
'timeline': [],
}
# Build timeline from tracking messages
timeline = []
if woo_order.sale_order_id:
messages = request.env['mail.message'].sudo().search([
('res_id', '=', woo_order.sale_order_id.id),
('model', '=', 'sale.order'),
], order='create_date asc')
for msg in messages:
timeline.append({
'date': str(msg.create_date),
'type': msg.message_type,
'body': msg.preview or '',
})
# Add shipment events
for shipment in woo_order.shipment_ids:
timeline.append({
'date': str(shipment.shipped_date) if shipment.shipped_date else '',
'type': 'shipment',
'body': f'Shipped via {shipment.carrier_id.name if shipment.carrier_id else "carrier"} — tracking: {shipment.tracking_number or "N/A"}',
})
return {
'order_id': order_id,
'status': woo_order.woo_status,
'odoo_state': woo_order.state,
'odoo_order_ref': woo_order.sale_order_id.name if woo_order.sale_order_id else '',
'timeline': timeline,
}
@http.route(
'/woo/api/order/messages',
type='jsonrpc', auth='none', csrf=False, methods=['POST'],
)
def order_messages(self, order_id=None, **kw):
"""
Fetch customer-visible messages for a WooCommerce order.
Expected payload: {"order_id": <woo_order_id>}
Returns: {"messages": [...]}
"""
try:
instance = self._authenticate_instance()
except AccessDenied:
return {'error': 'Unauthorized', 'code': 401}
if not order_id:
return {'error': 'order_id is required', 'code': 400}
woo_order = self._find_woo_order(instance, order_id)
if not woo_order or not woo_order.sale_order_id:
return {'order_id': order_id, 'messages': []}
# Get customer-visible messages
messages = request.env['mail.message'].sudo().search([
('res_id', '=', woo_order.sale_order_id.id),
('model', '=', 'sale.order'),
('message_type', 'in', ['comment', 'email']),
('subtype_id.internal', '=', False),
], order='create_date asc')
result = []
for msg in messages:
result.append({
'date': str(msg.create_date),
'author': msg.author_id.name if msg.author_id else '',
'body': msg.body or '',
})
return {
'order_id': order_id,
'messages': result,
}
@http.route(
'/woo/api/return/create',
type='jsonrpc', auth='none', csrf=False, methods=['POST'],
)
def return_create(self, order_id=None, reason=None, items=None, **kw):
"""
Submit a return request from the WooCommerce plugin.
Expected payload: {
"order_id": <woo_order_id>,
"reason": "<return reason>",
"items": [{"product_id": ..., "quantity": ..., "reason": ...}, ...]
}
Returns: {"success": True, "return_id": <id>} or {"error": ...}
"""
try:
instance = self._authenticate_instance()
except AccessDenied:
return {'error': 'Unauthorized', 'code': 401}
if not order_id:
return {'error': 'order_id is required', 'code': 400}
woo_order = self._find_woo_order(instance, order_id)
if not woo_order:
return {'error': 'Order not found', 'code': 404}
items = items or []
if not items:
return {'error': 'At least one return item is required', 'code': 400}
# Create woo.return
woo_return = request.env['woo.return'].sudo().create({
'instance_id': instance.id,
'order_id': woo_order.id,
'reason': reason or '',
'company_id': instance.company_id.id,
})
# Create return lines
for item in items:
wc_product_id = item.get('product_id')
quantity = item.get('quantity', 1)
item_reason = item.get('reason', 'other')
# Find mapped Odoo product
product = False
if wc_product_id:
pm = request.env['woo.product.map'].sudo().search([
('instance_id', '=', instance.id),
('woo_product_id', '=', wc_product_id),
('state', '=', 'mapped'),
], limit=1)
if pm:
product = pm.product_id
if not product:
# Try SKU from the item
sku = item.get('sku', '')
if sku:
product = request.env['product.product'].sudo().search([
('default_code', '=', sku),
], limit=1)
if product:
request.env['woo.return.line'].sudo().create({
'return_id': woo_return.id,
'product_id': product.id,
'quantity': quantity,
'reason': item_reason if item_reason in dict(
request.env['woo.return.line']._fields['reason'].selection
) else 'other',
'company_id': instance.company_id.id,
})
instance._log_sync(
'order', 'woo_to_odoo',
woo_order.sale_order_id.name if woo_order.sale_order_id else f'WC#{order_id}',
'success', f'Return request created with {len(items)} item(s)',
)
return {
'success': True,
'return_id': woo_return.id,
'message': 'Return request received.',
}

View File

@@ -1,249 +0,0 @@
# -*- 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 '',
'woo_category_name': m.woo_category_name 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 '',
'is_variation': m.is_variation,
'odoo_variant_count': len(m.product_id.product_tmpl_id.product_variant_ids) if m.product_id else 0,
'wc_is_simple': (m.woo_product_type or 'simple') == 'simple' and not m.is_variation,
'needs_variant_push': (
m.product_id
and not m.is_variation
and (m.woo_product_type or 'simple') == 'simple'
and len(m.product_id.product_tmpl_id.product_variant_ids) > 1
),
}
for m in maps
],
'total': total,
}

View File

@@ -1,185 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import json
import logging
import time
from collections import defaultdict
from odoo import http
from odoo.http import request, Response
from ..lib.woo_api_client import WooApiClient
_logger = logging.getLogger(__name__)
def _normalize_url(url):
"""Strip trailing slashes and lowercase for comparison."""
return url.rstrip('/').lower() if url else ''
class WooWebhookController(http.Controller):
"""Receive inbound WooCommerce webhook deliveries."""
# Simple in-memory rate limiter: {ip: [(timestamp, ...),]}
_rate_tracker = defaultdict(list)
_RATE_LIMIT = 60 # max requests per minute (1/sec sustained)
_RATE_WINDOW = 60 # seconds
@classmethod
def _check_rate_limit(cls, ip):
"""Return True if the IP is within rate limits, False if exceeded."""
now = time.time()
cutoff = now - cls._RATE_WINDOW
# Clean old entries
cls._rate_tracker[ip] = [
ts for ts in cls._rate_tracker[ip] if ts > cutoff
]
if len(cls._rate_tracker[ip]) >= cls._RATE_LIMIT:
return False
cls._rate_tracker[ip].append(now)
return True
def _find_instance(self, source_url):
"""Find a woo.instance matching the webhook source URL."""
instances = request.env['woo.instance'].sudo().search([
('state', '!=', 'draft'),
])
norm_source = _normalize_url(source_url)
for inst in instances:
if _normalize_url(inst.url) == norm_source:
return inst
return None
def _handle_ping(self, topic):
"""Return 200 for WooCommerce webhook test deliveries."""
_logger.info("WooCommerce webhook ping received. Topic: %s", topic)
return Response('OK', status=200)
def _verify_and_dispatch(self, dispatch_method):
"""
Common handler for all webhook endpoints.
- Rate limits by IP
- Detects ping
- Finds instance by source URL
- Verifies HMAC signature
- Delegates to dispatch_method(instance, data)
Returns a Response.
"""
# Rate limiting
remote_ip = request.httprequest.remote_addr or 'unknown'
if not self._check_rate_limit(remote_ip):
_logger.warning("Rate limit exceeded for IP %s", remote_ip)
return Response('Too Many Requests', status=429)
headers = request.httprequest.headers
topic = headers.get('X-WC-Webhook-Topic', '')
source_url = headers.get('X-WC-Webhook-Source', '')
signature = headers.get('X-WC-Webhook-Signature', '')
payload = request.httprequest.get_data()
# WooCommerce sends a test ping on webhook creation — body may be empty or minimal
if not payload or payload.strip() in (b'', b'{}', b'[]'):
return self._handle_ping(topic)
# Find matching instance
instance = self._find_instance(source_url)
if not instance:
_logger.warning(
"WooCommerce webhook: no matching instance for source URL '%s'", source_url
)
# Return 200 to prevent WooCommerce from retrying indefinitely
return Response('No matching instance', status=200)
# Verify HMAC signature
if instance.webhook_secret:
if not WooApiClient.verify_webhook_signature(payload, signature, instance.webhook_secret):
_logger.warning(
"WooCommerce webhook: invalid signature for instance '%s'", instance.name
)
return Response('Unauthorized', status=401)
else:
_logger.warning(
"WooCommerce webhook: instance '%s' has no webhook_secret — skipping signature check",
instance.name,
)
# Parse JSON body
try:
data = json.loads(payload)
except (json.JSONDecodeError, ValueError) as exc:
_logger.error("WooCommerce webhook: invalid JSON body — %s", exc)
return Response('Bad Request', status=400)
# Dispatch to model method
try:
dispatch_method(instance, data, topic)
except Exception:
_logger.exception(
"WooCommerce webhook: error dispatching topic '%s' for instance '%s'",
topic, instance.name,
)
return Response('Internal Error', status=500)
return Response('OK', status=200)
# -------------------------------------------------------------------------
# Webhook Endpoints
# -------------------------------------------------------------------------
@http.route(
'/woo/webhook/order',
type='http', auth='none', csrf=False, methods=['POST'],
save_session=False,
)
def webhook_order(self, **kw):
"""Receive order.created / order.updated from WooCommerce."""
def dispatch(instance, data, topic):
_logger.info(
"WooCommerce order webhook received. Instance: %s, Topic: %s, Order ID: %s",
instance.name, topic, data.get('id'),
)
if hasattr(instance, '_sync_order_from_wc'):
instance._sync_order_from_wc(data)
return self._verify_and_dispatch(dispatch)
@http.route(
'/woo/webhook/product',
type='http', auth='none', csrf=False, methods=['POST'],
save_session=False,
)
def webhook_product(self, **kw):
"""Receive product.updated from WooCommerce."""
def dispatch(instance, data, topic):
_logger.info(
"WooCommerce product webhook received. Instance: %s, Topic: %s, Product ID: %s",
instance.name, topic, data.get('id'),
)
if hasattr(instance, '_sync_product_from_wc'):
instance._sync_product_from_wc(data)
return self._verify_and_dispatch(dispatch)
@http.route(
'/woo/webhook/customer',
type='http', auth='none', csrf=False, methods=['POST'],
save_session=False,
)
def webhook_customer(self, **kw):
"""Receive customer.created / customer.updated from WooCommerce."""
def dispatch(instance, data, topic):
_logger.info(
"WooCommerce customer webhook received. Instance: %s, Topic: %s, Customer ID: %s",
instance.name, topic, data.get('id'),
)
if hasattr(instance, '_sync_customer_from_wc'):
instance._sync_customer_from_wc(data)
return self._verify_and_dispatch(dispatch)

View File

@@ -1,68 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_woo_sync_products" model="ir.cron">
<field name="name">WooCommerce: Sync Products</field>
<field name="model_id" ref="model_woo_instance"/>
<field name="state">code</field>
<field name="code">model._cron_sync_products()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<record id="cron_woo_sync_orders" model="ir.cron">
<field name="name">WooCommerce: Sync Orders</field>
<field name="model_id" ref="model_woo_instance"/>
<field name="state">code</field>
<field name="code">model._cron_sync_orders()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<record id="cron_woo_sync_inventory" model="ir.cron">
<field name="name">WooCommerce: Sync Inventory</field>
<field name="model_id" ref="model_woo_instance"/>
<field name="state">code</field>
<field name="code">model._cron_sync_inventory()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<record id="cron_woo_sync_customers" model="ir.cron">
<field name="name">WooCommerce: Sync Customers</field>
<field name="model_id" ref="model_woo_instance"/>
<field name="state">code</field>
<field name="code">model._cron_sync_customers()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<record id="cron_woo_health_check" model="ir.cron">
<field name="name">WooCommerce: Health Check</field>
<field name="model_id" ref="model_woo_instance"/>
<field name="state">code</field>
<field name="code">model._cron_health_check()</field>
<field name="interval_number">10</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<record id="cron_woo_cleanup_logs" model="ir.cron">
<field name="name">WooCommerce: Cleanup Old Sync Logs</field>
<field name="model_id" ref="model_woo_sync_log"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_logs()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
</odoo>

View File

@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="woo_sync_failure_notification" model="mail.template">
<field name="name">WooCommerce: Sync Failure Notification</field>
<field name="model_id" ref="model_woo_instance"/>
<field name="subject">WooCommerce Sync Failed: ${object.name}</field>
<field name="body_html"><![CDATA[
<p>Hello,</p>
<p>A WooCommerce sync has failed for the following instance:</p>
<ul>
<li><strong>Instance:</strong> ${object.name}</li>
<li><strong>Sync Type:</strong> ${ctx.get('sync_type', 'Unknown')}</li>
<li><strong>Error:</strong> ${ctx.get('error_message', 'No details available')}</li>
<li><strong>Timestamp:</strong> ${object.last_sync or 'N/A'}</li>
</ul>
<p>Please review your WooCommerce integration settings and resolve the issue.</p>
<p>— Fusion WooCommerce</p>
]]></field>
<field name="auto_delete">True</field>
</record>
<record id="woo_new_order_notification" model="mail.template">
<field name="name">WooCommerce: New Order Notification</field>
<field name="model_id" ref="model_woo_instance"/>
<field name="subject">New WooCommerce Order: ${ctx.get('order_number', '')}</field>
<field name="body_html"><![CDATA[
<p>Hello,</p>
<p>A new WooCommerce order has been received:</p>
<ul>
<li><strong>Order Number:</strong> ${ctx.get('order_number', 'N/A')}</li>
<li><strong>Customer:</strong> ${ctx.get('customer_name', 'N/A')}</li>
<li><strong>Total:</strong> $${ctx.get('order_total', '0.00')}</li>
</ul>
<p>Log in to Odoo to review and process this order.</p>
<p>— Fusion WooCommerce</p>
]]></field>
<field name="auto_delete">True</field>
</record>
</odoo>

View File

@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="carrier_canada_post" model="woo.shipping.carrier">
<field name="name">Canada Post</field>
<field name="code">canada_post</field>
<field name="tracking_url">https://www.canadapost-postescanada.ca/track-reperage/en#/search?searchFor={tracking}</field>
</record>
<record id="carrier_ups" model="woo.shipping.carrier">
<field name="name">UPS</field>
<field name="code">ups</field>
<field name="tracking_url">https://www.ups.com/track?tracknum={tracking}</field>
</record>
<record id="carrier_fedex" model="woo.shipping.carrier">
<field name="name">FedEx</field>
<field name="code">fedex</field>
<field name="tracking_url">https://www.fedex.com/fedextrack/?trknbr={tracking}</field>
</record>
<record id="carrier_purolator" model="woo.shipping.carrier">
<field name="name">Purolator</field>
<field name="code">purolator</field>
<field name="tracking_url">https://www.purolator.com/en/shipping/tracker?pin={tracking}</field>
</record>
<record id="carrier_dhl" model="woo.shipping.carrier">
<field name="name">DHL</field>
<field name="code">dhl</field>
<field name="tracking_url">https://www.dhl.com/en/express/tracking.html?AWB={tracking}</field>
</record>
<record id="carrier_other" model="woo.shipping.carrier">
<field name="name">Other</field>
<field name="code">other</field>
<field name="tracking_url"></field>
</record>
</odoo>

View File

@@ -1,3 +0,0 @@
from .woo_api_client import WooApiClient
from .ai_service import AIService
from .image_processor import ImageProcessor

View File

@@ -1,171 +0,0 @@
import json
import logging
_logger = logging.getLogger(__name__)
class AIService:
"""AI content generation service supporting Claude and OpenAI."""
def __init__(self, provider, api_key, model=None):
"""
Args:
provider: 'claude' or 'openai'
api_key: API key for the chosen provider
model: Model name (defaults to claude-sonnet-4-5-20250514 or gpt-4o)
"""
self.provider = provider
self.api_key = api_key
if model:
self.model = model
else:
self.model = 'claude-sonnet-4-5-20250514' if provider == 'claude' else 'gpt-4o'
self._client = None
def _get_client(self):
if self._client:
return self._client
if self.provider == 'claude':
try:
import anthropic
self._client = anthropic.Anthropic(api_key=self.api_key)
except ImportError:
raise RuntimeError("anthropic package not installed. Run: pip install anthropic")
elif self.provider == 'openai':
try:
import openai
self._client = openai.OpenAI(api_key=self.api_key)
except ImportError:
raise RuntimeError("openai package not installed. Run: pip install openai")
return self._client
def generate(self, system_prompt, user_message, max_tokens=2000):
"""Generate text using the configured AI provider."""
client = self._get_client()
try:
if self.provider == 'claude':
response = client.messages.create(
model=self.model,
max_tokens=max_tokens,
system=system_prompt,
messages=[{"role": "user", "content": user_message}],
)
return response.content[0].text
elif self.provider == 'openai':
response = client.chat.completions.create(
model=self.model,
max_tokens=max_tokens,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
)
return response.choices[0].message.content
except Exception as e:
_logger.error("AI generation failed (%s): %s", self.provider, str(e))
raise
def generate_product_content(self, product_info, prompts):
"""Generate all product content at once.
Args:
product_info: dict with keys like name, category, features, raw_description
prompts: dict with keys: title, short_desc, long_desc, meta_title, meta_desc, keywords
Returns:
dict with generated content for each field
"""
context = json.dumps(product_info, indent=2)
system = (
"You are an expert e-commerce copywriter and SEO specialist. "
"You create compelling, SEO-optimized product content for online stores. "
"Always respond with valid JSON containing the requested fields. "
"HTML descriptions should use proper semantic HTML tags."
)
user_msg = f"""Based on this product information:
{context}
Generate the following content as a JSON object with these exact keys:
1. "title": {prompts.get('title', 'SEO-optimized product title in Title Case')}
2. "short_description": {prompts.get('short_desc', 'Compelling 2-3 sentence HTML summary')}
3. "long_description": {prompts.get('long_desc', 'Detailed HTML product description with headings and lists')}
4. "meta_title": {prompts.get('meta_title', 'SEO meta title under 60 characters')}
5. "meta_description": {prompts.get('meta_desc', 'SEO meta description under 160 characters')}
6. "keywords": {prompts.get('keywords', 'Comma-separated SEO keywords')}
Respond ONLY with the JSON object, no markdown formatting."""
try:
raw = self.generate(system, user_msg, max_tokens=3000)
# Try to parse JSON from the response
# Strip any markdown code fences
cleaned = raw.strip()
if cleaned.startswith('```'):
cleaned = cleaned.split('\n', 1)[1]
if cleaned.endswith('```'):
cleaned = cleaned[:-3]
cleaned = cleaned.strip()
return json.loads(cleaned)
except json.JSONDecodeError:
_logger.warning("AI returned non-JSON response, returning raw text")
return {
'title': raw[:200] if raw else '',
'short_description': '',
'long_description': raw or '',
'meta_title': '',
'meta_description': '',
'keywords': '',
}
def generate_single_field(self, product_info, prompt, field_name):
"""Generate a single field using the given prompt."""
context = json.dumps(product_info, indent=2)
system = (
"You are an expert e-commerce copywriter and SEO specialist. "
"Respond with ONLY the requested content, no explanations or formatting."
)
user_msg = f"Product info:\n{context}\n\nTask: {prompt}"
result = self.generate(system, user_msg, max_tokens=1500)
return result.strip() if result else ''
def generate_image_metadata(self, product_name, product_category, prompt_alt, prompt_caption):
"""Generate SEO metadata for a product image.
Returns:
dict with: alt_text, caption, title, description
"""
system = (
"You are an SEO specialist for e-commerce product images. "
"Generate metadata that helps with image SEO and accessibility. "
"Respond ONLY with a JSON object."
)
user_msg = f"""Product: {product_name}
Category: {product_category}
Generate image metadata as JSON:
- "alt_text": {prompt_alt} (under 125 characters)
- "caption": {prompt_caption}
- "title": SEO-optimized image title
- "description": Descriptive image text for SEO
Respond ONLY with the JSON object."""
try:
raw = self.generate(system, user_msg, max_tokens=500)
cleaned = raw.strip()
if cleaned.startswith('```'):
cleaned = cleaned.split('\n', 1)[1]
if cleaned.endswith('```'):
cleaned = cleaned[:-3]
cleaned = cleaned.strip()
return json.loads(cleaned)
except (json.JSONDecodeError, Exception):
return {
'alt_text': product_name,
'caption': product_name,
'title': product_name,
'description': product_name,
}

View File

@@ -1,104 +0,0 @@
import base64
import io
import logging
import struct
_logger = logging.getLogger(__name__)
class ImageProcessor:
"""Process product images: EXIF geo-tagging, metadata, optimization."""
@staticmethod
def geo_tag_image(image_b64, company_name, address, phone, lat, lng):
"""Write EXIF metadata with company info and GPS coordinates.
Args:
image_b64: base64-encoded image data
company_name: company name for Copyright/Artist tags
address: company address for ImageDescription
phone: company phone
lat: GPS latitude (float)
lng: GPS longitude (float)
Returns:
base64-encoded image with EXIF data
"""
try:
import piexif
except ImportError:
_logger.warning("piexif not installed — skipping geo-tagging. Run: pip install piexif")
return image_b64
try:
image_data = base64.b64decode(image_b64)
# Check if it's a JPEG (piexif only works with JPEG)
if not image_data[:2] == b'\xff\xd8':
_logger.info("Image is not JPEG — skipping EXIF geo-tagging")
return image_b64
# Try to load existing EXIF
try:
exif_dict = piexif.load(image_data)
except Exception:
exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}}
# Set 0th IFD tags
exif_dict["0th"][piexif.ImageIFD.ImageDescription] = address.encode('utf-8') if address else b''
exif_dict["0th"][piexif.ImageIFD.Copyright] = (
f"Copyright {company_name}".encode('utf-8') if company_name else b''
)
exif_dict["0th"][piexif.ImageIFD.Artist] = company_name.encode('utf-8') if company_name else b''
# Set GPS tags if coordinates provided
if lat and lng:
lat_deg = ImageProcessor._decimal_to_dms(abs(lat))
lng_deg = ImageProcessor._decimal_to_dms(abs(lng))
exif_dict["GPS"] = {
piexif.GPSIFD.GPSLatitudeRef: b'N' if lat >= 0 else b'S',
piexif.GPSIFD.GPSLatitude: lat_deg,
piexif.GPSIFD.GPSLongitudeRef: b'E' if lng >= 0 else b'W',
piexif.GPSIFD.GPSLongitude: lng_deg,
}
exif_bytes = piexif.dump(exif_dict)
output = io.BytesIO()
piexif.insert(exif_bytes, image_data, output)
return base64.b64encode(output.getvalue()).decode('utf-8')
except Exception as e:
_logger.error("Failed to geo-tag image: %s", str(e))
return image_b64
@staticmethod
def _decimal_to_dms(decimal):
"""Convert decimal degrees to EXIF DMS format (degrees, minutes, seconds as rationals)."""
degrees = int(decimal)
minutes_float = (decimal - degrees) * 60
minutes = int(minutes_float)
seconds = int((minutes_float - minutes) * 60 * 10000)
return (
(degrees, 1),
(minutes, 1),
(seconds, 10000),
)
@staticmethod
def prepare_wc_image(image_b64, filename, alt_text='', caption='', title='', description=''):
"""Prepare image data for WooCommerce upload.
Returns dict ready for WC product images array, using src as base64 data URL.
Note: WC REST API v3 accepts image URLs in 'src'. For base64, we need to upload
via WordPress media endpoint first, then reference by URL.
"""
return {
'name': title or filename,
'alt': alt_text or '',
'caption': caption or '',
'description': description or '',
# The actual upload will be handled by the wizard
'_base64': image_b64,
'_filename': filename,
}

View File

@@ -1,341 +0,0 @@
import base64
import hashlib
import hmac
import logging
import threading
import time
import requests
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Shared circuit breaker state — one per WC base URL so all Odoo workers
# referencing the same WooCommerce store share the same breaker.
# ---------------------------------------------------------------------------
_circuit_breakers = {}
_cb_lock = threading.Lock()
class _CircuitBreaker:
"""Per-host circuit breaker: CLOSED → OPEN after N failures, auto-resets
after a cooldown period to HALF_OPEN (allows one probe request)."""
CLOSED = 'closed'
OPEN = 'open'
HALF_OPEN = 'half_open'
def __init__(self, failure_threshold=5, cooldown_seconds=60):
self.failure_threshold = failure_threshold
self.cooldown_seconds = cooldown_seconds
self.state = self.CLOSED
self.consecutive_failures = 0
self.last_failure_time = 0
self._lock = threading.Lock()
def record_success(self):
with self._lock:
self.consecutive_failures = 0
self.state = self.CLOSED
def record_failure(self):
with self._lock:
self.consecutive_failures += 1
self.last_failure_time = time.monotonic()
if self.consecutive_failures >= self.failure_threshold:
self.state = self.OPEN
_logger.warning(
"Circuit breaker OPEN after %d consecutive failures — "
"blocking requests for %ds",
self.consecutive_failures, self.cooldown_seconds,
)
def allow_request(self):
with self._lock:
if self.state == self.CLOSED:
return True
elapsed = time.monotonic() - self.last_failure_time
if elapsed >= self.cooldown_seconds:
self.state = self.HALF_OPEN
_logger.info("Circuit breaker HALF_OPEN — allowing probe request")
return True
return False
class _TokenBucket:
"""Simple token-bucket rate limiter. Tokens refill at *rate* per second
up to *capacity*. ``consume()`` blocks until a token is available."""
def __init__(self, rate, capacity):
self.rate = rate
self.capacity = capacity
self.tokens = capacity
self.last_refill = time.monotonic()
self._lock = threading.Lock()
def consume(self):
while True:
with self._lock:
now = time.monotonic()
elapsed = now - self.last_refill
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
self.last_refill = now
if self.tokens >= 1:
self.tokens -= 1
return
time.sleep(0.1)
class WooApiClient:
"""WooCommerce REST API v3 client wrapper with rate limiting and circuit
breaker protection."""
# Default: 3 requests/sec, burst up to 5.
# WooCommerce typically allows ~240 req/min (4/sec) so 3/sec is safe.
DEFAULT_RATE = 3
DEFAULT_BURST = 5
def __init__(self, url, consumer_key, consumer_secret,
api_version='wc/v3', timeout=30,
rate_limit=None, burst_limit=None):
self.base_url = url.rstrip('/')
self.api_version = api_version
self.timeout = timeout
self.session = requests.Session()
self.session.auth = (consumer_key, consumer_secret)
self.session.headers.update({
'Content-Type': 'application/json',
'User-Agent': 'FusionWooCommerce/1.0',
})
rate = rate_limit or self.DEFAULT_RATE
burst = burst_limit or self.DEFAULT_BURST
self._bucket = _TokenBucket(rate, burst)
with _cb_lock:
if self.base_url not in _circuit_breakers:
_circuit_breakers[self.base_url] = _CircuitBreaker(
failure_threshold=5, cooldown_seconds=60,
)
self._breaker = _circuit_breakers[self.base_url]
def _url(self, endpoint):
return f"{self.base_url}/wp-json/{self.api_version}/{endpoint}"
def _request(self, method, endpoint, data=None, params=None, retries=3):
url = self._url(endpoint)
if not self._breaker.allow_request():
raise ConnectionError(
"WooCommerce API circuit breaker is OPEN for %s"
"too many consecutive failures. Retry later." % self.base_url
)
last_exc = None
for attempt in range(retries):
self._bucket.consume()
try:
response = self.session.request(
method, url,
json=data, params=params,
timeout=self.timeout,
)
# --- Handle rate-limit response from WC / server ---
if response.status_code == 429:
retry_after = int(response.headers.get('Retry-After', 10))
retry_after = min(retry_after, 120)
_logger.warning(
"WC API 429 on %s %s — backing off %ds (attempt %d/%d)",
method, endpoint, retry_after, attempt + 1, retries,
)
time.sleep(retry_after)
continue
# --- Non-retryable client errors (400-499 except 429) ---
if 400 <= response.status_code < 500:
_logger.error(
"WC API %s %s returned %s (non-retryable): %s",
method, endpoint, response.status_code,
response.text[:500],
)
self._breaker.record_success()
response.raise_for_status()
# --- Server errors (500+) are retryable ---
if response.status_code >= 500:
_logger.warning(
"WC API %s %s returned %s (attempt %d/%d): %s",
method, endpoint, response.status_code,
attempt + 1, retries, response.text[:300],
)
last_exc = requests.HTTPError(response=response)
wait = min(2 ** attempt * 2, 30)
time.sleep(wait)
continue
self._breaker.record_success()
return response.json()
except requests.exceptions.ConnectionError as exc:
last_exc = exc
wait = min(2 ** attempt * 2, 30)
_logger.warning(
"WC API connection error %s %s (attempt %d/%d): %s"
"retrying in %ds",
method, endpoint, attempt + 1, retries, exc, wait,
)
time.sleep(wait)
except requests.exceptions.Timeout as exc:
last_exc = exc
wait = min(2 ** attempt * 2, 30)
_logger.warning(
"WC API timeout %s %s (attempt %d/%d) — retrying in %ds",
method, endpoint, attempt + 1, retries, wait,
)
time.sleep(wait)
except Exception as exc:
last_exc = exc
_logger.error(
"WC API unexpected error %s %s: %s", method, endpoint, exc,
)
break
self._breaker.record_failure()
raise last_exc
# --- Convenience methods ---
def get(self, endpoint, params=None):
return self._request('GET', endpoint, params=params)
def post(self, endpoint, data):
return self._request('POST', endpoint, data=data)
def put(self, endpoint, data):
return self._request('PUT', endpoint, data=data)
def delete(self, endpoint):
return self._request('DELETE', endpoint)
# --- Product endpoints ---
def get_products(self, page=1, per_page=100, **kwargs):
params = {'page': page, 'per_page': per_page, **kwargs}
return self.get('products', params=params)
def get_product(self, product_id):
return self.get(f'products/{product_id}')
def get_product_variations(self, product_id, page=1, per_page=100):
params = {'page': page, 'per_page': per_page}
return self.get(f'products/{product_id}/variations', params=params)
def update_product(self, product_id, data):
return self.put(f'products/{product_id}', data)
def create_product(self, data):
return self.post('products', data)
# --- Attribute endpoints ---
def get_product_attributes(self):
return self.get('products/attributes', params={'per_page': 100})
def create_product_attribute(self, data):
return self.post('products/attributes', data)
def get_attribute_terms(self, attribute_id, page=1, per_page=100):
return self.get(
f'products/attributes/{attribute_id}/terms',
params={'page': page, 'per_page': per_page},
)
def create_attribute_term(self, attribute_id, data):
return self.post(f'products/attributes/{attribute_id}/terms', data)
# --- Variation endpoints ---
def create_product_variation(self, product_id, data):
return self.post(f'products/{product_id}/variations', data)
def update_product_variation(self, product_id, variation_id, data):
return self.put(f'products/{product_id}/variations/{variation_id}', data)
def delete_product_variation(self, product_id, variation_id):
return self.delete(f'products/{product_id}/variations/{variation_id}')
def batch_create_variations(self, product_id, variations_data):
"""Create multiple variations at once using WC batch endpoint."""
return self.post(
f'products/{product_id}/variations/batch',
{'create': variations_data},
)
# --- Order endpoints ---
def get_orders(self, page=1, per_page=100, **kwargs):
params = {'page': page, 'per_page': per_page, **kwargs}
return self.get('orders', params=params)
def get_order(self, order_id):
return self.get(f'orders/{order_id}')
def update_order(self, order_id, data):
return self.put(f'orders/{order_id}', data)
# --- Customer endpoints ---
def get_customers(self, page=1, per_page=100, **kwargs):
params = {'page': page, 'per_page': per_page, **kwargs}
return self.get('customers', params=params)
def get_customer(self, customer_id):
return self.get(f'customers/{customer_id}')
def create_customer(self, data):
return self.post('customers', data)
def update_customer(self, customer_id, data):
return self.put(f'customers/{customer_id}', data)
# --- Webhook endpoints ---
def create_webhook(self, data):
return self.post('webhooks', data)
def get_webhooks(self):
return self.get('webhooks', params={'per_page': 100})
def delete_webhook(self, webhook_id):
return self.delete(f'webhooks/{webhook_id}')
# --- Tax endpoints ---
def get_tax_classes(self):
return self.get('taxes/classes')
# --- Utility ---
def test_connection(self):
try:
result = self.get('system_status')
wc_version = result.get('environment', {}).get('version', 'unknown')
return True, wc_version
except Exception as exc:
return False, str(exc)
@staticmethod
def verify_webhook_signature(payload, signature, secret):
"""Verify a WooCommerce webhook HMAC-SHA256 signature."""
if isinstance(payload, str):
payload = payload.encode('utf-8')
if isinstance(secret, str):
secret = secret.encode('utf-8')
computed = base64.b64encode(
hmac.new(secret, payload, hashlib.sha256).digest()
).decode('utf-8')
return hmac.compare_digest(computed, signature)

View File

@@ -1,16 +0,0 @@
from . import woo_shipping_carrier
from . import woo_instance
from . import woo_category_map
from . import woo_product_map
from . import woo_order
from . import woo_shipment
from . import woo_customer
from . import woo_sync_log
from . import woo_conflict
from . import woo_tax_map
from . import woo_pricelist_map
from . import woo_return
from . import sale_order
from . import stock_picking
from . import account_move
from . import res_partner

View File

@@ -1,28 +0,0 @@
import logging
from odoo import models, fields
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
woo_order_id = fields.Many2one('woo.order', string='WooCommerce Order')
is_woo_invoice = fields.Boolean(compute='_compute_is_woo_invoice', string='Is WC Invoice')
def _compute_is_woo_invoice(self):
for move in self:
move.is_woo_invoice = bool(move.woo_order_id)
def action_post(self):
"""Override to auto-push invoice PDF to WooCommerce on posting."""
res = super().action_post()
for move in self:
if move.woo_order_id and not move.woo_order_id.invoice_synced:
try:
move.woo_order_id.action_push_invoice_pdf()
move.woo_order_id.invoice_synced = True
except Exception as e:
_logger.error("Failed to push invoice PDF to WC: %s", e)
return res

View File

@@ -1,13 +0,0 @@
from odoo import models, fields, api
class ResPartner(models.Model):
_inherit = 'res.partner'
woo_customer_ids = fields.One2many('woo.customer', 'partner_id', string='WooCommerce Links')
is_woo_customer = fields.Boolean(compute='_compute_is_woo_customer', string='Is WC Customer', store=True)
@api.depends('woo_customer_ids')
def _compute_is_woo_customer(self):
for partner in self:
partner.is_woo_customer = bool(partner.woo_customer_ids)

View File

@@ -1,12 +0,0 @@
from odoo import models, fields
class SaleOrder(models.Model):
_inherit = 'sale.order'
woo_bind_ids = fields.One2many('woo.order', 'sale_order_id', string='WooCommerce Orders')
woo_order_count = fields.Integer(compute='_compute_woo_order_count', string='WC Orders')
def _compute_woo_order_count(self):
for order in self:
order.woo_order_count = len(order.woo_bind_ids)

View File

@@ -1,57 +0,0 @@
import logging
from odoo import models, fields
_logger = logging.getLogger(__name__)
class StockPicking(models.Model):
_inherit = 'stock.picking'
woo_tracking_number = fields.Char(string='WC Tracking Number')
woo_carrier_id = fields.Many2one('woo.shipping.carrier', string='WC Shipping Carrier')
woo_shipment_ids = fields.One2many('woo.shipment', 'picking_id', string='WC Shipments')
is_woo_delivery = fields.Boolean(compute='_compute_is_woo_delivery', string='Is WC Delivery')
def _compute_is_woo_delivery(self):
for picking in self:
picking.is_woo_delivery = bool(picking.woo_shipment_ids) or bool(
picking.sale_id and picking.sale_id.woo_bind_ids
)
def button_validate(self):
"""Override to auto-create shipment and push tracking to WC."""
res = super().button_validate()
for picking in self:
if not picking.sale_id or not picking.sale_id.woo_bind_ids:
continue
woo_order = picking.sale_id.woo_bind_ids[0]
# Create shipment record
shipment_vals = {
'order_id': woo_order.id,
'picking_id': picking.id,
'carrier_id': picking.woo_carrier_id.id if picking.woo_carrier_id else False,
'tracking_number': picking.woo_tracking_number or '',
'shipped_date': fields.Datetime.now(),
'is_backorder': bool(picking.backorder_ids),
'company_id': picking.company_id.id,
}
shipment = self.env['woo.shipment'].create(shipment_vals)
# Auto-push to WC if tracking number is set
if picking.woo_tracking_number:
try:
woo_order.action_push_shipping(
picking.woo_tracking_number,
picking.woo_carrier_id.id if picking.woo_carrier_id else False,
)
shipment.synced_to_woo = True
except Exception as e:
_logger.error("Failed to push shipping to WC: %s", e)
# Push delivery PDF
try:
woo_order.action_push_delivery_pdf(picking)
except Exception as e:
_logger.warning("Failed to push delivery PDF to WC: %s", e)
return res

View File

@@ -1,15 +0,0 @@
from odoo import api, fields, models
class WooCategoryMap(models.Model):
_name = 'woo.category.map'
_description = 'WooCommerce Category Mapping'
_order = 'odoo_category_id'
_rec_name = 'woo_category_name'
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
odoo_category_id = fields.Many2one('product.category', string='Odoo Category')
woo_category_id = fields.Integer(string='WC Category ID', required=True)
woo_category_name = fields.Char(string='WC Category Name')
woo_category_slug = fields.Char(string='WC Category Slug')
company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company)

View File

@@ -1,100 +0,0 @@
import logging
from odoo import fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class WooConflict(models.Model):
_name = 'woo.conflict'
_description = 'WooCommerce Sync Conflict'
_rec_name = 'field_name'
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
conflict_type = fields.Selection([
('product', 'Product'),
('customer', 'Customer'),
('order', 'Order'),
])
map_id = fields.Many2one('woo.product.map')
customer_id = fields.Many2one('woo.customer')
order_id = fields.Many2one('woo.order')
field_name = fields.Char()
odoo_value = fields.Char()
woo_value = fields.Char()
resolution = fields.Selection([
('pending', 'Pending'),
('use_odoo', 'Use Odoo'),
('use_woo', 'Use WooCommerce'),
], default='pending')
resolved_by = fields.Many2one('res.users')
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
)
# ------------------------------------------------------------------
# Resolution methods (Task 23)
# ------------------------------------------------------------------
def action_use_odoo(self):
"""Resolve conflict by pushing Odoo value to WooCommerce."""
self.ensure_one()
if self.resolution != 'pending':
raise UserError("This conflict has already been resolved.")
client = self.instance_id._get_client()
if self.conflict_type == 'product' and self.map_id:
if self.field_name == 'price':
client.update_product(self.map_id.woo_product_id, {
'regular_price': self.odoo_value,
})
self.map_id.state = 'mapped'
self.map_id.last_synced = fields.Datetime.now()
self.resolution = 'use_odoo'
self.resolved_by = self.env.user
self.instance_id._log_sync(
self.conflict_type or 'product', 'odoo_to_woo',
self.map_id.product_id.display_name if self.map_id and self.map_id.product_id else 'N/A',
'success', f'Conflict resolved: use Odoo value ({self.odoo_value})',
)
def action_use_woo(self):
"""Resolve conflict by pulling WooCommerce value into Odoo."""
self.ensure_one()
if self.resolution != 'pending':
raise UserError("This conflict has already been resolved.")
if self.conflict_type == 'product' and self.map_id and self.map_id.product_id:
if self.field_name == 'price':
self.map_id.product_id.list_price = float(self.woo_value or 0)
self.map_id.state = 'mapped'
self.map_id.last_synced = fields.Datetime.now()
self.resolution = 'use_woo'
self.resolved_by = self.env.user
self.instance_id._log_sync(
self.conflict_type or 'product', 'woo_to_odoo',
self.map_id.product_id.display_name if self.map_id and self.map_id.product_id else 'N/A',
'success', f'Conflict resolved: use WC value ({self.woo_value})',
)
def action_bulk_resolve_odoo(self):
"""Server action: resolve all selected conflicts with Odoo values."""
for conflict in self:
if conflict.resolution == 'pending':
try:
conflict.action_use_odoo()
except Exception as e:
_logger.error("Bulk resolve (Odoo) failed for conflict %s: %s", conflict.id, e)
def action_bulk_resolve_woo(self):
"""Server action: resolve all selected conflicts with WC values."""
for conflict in self:
if conflict.resolution == 'pending':
try:
conflict.action_use_woo()
except Exception as e:
_logger.error("Bulk resolve (WC) failed for conflict %s: %s", conflict.id, e)

View File

@@ -1,92 +0,0 @@
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class WooCustomer(models.Model):
_name = 'woo.customer'
_description = 'WooCommerce Customer'
_rec_name = 'woo_email'
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
partner_id = fields.Many2one('res.partner', required=True)
woo_customer_id = fields.Integer(index=True)
woo_email = fields.Char()
last_synced = fields.Datetime()
company_id = fields.Many2one(
'res.company', required=True, default=lambda self: self.env.company,
)
# ------------------------------------------------------------------
# Helpers (Task 25)
# ------------------------------------------------------------------
@api.model
def _find_or_create(self, instance, email, wc_data=None):
"""Find or create a woo.customer + res.partner for the given email.
Args:
instance: woo.instance record
email: customer email address
wc_data: optional dict with full WC customer payload
Returns:
woo.customer record
"""
email = (email or '').strip().lower()
if not email:
return self.browse()
wc_customer_id = (wc_data or {}).get('id', 0)
# Check existing link
if wc_customer_id:
existing = self.search([
('instance_id', '=', instance.id),
('woo_customer_id', '=', wc_customer_id),
], limit=1)
if existing:
return existing
# Check by email
existing = self.search([
('instance_id', '=', instance.id),
('woo_email', '=ilike', email),
], limit=1)
if existing:
if wc_customer_id and not existing.woo_customer_id:
existing.woo_customer_id = wc_customer_id
return existing
# Find or create partner
partner = self.env['res.partner'].search([
('email', '=ilike', email),
'|', ('company_id', '=', instance.company_id.id), ('company_id', '=', False),
], limit=1)
if not partner:
billing = (wc_data or {}).get('billing', {})
partner_vals = instance._prepare_partner_vals(billing) if billing else {
'name': email.split('@')[0].title(),
'email': email,
'company_id': instance.company_id.id,
}
partner = self.env['res.partner'].create(partner_vals)
# Create woo.customer link
woo_cust = self.create({
'instance_id': instance.id,
'partner_id': partner.id,
'woo_customer_id': wc_customer_id,
'woo_email': email,
'last_synced': fields.Datetime.now(),
'company_id': instance.company_id.id,
})
instance._log_sync(
'customer', 'woo_to_odoo', partner.display_name,
'success', f'Customer created from WC (email: {email})',
)
return woo_cust

File diff suppressed because it is too large Load Diff

View File

@@ -1,297 +0,0 @@
import base64
import logging
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class WooOrder(models.Model):
_name = 'woo.order'
_description = 'WooCommerce Order'
_inherit = ['mail.thread', 'mail.activity.mixin']
_rec_name = 'display_name'
_order = 'id desc'
display_name = fields.Char(compute='_compute_display_name', store=True)
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
sale_order_id = fields.Many2one('sale.order')
woo_order_id = fields.Integer(index=True)
woo_order_number = fields.Char(string='WC Order #')
woo_status = fields.Selection([
('pending', 'Pending Payment'),
('processing', 'Processing'),
('on-hold', 'On Hold'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
('refunded', 'Refunded'),
('failed', 'Failed'),
('trash', 'Trashed'),
], string='WC Status', tracking=True)
invoice_id = fields.Many2one('account.move')
invoice_synced = fields.Boolean()
company_id = fields.Many2one(
'res.company', required=True, default=lambda self: self.env.company,
)
state = fields.Selection([
('new', 'New'),
('confirmed', 'Confirmed'),
('shipped', 'Shipped'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
], default='new', tracking=True)
WC_STATUS_TO_STATE = {
'pending': 'new',
'on-hold': 'new',
'processing': 'confirmed',
'completed': 'completed',
'cancelled': 'cancelled',
'refunded': 'cancelled',
'failed': 'cancelled',
'trash': 'cancelled',
}
@api.onchange('woo_status')
def _onchange_woo_status(self):
if self.woo_status:
self.state = self.WC_STATUS_TO_STATE.get(self.woo_status, self.state)
def _set_woo_status(self, wc_status):
"""Set woo_status and auto-map to Odoo state."""
vals = {}
if wc_status:
vals['woo_status'] = wc_status
mapped_state = self.WC_STATUS_TO_STATE.get(wc_status)
if mapped_state:
vals['state'] = mapped_state
if vals:
self.write(vals)
shipment_ids = fields.One2many('woo.shipment', 'order_id')
delivery_count = fields.Integer(compute='_compute_delivery_count')
invoice_count = fields.Integer(compute='_compute_invoice_count')
@api.depends('woo_order_number', 'sale_order_id', 'sale_order_id.name')
def _compute_display_name(self):
for rec in self:
parts = []
if rec.woo_order_number:
parts.append('WC#%s' % rec.woo_order_number)
if rec.sale_order_id:
parts.append(rec.sale_order_id.name)
rec.display_name = ''.join(parts) if parts else 'WC Order #%s' % rec.id
@api.depends('sale_order_id')
def _compute_delivery_count(self):
for rec in self:
if rec.sale_order_id:
rec.delivery_count = self.env['stock.picking'].search_count([
('origin', '=', rec.sale_order_id.name),
])
else:
rec.delivery_count = 0
@api.depends('sale_order_id')
def _compute_invoice_count(self):
for rec in self:
if rec.sale_order_id:
rec.invoice_count = self.env['account.move'].search_count([
('invoice_origin', '=', rec.sale_order_id.name),
('move_type', 'in', ['out_invoice', 'out_refund']),
])
else:
rec.invoice_count = 0
def action_view_sale_order(self):
self.ensure_one()
if not self.sale_order_id:
return
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'views': [(False, 'form')],
}
def action_view_deliveries(self):
self.ensure_one()
pickings = self.env['stock.picking'].search([
('origin', '=', self.sale_order_id.name),
])
if len(pickings) == 1:
return {
'type': 'ir.actions.act_window',
'res_model': 'stock.picking',
'res_id': pickings.id,
'views': [(False, 'form')],
}
return {
'type': 'ir.actions.act_window',
'name': 'Deliveries',
'res_model': 'stock.picking',
'view_mode': 'list,form',
'domain': [('origin', '=', self.sale_order_id.name)],
}
def action_view_invoices(self):
self.ensure_one()
invoices = self.env['account.move'].search([
('invoice_origin', '=', self.sale_order_id.name),
('move_type', 'in', ['out_invoice', 'out_refund']),
])
if len(invoices) == 1:
return {
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'res_id': invoices.id,
'views': [(False, 'form')],
}
return {
'type': 'ir.actions.act_window',
'name': 'Invoices',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('invoice_origin', '=', self.sale_order_id.name),
('move_type', 'in', ['out_invoice', 'out_refund'])],
}
# ------------------------------------------------------------------
# Push methods (Task 20)
# ------------------------------------------------------------------
def action_push_shipping(self, tracking_number, carrier_id=False):
"""Push shipping/tracking info to WooCommerce and update status."""
self.ensure_one()
client = self.instance_id._get_client()
update_data = {
'status': 'completed',
}
# Build tracking meta
meta = [
{'key': '_tracking_number', 'value': tracking_number},
]
if carrier_id:
carrier = self.env['woo.shipping.carrier'].browse(carrier_id)
if carrier.exists():
meta.append({'key': '_tracking_provider', 'value': carrier.name})
if carrier.tracking_url:
url = carrier.tracking_url.replace('{tracking}', tracking_number)
meta.append({'key': '_tracking_url', 'value': url})
update_data['meta_data'] = meta
client.update_order(self.woo_order_id, update_data)
self._set_woo_status('completed')
self.state = 'shipped'
self.instance_id._log_sync(
'order', 'odoo_to_woo', self.sale_order_id.name,
'success', f'Shipping pushed with tracking: {tracking_number}',
)
def action_push_completed(self):
"""Mark WC order as completed."""
self.ensure_one()
client = self.instance_id._get_client()
client.update_order(self.woo_order_id, {'status': 'completed'})
self._set_woo_status('completed')
self.instance_id._log_sync(
'order', 'odoo_to_woo', self.sale_order_id.name,
'success', 'Order marked as completed in WC',
)
def action_push_invoice_pdf(self):
"""Render invoice PDF and push to WC via custom plugin endpoint."""
self.ensure_one()
if not self.invoice_id:
raise UserError("No invoice linked to this WC order.")
# Generate PDF report
report = self.env.ref('account.account_invoices')
pdf_content, _content_type = report._render_qweb_pdf(
report.id, [self.invoice_id.id]
)
pdf_b64 = base64.b64encode(pdf_content).decode('utf-8')
# Push to WC via order note or meta
try:
client = self.instance_id._get_client()
client.update_order(self.woo_order_id, {
'meta_data': [
{'key': '_odoo_invoice_ref', 'value': self.invoice_id.name},
{'key': '_odoo_invoice_pdf', 'value': pdf_b64},
]
})
self.invoice_synced = True
self.instance_id._log_sync(
'invoice', 'odoo_to_woo', self.invoice_id.name,
'success', 'Invoice PDF pushed to WC',
)
except Exception as e:
_logger.error("Failed to push invoice PDF to WC: %s", e)
self.instance_id._log_sync(
'invoice', 'odoo_to_woo', self.invoice_id.name,
'failed', str(e),
)
raise
def action_push_delivery_pdf(self, picking):
"""Render delivery slip PDF and push to WC."""
self.ensure_one()
report = self.env.ref('stock.action_report_delivery')
pdf_content, _content_type = report._render_qweb_pdf(
report.id, [picking.id]
)
pdf_b64 = base64.b64encode(pdf_content).decode('utf-8')
try:
client = self.instance_id._get_client()
client.update_order(self.woo_order_id, {
'meta_data': [
{'key': '_odoo_delivery_ref', 'value': picking.name},
{'key': '_odoo_delivery_pdf', 'value': pdf_b64},
]
})
self.instance_id._log_sync(
'order', 'odoo_to_woo', picking.name,
'success', 'Delivery PDF pushed to WC',
)
except Exception as e:
_logger.error("Failed to push delivery PDF to WC: %s", e)
def _push_messages_to_wc(self):
"""Extract customer-visible messages and push as WC order notes."""
self.ensure_one()
if not self.sale_order_id:
return
client = self.instance_id._get_client()
# Get messages from the sale order that are customer-visible
messages = self.env['mail.message'].search([
('res_id', '=', self.sale_order_id.id),
('model', '=', 'sale.order'),
('message_type', 'in', ['comment', 'email']),
('subtype_id.internal', '=', False),
], order='create_date asc')
for msg in messages:
note_body = msg.body or msg.preview or ''
if not note_body:
continue
try:
# WC order notes endpoint
client.post(f'orders/{self.woo_order_id}/notes', {
'note': note_body,
'customer_note': True,
})
except Exception as e:
_logger.warning(
"Failed to push message to WC order %s: %s",
self.woo_order_id, e,
)

View File

@@ -1,36 +0,0 @@
from odoo import api, fields, models
class WooPricelistMap(models.Model):
_name = 'woo.pricelist.map'
_description = 'WooCommerce Pricelist Mapping'
_rec_name = 'woo_role_name'
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
pricelist_id = fields.Many2one('product.pricelist', required=True)
woo_role = fields.Char(required=True)
woo_role_name = fields.Char()
company_id = fields.Many2one(
'res.company', required=True, default=lambda self: self.env.company,
)
# ------------------------------------------------------------------
# Lookup helper (Task 26)
# ------------------------------------------------------------------
@api.model
def get_pricelist_for_role(self, instance, wc_role):
"""Return the Odoo product.pricelist mapped to a WC customer role.
Args:
instance: woo.instance record
wc_role: WC customer role slug (e.g. 'wholesale', 'customer')
Returns:
product.pricelist record or empty recordset
"""
mapping = self.search([
('instance_id', '=', instance.id),
('woo_role', '=', wc_role),
], limit=1)
return mapping.pricelist_id if mapping else self.env['product.pricelist']

View File

@@ -1,476 +0,0 @@
import base64
import hashlib
import json
import logging
import requests
from odoo import fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class WooProductMap(models.Model):
_name = 'woo.product.map'
_description = 'WooCommerce Product Mapping'
_rec_name = 'woo_product_name'
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
product_id = fields.Many2one('product.product')
woo_product_id = fields.Integer()
woo_product_name = fields.Char()
woo_sku = fields.Char()
woo_product_type = fields.Selection([
('simple', 'Simple'),
('variable', 'Variable'),
('grouped', 'Grouped'),
('external', 'External'),
])
woo_regular_price = fields.Float(string='WC Standard Price', digits='Product Price')
woo_sale_price = fields.Float(string='WC Sale Price', digits='Product Price')
woo_permalink = fields.Char(string='WC Product URL')
woo_category_id = fields.Integer(string='WC Category ID')
woo_category_name = fields.Char(string='WC Category')
woo_parent_id = fields.Integer()
is_variation = fields.Boolean()
sync_price = fields.Boolean(default=True)
sync_inventory = fields.Boolean(default=True)
sync_images = fields.Boolean(default=True)
woo_image_ids = fields.Char() # JSON
last_synced = fields.Datetime()
company_id = fields.Many2one(
'res.company', required=True, default=lambda self: self.env.company,
)
state = fields.Selection([
('unmapped', 'Unmapped'),
('mapped', 'Mapped'),
('conflict', 'Conflict'),
('error', 'Error'),
], default='unmapped')
# ------------------------------------------------------------------
# Individual Price Sync
# ------------------------------------------------------------------
def action_push_price_to_odoo(self):
"""Update Odoo product price from WC sale price (or regular if no sale)."""
for rec in self:
if not rec.product_id:
continue
# Use sale price if available, otherwise regular price
wc_price = rec.woo_sale_price if rec.woo_sale_price else rec.woo_regular_price
if wc_price:
rec.product_id.list_price = wc_price
rec.instance_id._log_sync(
'product', 'woo_to_odoo', rec.product_id.name, 'success',
'Odoo price updated from WC: $%.2f' % wc_price,
)
def action_push_price_to_wc(self):
"""Push Odoo price to WC.
Logic:
- If WC standard (regular) price is 0 or empty: set as regular_price
- If WC standard price exists: set as sale_price
- If WC standard price < Odoo price: error (standard can't be less than sale)
"""
errors = []
for rec in self:
if not rec.product_id or not rec.instance_id:
continue
odoo_price = rec.product_id.list_price
wc_regular = rec.woo_regular_price or 0.0
client = rec.instance_id._get_client()
update_data = {}
if wc_regular < 0.01:
# No standard price set — push as regular_price
update_data = {
'regular_price': str(odoo_price),
'sale_price': '',
}
rec.woo_regular_price = odoo_price
rec.woo_sale_price = 0.0
else:
# Standard price exists — push as sale_price
if wc_regular < odoo_price - 0.01:
# Standard price is less than the price we want to set as sale
errors.append(
'%s: WC standard price ($%.2f) is less than Odoo price ($%.2f). '
'Update the standard price first.' % (rec.woo_product_name, wc_regular, odoo_price)
)
continue
update_data = {
'sale_price': str(odoo_price),
}
rec.woo_sale_price = odoo_price
try:
client.update_product(rec.woo_product_id, update_data)
rec.instance_id._log_sync(
'product', 'odoo_to_woo', rec.product_id.name, 'success',
'Price pushed to WC: $%.2f' % odoo_price,
)
except Exception as e:
errors.append('%s: %s' % (rec.woo_product_name, str(e)))
if errors:
raise UserError('\n'.join(errors))
def action_set_regular_price(self, price):
"""Set the WC standard (regular) price directly."""
self.ensure_one()
if not self.instance_id:
return
client = self.instance_id._get_client()
# If there's a sale price, regular must be >= sale
if self.woo_sale_price and price < self.woo_sale_price - 0.01:
raise UserError(
'Standard price ($%.2f) cannot be less than the current sale price ($%.2f).'
% (price, self.woo_sale_price)
)
client.update_product(self.woo_product_id, {'regular_price': str(price)})
self.woo_regular_price = price
self.instance_id._log_sync(
'product', 'odoo_to_woo', self.woo_product_name, 'success',
'Standard price set to $%.2f' % price,
)
def action_set_sale_price(self, price):
"""Set the WC sale price directly."""
self.ensure_one()
if not self.instance_id:
return
client = self.instance_id._get_client()
# Sale price cannot exceed regular price
if self.woo_regular_price and price > self.woo_regular_price + 0.01:
raise UserError(
'Sale price ($%.2f) cannot exceed the standard price ($%.2f).'
% (price, self.woo_regular_price)
)
update_data = {'sale_price': str(price) if price > 0 else ''}
client.update_product(self.woo_product_id, update_data)
self.woo_sale_price = price
self.instance_id._log_sync(
'product', 'odoo_to_woo', self.woo_product_name, 'success',
'Sale price set to $%.2f' % price,
)
# ------------------------------------------------------------------
# Push Variants to WooCommerce
# ------------------------------------------------------------------
def action_push_variants_to_wc(self):
"""Convert a simple WC product to variable and create variations from Odoo variants."""
self.ensure_one()
if not self.product_id or not self.instance_id:
raise UserError("Product or instance not set.")
tmpl = self.product_id.product_tmpl_id
variants = tmpl.product_variant_ids
if len(variants) <= 1:
raise UserError("This product has no variants in Odoo.")
client = self.instance_id._get_client()
inst = self.instance_id
# Step 1: Build WC attributes from Odoo attribute lines
wc_attributes = []
for attr_line in tmpl.attribute_line_ids:
attr_name = attr_line.attribute_id.name
attr_values = attr_line.value_ids.mapped('name')
# Find or create WC attribute
wc_attr = self._find_or_create_wc_attribute(client, attr_name)
# Create terms for each value
wc_terms = []
for val_name in attr_values:
term = self._find_or_create_wc_attribute_term(client, wc_attr['id'], val_name)
wc_terms.append(term['name'])
wc_attributes.append({
'id': wc_attr['id'],
'name': attr_name,
'position': 0,
'visible': True,
'variation': True,
'options': wc_terms,
})
# Step 2: Update the WC product from simple → variable with attributes
try:
client.update_product(self.woo_product_id, {
'type': 'variable',
'attributes': wc_attributes,
})
self.woo_product_type = 'variable'
except Exception as e:
raise UserError("Failed to convert WC product to variable: %s" % str(e))
# Build WC attribute ID lookup
wc_attr_id_map = {a['name'].upper(): a['id'] for a in wc_attributes}
# Step 3: Create a WC variation for each Odoo variant
created = 0
for variant in variants:
existing = self.search([
('instance_id', '=', inst.id),
('product_id', '=', variant.id),
('is_variation', '=', True),
], limit=1)
if existing:
continue
# Build variation attributes with WC IDs
var_attributes = []
for ptav in variant.product_template_attribute_value_ids:
attr_name = ptav.attribute_id.name
wc_aid = wc_attr_id_map.get(attr_name.upper(), 0)
entry = {'option': ptav.name}
if wc_aid:
entry['id'] = wc_aid
else:
entry['name'] = attr_name
var_attributes.append(entry)
var_data = {
'regular_price': str(variant.list_price),
'sku': variant.default_code or '',
'attributes': var_attributes,
'manage_stock': True,
'stock_quantity': int(variant.qty_available),
}
# Tax class
wc_tax_class = self.env['woo.tax.map'].get_wc_tax_class(inst, variant.taxes_id[:1].id if variant.taxes_id else False)
if wc_tax_class:
var_data['tax_class'] = wc_tax_class
# Variant image — pass Odoo's public URL, WC downloads it directly
if variant.image_variant_1920:
odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
filename = (variant.default_code or 'variant') + '.png'
if odoo_base:
img_url = f"{odoo_base}/web/image/product.product/{variant.id}/image_1920/{filename}"
var_data['image'] = {
'src': img_url,
'name': filename,
'alt': variant.display_name,
}
try:
wc_variation = client.create_product_variation(self.woo_product_id, var_data)
# Create variation product map
self.create({
'instance_id': inst.id,
'product_id': variant.id,
'woo_product_id': wc_variation['id'],
'woo_product_name': variant.display_name,
'woo_sku': variant.default_code or '',
'woo_regular_price': variant.list_price,
'woo_sale_price': 0,
'woo_permalink': self.woo_permalink or '',
'woo_product_type': 'simple',
'woo_parent_id': self.woo_product_id,
'is_variation': True,
'state': 'mapped',
'company_id': inst.company_id.id,
})
created += 1
except Exception as e:
_logger.error("Failed to create variation for %s: %s", variant.display_name, e)
inst._log_sync('product', 'odoo_to_woo', tmpl.name, 'success',
'Pushed %d variants to WC product #%s' % (created, self.woo_product_id))
return True
def _find_or_create_wc_attribute(self, client, attr_name):
"""Find or create a WC product attribute by name."""
try:
attrs = client.get_product_attributes()
for a in attrs:
if a.get('name', '').lower() == attr_name.lower():
return a
except Exception:
pass
return client.create_product_attribute({
'name': attr_name,
'slug': attr_name.lower().replace(' ', '-'),
'type': 'select',
'order_by': 'menu_order',
})
def _find_or_create_wc_attribute_term(self, client, attr_id, term_name):
"""Find or create a WC attribute term."""
try:
terms = client.get_attribute_terms(attr_id)
for t in terms:
if t.get('name', '').lower() == term_name.lower():
return t
except Exception:
pass
return client.create_attribute_term(attr_id, {'name': term_name})
# ------------------------------------------------------------------
# Create in Odoo (from unmapped WC product)
# ------------------------------------------------------------------
def action_create_in_odoo(self):
"""Create an Odoo product from WC mapping data, link the mapping, and
return the new product ID so the JS can open the form."""
self.ensure_one()
if self.product_id:
raise UserError("This mapping already has an Odoo product linked.")
wc_price = self.woo_sale_price or self.woo_regular_price or 0.0
# Resolve Odoo category from WC category mapping
categ_id = False
if self.woo_category_id and self.instance_id:
cat_map = self.env['woo.category.map'].search([
('instance_id', '=', self.instance_id.id),
('woo_category_id', '=', self.woo_category_id),
('odoo_category_id', '!=', False),
], limit=1)
if cat_map:
categ_id = cat_map.odoo_category_id.id
product_vals = {
'name': (self.woo_product_name or 'New Product').upper(),
'default_code': self.woo_sku or '',
'list_price': wc_price,
'type': 'consu',
}
if categ_id:
product_vals['categ_id'] = categ_id
product = self.env['product.product'].create(product_vals)
self.write({
'product_id': product.id,
'state': 'mapped',
})
if self.instance_id:
self.instance_id._log_sync(
'product', 'woo_to_odoo', product.name, 'success',
'Created Odoo product from WC #%s' % self.woo_product_id,
)
return {'product_id': product.id}
# ------------------------------------------------------------------
# SKU Sync
# ------------------------------------------------------------------
def action_set_wc_sku(self, sku):
"""Set WC product SKU."""
self.ensure_one()
if not self.instance_id:
return
client = self.instance_id._get_client()
client.update_product(self.woo_product_id, {'sku': sku})
self.woo_sku = sku
self.instance_id._log_sync(
'product', 'odoo_to_woo', self.woo_product_name, 'success',
'WC SKU set to %s' % sku,
)
def action_push_sku_to_odoo(self):
"""Copy WC SKU to Odoo internal reference."""
for rec in self:
if rec.product_id and rec.woo_sku:
rec.product_id.default_code = rec.woo_sku
rec.instance_id._log_sync(
'product', 'woo_to_odoo', rec.product_id.name, 'success',
'Odoo SKU set from WC: %s' % rec.woo_sku,
)
def action_push_sku_to_wc(self):
"""Copy Odoo internal reference to WC SKU."""
for rec in self:
if rec.product_id and rec.instance_id:
sku = rec.product_id.default_code or ''
client = rec.instance_id._get_client()
client.update_product(rec.woo_product_id, {'sku': sku})
rec.woo_sku = sku
rec.instance_id._log_sync(
'product', 'odoo_to_woo', rec.product_id.name, 'success',
'WC SKU set from Odoo: %s' % sku,
)
# ------------------------------------------------------------------
# Image Sync (Task 22)
# ------------------------------------------------------------------
def action_sync_images(self):
"""Sync product images between Odoo and WooCommerce."""
for pm in self:
if not pm.sync_images or not pm.product_id or pm.state != 'mapped':
continue
try:
pm._sync_images_single()
except Exception as e:
_logger.error(
"Image sync failed for %s (WC#%s): %s",
pm.product_id.display_name, pm.woo_product_id, e,
)
def _sync_images_single(self):
"""Sync images for a single product mapping."""
self.ensure_one()
client = self.instance_id._get_client()
wc_product = client.get_product(self.woo_product_id)
wc_images = wc_product.get('images', [])
# Get Odoo product image hash
odoo_image = self.product_id.image_1920
odoo_hash = ''
if odoo_image:
odoo_hash = hashlib.md5(base64.b64decode(odoo_image)).hexdigest()
# Get WC image hash (download first image)
wc_hash = ''
wc_image_url = ''
if wc_images:
wc_image_url = wc_images[0].get('src', '')
if wc_image_url:
try:
resp = requests.get(wc_image_url, timeout=15)
if resp.status_code == 200:
wc_hash = hashlib.md5(resp.content).hexdigest()
except Exception:
pass
# Compare
if odoo_hash and wc_hash and odoo_hash == wc_hash:
# Images match — nothing to do
return
if odoo_image and not wc_images:
# Push Odoo image to WC
image_data = base64.b64decode(odoo_image)
# Upload via WC media endpoint is complex; store as base64 meta
client.update_product(self.woo_product_id, {
'images': [{'src': '', 'name': self.product_id.name}],
})
_logger.info("Image push for %s — WC images updated", self.product_id.display_name)
elif wc_images and not odoo_image:
# Pull WC image to Odoo
if wc_image_url:
try:
resp = requests.get(wc_image_url, timeout=15)
if resp.status_code == 200:
self.product_id.image_1920 = base64.b64encode(resp.content)
except Exception as e:
_logger.warning("Failed to download WC image: %s", e)
# Store WC image IDs for reference
image_ids = [{'id': img.get('id'), 'src': img.get('src', '')} for img in wc_images]
self.woo_image_ids = json.dumps(image_ids)
self.last_synced = fields.Datetime.now()

View File

@@ -1,210 +0,0 @@
import logging
from odoo import fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class WooReturn(models.Model):
_name = 'woo.return'
_description = 'WooCommerce Return'
_rec_name = 'order_id'
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
order_id = fields.Many2one('woo.order', required=True)
picking_id = fields.Many2one('stock.picking')
reason = fields.Text()
line_ids = fields.One2many('woo.return.line', 'return_id')
state = fields.Selection([
('requested', 'Requested'),
('approved', 'Approved'),
('received', 'Received'),
('refunded', 'Refunded'),
('rejected', 'Rejected'),
], default='requested')
company_id = fields.Many2one(
'res.company', required=True, default=lambda self: self.env.company,
)
# ------------------------------------------------------------------
# Workflow methods (Task 24)
# ------------------------------------------------------------------
def action_approve(self):
"""Approve the return — create a reverse stock.picking."""
self.ensure_one()
if self.state != 'requested':
raise UserError("Only requested returns can be approved.")
sale_order = self.order_id.sale_order_id
if not sale_order:
raise UserError("No sale order linked to this WC order.")
# Find the outbound delivery picking
delivery_picking = self.env['stock.picking'].search([
('origin', '=', sale_order.name),
('picking_type_code', '=', 'outgoing'),
('state', '=', 'done'),
], limit=1)
if delivery_picking:
# Create return picking via stock.return.picking wizard
return_wizard = self.env['stock.return.picking'].with_context(
active_id=delivery_picking.id,
active_model='stock.picking',
).create({})
# Update quantities based on return lines
for wizard_line in return_wizard.product_return_moves:
return_line = self.line_ids.filtered(
lambda l: l.product_id == wizard_line.product_id
)
if return_line:
wizard_line.quantity = return_line[0].quantity
else:
wizard_line.quantity = 0
# Remove lines with 0 quantity
return_wizard.product_return_moves.filtered(
lambda l: l.quantity == 0
).unlink()
if return_wizard.product_return_moves:
result = return_wizard.action_create_returns()
if result and result.get('res_id'):
self.picking_id = result['res_id']
self.state = 'approved'
# Push status to WC
try:
client = self.instance_id._get_client()
client.update_order(self.order_id.woo_order_id, {
'meta_data': [
{'key': '_return_status', 'value': 'approved'},
]
})
except Exception as e:
_logger.warning("Failed to push return approval to WC: %s", e)
self.instance_id._log_sync(
'order', 'odoo_to_woo', self.order_id.sale_order_id.name,
'success', 'Return approved',
)
def action_reject(self):
"""Reject the return request."""
self.ensure_one()
if self.state != 'requested':
raise UserError("Only requested returns can be rejected.")
self.state = 'rejected'
try:
client = self.instance_id._get_client()
client.update_order(self.order_id.woo_order_id, {
'meta_data': [
{'key': '_return_status', 'value': 'rejected'},
]
})
except Exception as e:
_logger.warning("Failed to push return rejection to WC: %s", e)
self.instance_id._log_sync(
'order', 'odoo_to_woo', self.order_id.sale_order_id.name,
'success', 'Return rejected',
)
def action_receive(self):
"""Mark return items as received."""
self.ensure_one()
if self.state != 'approved':
raise UserError("Only approved returns can be marked as received.")
# Validate the return picking if it exists
if self.picking_id and self.picking_id.state not in ('done', 'cancel'):
self.picking_id.button_validate()
self.state = 'received'
def action_refund(self):
"""Create a credit note and sync refund to WooCommerce."""
self.ensure_one()
if self.state not in ('received', 'approved'):
raise UserError("Return must be received or approved before refunding.")
invoice = self.order_id.invoice_id
if not invoice:
raise UserError("No invoice found for this WC order.")
# Create credit note (reversal)
reversal_wizard = self.env['account.move.reversal'].with_context(
active_model='account.move',
active_ids=[invoice.id],
).create({
'reason': self.reason or 'WooCommerce Return',
'journal_id': invoice.journal_id.id,
})
reversal_result = reversal_wizard.reverse_moves()
credit_note = False
if reversal_result and reversal_result.get('res_id'):
credit_note = self.env['account.move'].browse(reversal_result['res_id'])
elif reversal_result and reversal_result.get('domain'):
credit_note = self.env['account.move'].search(
reversal_result['domain'], limit=1,
)
if credit_note:
credit_note.action_post()
# Sync refund to WC
try:
client = self.instance_id._get_client()
# Calculate refund amount from return lines
refund_amount = sum(
line.quantity * line.product_id.list_price
for line in self.line_ids
)
refund_data = {
'amount': str(refund_amount),
'reason': self.reason or 'Return/Refund',
}
# Create WC refund
client.post(
f'orders/{self.order_id.woo_order_id}/refunds',
refund_data,
)
client.update_order(self.order_id.woo_order_id, {
'meta_data': [
{'key': '_return_status', 'value': 'refunded'},
]
})
except Exception as e:
_logger.error("Failed to sync refund to WC: %s", e)
self.state = 'refunded'
self.instance_id._log_sync(
'order', 'odoo_to_woo', self.order_id.sale_order_id.name,
'success', f'Refund processed',
)
class WooReturnLine(models.Model):
_name = 'woo.return.line'
_description = 'WooCommerce Return Line'
return_id = fields.Many2one('woo.return', required=True, ondelete='cascade')
product_id = fields.Many2one('product.product', required=True)
quantity = fields.Float(default=1.0)
reason = fields.Selection([
('defective', 'Defective'),
('wrong_item', 'Wrong Item'),
('not_needed', 'Not Needed'),
('damaged', 'Damaged'),
('other', 'Other'),
])
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
)

View File

@@ -1,18 +0,0 @@
from odoo import fields, models
class WooShipment(models.Model):
_name = 'woo.shipment'
_description = 'WooCommerce Shipment'
_rec_name = 'tracking_number'
order_id = fields.Many2one('woo.order', required=True, ondelete='cascade')
picking_id = fields.Many2one('stock.picking')
carrier_id = fields.Many2one('woo.shipping.carrier')
tracking_number = fields.Char()
shipped_date = fields.Datetime()
is_backorder = fields.Boolean()
synced_to_woo = fields.Boolean()
company_id = fields.Many2one(
'res.company', required=True, default=lambda self: self.env.company,
)

View File

@@ -1,11 +0,0 @@
from odoo import fields, models
class WooShippingCarrier(models.Model):
_name = 'woo.shipping.carrier'
_description = 'WooCommerce Shipping Carrier'
name = fields.Char(required=True)
code = fields.Char(required=True)
tracking_url = fields.Char(help='Use {tracking} as placeholder')
active = fields.Boolean(default=True)

View File

@@ -1,88 +0,0 @@
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
SUCCESS_RETENTION_DAYS = 30
ERROR_RETENTION_DAYS = 90
class WooSyncLog(models.Model):
_name = 'woo.sync.log'
_description = 'WooCommerce Sync Log'
_order = 'create_date desc'
_rec_name = 'record_ref'
instance_id = fields.Many2one('woo.instance', ondelete='cascade')
sync_type = fields.Selection([
('product', 'Product'),
('order', 'Order'),
('invoice', 'Invoice'),
('inventory', 'Inventory'),
('customer', 'Customer'),
])
direction = fields.Selection([
('odoo_to_woo', 'Odoo \u2192 WooCommerce'),
('woo_to_odoo', 'WooCommerce \u2192 Odoo'),
])
record_ref = fields.Char()
state = fields.Selection([
('success', 'Success'),
('failed', 'Failed'),
('conflict', 'Conflict'),
])
message = fields.Text()
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
)
@api.model
def _cron_cleanup_logs(self):
"""Purge success/conflict logs older than 30 days, errors older than 90."""
now = fields.Datetime.now()
cutoff_success = fields.Datetime.subtract(now, days=SUCCESS_RETENTION_DAYS)
cutoff_error = fields.Datetime.subtract(now, days=ERROR_RETENTION_DAYS)
logs = self.search([
'|',
'&', ('state', '!=', 'failed'), ('create_date', '<', cutoff_success),
'&', ('state', '=', 'failed'), ('create_date', '<', cutoff_error),
])
count = len(logs)
if count:
logs.unlink()
_logger.info("WooCommerce: purged %d old sync log entries", count)
def action_purge_old_logs(self):
"""Manual purge: delete success logs > 7 days, error logs > 30 days."""
self.env['woo.sync.log'].check_access_rights('unlink')
now = fields.Datetime.now()
cutoff_success = fields.Datetime.subtract(now, days=7)
cutoff_error = fields.Datetime.subtract(now, days=30)
logs = self.env['woo.sync.log'].search([
'|',
'&', ('state', '!=', 'failed'), ('create_date', '<', cutoff_success),
'&', ('state', '=', 'failed'), ('create_date', '<', cutoff_error),
])
count = len(logs)
logs.unlink()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Sync Logs Purged',
'message': f'{count} old log entries deleted.',
'type': 'success',
'next': {'type': 'ir.actions.act_window_close'},
},
}
@api.model
def action_clear_errors(self):
"""Clear all failed sync log entries. Called from dashboard."""
self.check_access_rights('unlink')
logs = self.search([('state', '=', 'failed')])
count = len(logs)
logs.unlink()
_logger.info("WooCommerce: manually cleared %d error log entries", count)
return count

View File

@@ -1,53 +0,0 @@
from odoo import api, fields, models
class WooTaxMap(models.Model):
_name = 'woo.tax.map'
_description = 'WooCommerce Tax Mapping'
_rec_name = 'woo_tax_class_name'
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
tax_id = fields.Many2one('account.tax', string='Odoo Tax')
woo_tax_class = fields.Char(required=True)
woo_tax_class_name = fields.Char()
company_id = fields.Many2one(
'res.company', required=True, default=lambda self: self.env.company,
)
# ------------------------------------------------------------------
# Lookup helpers (Task 26)
# ------------------------------------------------------------------
@api.model
def get_odoo_tax(self, instance, wc_tax_class):
"""Return the Odoo account.tax mapped to a WC tax class.
Args:
instance: woo.instance record
wc_tax_class: WC tax class slug (e.g. 'standard', 'reduced-rate')
Returns:
account.tax record or empty recordset
"""
mapping = self.search([
('instance_id', '=', instance.id),
('woo_tax_class', '=', wc_tax_class),
], limit=1)
return mapping.tax_id if mapping else self.env['account.tax']
@api.model
def get_wc_tax_class(self, instance, tax_id):
"""Return the WC tax class slug mapped to an Odoo tax.
Args:
instance: woo.instance record
tax_id: account.tax record id
Returns:
str: WC tax class slug, or empty string if not mapped
"""
mapping = self.search([
('instance_id', '=', instance.id),
('tax_id', '=', tax_id),
], limit=1)
return mapping.woo_tax_class if mapping else ''

View File

@@ -1,34 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_woo_instance_user,woo.instance.user,model_woo_instance,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_instance_manager,woo.instance.manager,model_woo_instance,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_shipping_carrier_user,woo.shipping.carrier.user,model_woo_shipping_carrier,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_shipping_carrier_manager,woo.shipping.carrier.manager,model_woo_shipping_carrier,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_product_map_user,woo.product.map.user,model_woo_product_map,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_product_map_manager,woo.product.map.manager,model_woo_product_map,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_order_user,woo.order.user,model_woo_order,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_order_manager,woo.order.manager,model_woo_order,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_shipment_user,woo.shipment.user,model_woo_shipment,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_shipment_manager,woo.shipment.manager,model_woo_shipment,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_customer_user,woo.customer.user,model_woo_customer,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_customer_manager,woo.customer.manager,model_woo_customer,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_sync_log_user,woo.sync.log.user,model_woo_sync_log,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_sync_log_manager,woo.sync.log.manager,model_woo_sync_log,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_conflict_user,woo.conflict.user,model_woo_conflict,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_conflict_manager,woo.conflict.manager,model_woo_conflict,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_tax_map_user,woo.tax.map.user,model_woo_tax_map,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_tax_map_manager,woo.tax.map.manager,model_woo_tax_map,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_pricelist_map_user,woo.pricelist.map.user,model_woo_pricelist_map,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_pricelist_map_manager,woo.pricelist.map.manager,model_woo_pricelist_map,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_return_user,woo.return.user,model_woo_return,fusion_woocommerce.group_woo_user,1,0,0,0
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_category_map_user,woo.category.map.user,model_woo_category_map,fusion_woocommerce.group_woo_user,1,0,0,0
access_woo_category_map_manager,woo.category.map.manager,model_woo_category_map,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
access_woo_product_create_wizard_manager,woo.product.create.wizard.manager,model_woo_product_create_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_category_filter_manager,woo.category.filter.manager,model_woo_category_filter,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_product_create_variant_line_manager,woo.product.create.variant.line.manager,model_woo_product_create_variant_line,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_variant_push_wizard_manager,woo.variant.push.wizard.manager,model_woo_variant_push_wizard,fusion_woocommerce.group_woo_manager,1,1,1,1
access_woo_variant_push_line_manager,woo.variant.push.line.manager,model_woo_variant_push_line,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
2 access_woo_instance_user woo.instance.user model_woo_instance fusion_woocommerce.group_woo_user 1 0 0 0
3 access_woo_instance_manager woo.instance.manager model_woo_instance fusion_woocommerce.group_woo_manager 1 1 1 1
4 access_woo_shipping_carrier_user woo.shipping.carrier.user model_woo_shipping_carrier fusion_woocommerce.group_woo_user 1 0 0 0
5 access_woo_shipping_carrier_manager woo.shipping.carrier.manager model_woo_shipping_carrier fusion_woocommerce.group_woo_manager 1 1 1 1
6 access_woo_product_map_user woo.product.map.user model_woo_product_map fusion_woocommerce.group_woo_user 1 0 0 0
7 access_woo_product_map_manager woo.product.map.manager model_woo_product_map fusion_woocommerce.group_woo_manager 1 1 1 1
8 access_woo_order_user woo.order.user model_woo_order fusion_woocommerce.group_woo_user 1 0 0 0
9 access_woo_order_manager woo.order.manager model_woo_order fusion_woocommerce.group_woo_manager 1 1 1 1
10 access_woo_shipment_user woo.shipment.user model_woo_shipment fusion_woocommerce.group_woo_user 1 0 0 0
11 access_woo_shipment_manager woo.shipment.manager model_woo_shipment fusion_woocommerce.group_woo_manager 1 1 1 1
12 access_woo_customer_user woo.customer.user model_woo_customer fusion_woocommerce.group_woo_user 1 0 0 0
13 access_woo_customer_manager woo.customer.manager model_woo_customer fusion_woocommerce.group_woo_manager 1 1 1 1
14 access_woo_sync_log_user woo.sync.log.user model_woo_sync_log fusion_woocommerce.group_woo_user 1 0 0 0
15 access_woo_sync_log_manager woo.sync.log.manager model_woo_sync_log fusion_woocommerce.group_woo_manager 1 1 1 1
16 access_woo_conflict_user woo.conflict.user model_woo_conflict fusion_woocommerce.group_woo_user 1 0 0 0
17 access_woo_conflict_manager woo.conflict.manager model_woo_conflict fusion_woocommerce.group_woo_manager 1 1 1 1
18 access_woo_tax_map_user woo.tax.map.user model_woo_tax_map fusion_woocommerce.group_woo_user 1 0 0 0
19 access_woo_tax_map_manager woo.tax.map.manager model_woo_tax_map fusion_woocommerce.group_woo_manager 1 1 1 1
20 access_woo_pricelist_map_user woo.pricelist.map.user model_woo_pricelist_map fusion_woocommerce.group_woo_user 1 0 0 0
21 access_woo_pricelist_map_manager woo.pricelist.map.manager model_woo_pricelist_map fusion_woocommerce.group_woo_manager 1 1 1 1
22 access_woo_return_user woo.return.user model_woo_return fusion_woocommerce.group_woo_user 1 0 0 0
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_category_map_user woo.category.map.user model_woo_category_map fusion_woocommerce.group_woo_user 1 0 0 0
27 access_woo_category_map_manager woo.category.map.manager model_woo_category_map fusion_woocommerce.group_woo_manager 1 1 1 1
28 access_woo_setup_wizard_manager woo.setup.wizard.manager model_woo_setup_wizard fusion_woocommerce.group_woo_manager 1 1 1 1
29 access_woo_product_fetch_manager woo.product.fetch.manager model_woo_product_fetch fusion_woocommerce.group_woo_manager 1 1 1 1
30 access_woo_product_create_wizard_manager woo.product.create.wizard.manager model_woo_product_create_wizard fusion_woocommerce.group_woo_manager 1 1 1 1
31 access_woo_category_filter_manager woo.category.filter.manager model_woo_category_filter fusion_woocommerce.group_woo_manager 1 1 1 1
32 access_woo_product_create_variant_line_manager woo.product.create.variant.line.manager model_woo_product_create_variant_line fusion_woocommerce.group_woo_manager 1 1 1 1
33 access_woo_variant_push_wizard_manager woo.variant.push.wizard.manager model_woo_variant_push_wizard fusion_woocommerce.group_woo_manager 1 1 1 1
34 access_woo_variant_push_line_manager woo.variant.push.line.manager model_woo_variant_push_line fusion_woocommerce.group_woo_manager 1 1 1 1

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_woocommerce" model="ir.module.category">
<field name="name">WooCommerce</field>
<field name="sequence">50</field>
</record>
<record id="group_woo_user" model="res.groups">
<field name="name">WooCommerce User</field>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="group_woo_manager" model="res.groups">
<field name="name">WooCommerce Manager</field>
<field name="implied_ids" eval="[(4, ref('group_woo_user'))]"/>
</record>
<!-- Auto-grant WooCommerce Manager to every internal user so the module
works out of the box without permission errors. Admins can revoke
later by removing users from this implied group. -->
<record id="base.group_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_woo_manager'))]"/>
</record>
</odoo>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -1,411 +0,0 @@
/* ============================================================
Fusion WooCommerce — Layout-Only Styles
All colors are inherited from Odoo's compiled theme.
No CSS custom properties for colors — Odoo handles theming
via SCSS compilation, not Bootstrap's data-bs-theme.
============================================================ */
/* ----------------------------------------------------------
Tab navigation
---------------------------------------------------------- */
.woo-tabs {
display: flex;
gap: 4px;
border-bottom: 2px solid;
border-color: inherit;
margin-bottom: 16px;
}
.woo-tab {
padding: 8px 20px;
cursor: pointer;
font-weight: 500;
opacity: 0.6;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
background: none;
color: inherit;
transition: opacity 0.15s, border-color 0.15s;
}
.woo-tab:hover { opacity: 0.85; }
.woo-tab.active {
opacity: 1;
font-weight: 600;
border-bottom-color: currentColor;
}
/* ----------------------------------------------------------
Top bar / stats
---------------------------------------------------------- */
.woo-topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid;
border-color: inherit;
flex-wrap: wrap;
}
.woo-topbar select,
.woo-topbar input {
border: 1px solid;
border-color: inherit;
border-radius: 6px;
padding: 6px 10px;
font-size: 0.875rem;
background: inherit;
color: inherit;
}
.woo-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 14px;
border-left: 1px solid;
border-color: inherit;
}
.woo-stat-value {
font-size: 1.25rem;
font-weight: 700;
}
.woo-stat-label {
font-size: 0.7rem;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ----------------------------------------------------------
Search bar
---------------------------------------------------------- */
.woo-search-wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.woo-search-wrap .woo-search-icon {
position: absolute;
left: 10px;
opacity: 0.5;
font-size: 14px;
pointer-events: none;
}
.woo-search-input {
padding: 6px 10px 6px 32px;
border: 1px solid;
border-color: inherit;
border-radius: 6px;
font-size: 0.875rem;
width: 240px;
background: inherit;
color: inherit;
}
.woo-search-input:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
.woo-search-input::placeholder { opacity: 0.5; }
/* ----------------------------------------------------------
Tables
---------------------------------------------------------- */
.woo-table-wrap {
overflow-x: auto;
border: 1px solid;
border-color: inherit;
border-radius: 6px;
}
.woo-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
margin-bottom: 0;
}
.woo-table th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid;
border-color: inherit;
white-space: nowrap;
opacity: 0.85;
}
.woo-table td {
padding: 9px 12px;
border-bottom: 1px solid;
border-color: inherit;
vertical-align: middle;
}
.woo-table tbody tr:last-child td { border-bottom: none; }
.woo-table tr:hover td { opacity: 0.9; }
.woo-table tr.selected td { font-weight: 500; }
/* ----------------------------------------------------------
Split view (unmatched tab)
---------------------------------------------------------- */
.woo-split {
display: grid;
grid-template-columns: 1fr 40px 1fr;
gap: 0;
align-items: start;
}
.woo-split-panel {
border: 1px solid;
border-color: inherit;
border-radius: 8px;
overflow: hidden;
}
.woo-split-panel-header {
padding: 10px 14px;
font-weight: 600;
border-bottom: 1px solid;
border-color: inherit;
display: flex;
justify-content: space-between;
align-items: center;
}
.woo-split-divider {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 60px;
gap: 8px;
opacity: 0.5;
font-size: 1.2rem;
}
.woo-split-list {
max-height: 480px;
overflow-y: auto;
}
.woo-split-item {
padding: 10px 14px;
cursor: pointer;
border-bottom: 1px solid;
border-color: inherit;
transition: opacity 0.1s;
}
.woo-split-item:last-child { border-bottom: none; }
.woo-split-item:hover { opacity: 0.8; }
.woo-split-item.selected { font-weight: 600; }
.woo-split-item-name { font-weight: 500; }
.woo-split-item-sub { font-size: 0.75rem; opacity: 0.6; margin-top: 1px; }
/* ----------------------------------------------------------
Map actions bar
---------------------------------------------------------- */
.woo-map-actions {
display: flex;
gap: 8px;
padding: 10px 0 14px;
flex-wrap: wrap;
border-bottom: 1px solid;
border-color: inherit;
margin-bottom: 14px;
}
/* ----------------------------------------------------------
Dashboard cards
---------------------------------------------------------- */
.woo-dashboard { padding: 20px; }
.woo-dashboard-title {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 4px;
}
.woo-dashboard-subtitle {
font-size: 0.875rem;
opacity: 0.6;
margin-bottom: 24px;
}
.woo-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.woo-card {
border: 1px solid;
border-color: inherit;
border-radius: 10px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
.woo-card-clickable { cursor: pointer; }
.woo-card-clickable:hover { opacity: 0.85; }
.woo-card-icon { font-size: 1.6rem; margin-bottom: 4px; }
.woo-card-value { font-size: 2rem; font-weight: 700; line-height: 1; }
.woo-card-label {
font-size: 0.8rem;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.woo-card-sub { font-size: 0.78rem; opacity: 0.5; margin-top: 2px; }
.woo-card-pending { border-left-width: 4px; border-left-style: solid; }
.woo-card-errors { border-left-width: 4px; border-left-style: solid; }
.woo-card-mapped { border-left-width: 4px; border-left-style: solid; }
.woo-card-sync { border-left-width: 4px; border-left-style: solid; }
/* ----------------------------------------------------------
Progress bar
---------------------------------------------------------- */
.woo-progress-wrap {
border-radius: 6px;
height: 8px;
overflow: hidden;
margin-top: 6px;
opacity: 0.15;
background: currentColor;
}
.woo-progress-bar {
height: 100%;
border-radius: 6px;
transition: width 0.4s ease;
}
/* ----------------------------------------------------------
Loading spinner
---------------------------------------------------------- */
.woo-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
opacity: 0.6;
gap: 10px;
font-size: 0.9rem;
}
.woo-spinner {
width: 20px;
height: 20px;
border: 2px solid currentColor;
opacity: 0.3;
border-top-color: currentColor;
border-radius: 50%;
animation: woo-spin 0.7s linear infinite;
}
@keyframes woo-spin { to { transform: rotate(360deg); } }
/* ----------------------------------------------------------
Empty states
---------------------------------------------------------- */
.woo-empty {
text-align: center;
padding: 48px 20px;
opacity: 0.5;
}
.woo-empty-icon { font-size: 2.5rem; margin-bottom: 10px; }
.woo-empty-text { font-size: 0.9rem; }
/* ----------------------------------------------------------
Quick actions
---------------------------------------------------------- */
.woo-quick-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
/* ----------------------------------------------------------
Section header
---------------------------------------------------------- */
.woo-section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid;
border-color: inherit;
}
/* ----------------------------------------------------------
Checkbox column
---------------------------------------------------------- */
.woo-table th.woo-check-col,
.woo-table td.woo-check-col {
width: 36px;
text-align: center;
}
/* ----------------------------------------------------------
Utility classes
---------------------------------------------------------- */
.woo-code {
font-family: monospace;
font-size: 0.85em;
padding: 1px 6px;
border-radius: 4px;
opacity: 0.8;
}
/* ----------------------------------------------------------
Pagination
---------------------------------------------------------- */
.woo-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 0;
border-top: 1px solid;
border-color: inherit;
margin-top: 4px;
}
.woo-pagination-info {
font-size: 0.85rem;
opacity: 0.6;
}
/* ----------------------------------------------------------
Icon buttons (sync arrows)
---------------------------------------------------------- */
.woo-btn-icon {
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
opacity: 0.5;
font-size: 0.8rem;
border-radius: 4px;
color: inherit;
transition: opacity 0.15s;
}
.woo-btn-icon:hover { opacity: 1; }
/* Product link to WooCommerce */
.woo-product-link { text-decoration: none; }
.woo-product-link:hover { text-decoration: underline; }
.woo-external-icon { font-size: 0.7rem; opacity: 0.5; }
.woo-product-link:hover .woo-external-icon { opacity: 1; }
/* Sale price highlight */
.woo-sale-price { font-weight: 600; }
.woo-price-sync-col { width: 60px; white-space: nowrap; }
/* ----------------------------------------------------------
Editable price cells
---------------------------------------------------------- */
.woo-editable-cell { cursor: pointer; position: relative; }
.woo-editable-cell:hover { opacity: 0.8; }
.woo-margin-cell { font-weight: 600; }
.woo-edit-input {
width: 90px;
padding: 2px 6px;
border: 2px solid;
border-color: inherit;
border-radius: 4px;
font-size: 0.85rem;
text-align: right;
background: inherit;
color: inherit;
outline: none;
}
.woo-edit-input-text { text-align: left; width: 120px; }
/* Clear errors button inside dashboard card */
.woo-clear-btn { font-size: 0.72rem; padding: 1px 8px; }

View File

@@ -1,47 +0,0 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
/**
* AjaxSearch — reusable debounced search component.
*
* Props:
* endpoint {String} The /woo/search/* URL to POST to.
* instanceId {Number} woo.instance ID (optional).
* onResults {Function} Callback receives the results array.
* placeholder {String} Input placeholder text.
*/
export class AjaxSearch extends Component {
static template = "fusion_woocommerce.AjaxSearch";
static props = ["endpoint", "onResults", "*"];
setup() {
this.state = useState({ query: "" });
this._debounceTimer = null;
}
onInput(ev) {
const query = ev.target.value;
this.state.query = query;
clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => {
this._doSearch(query);
}, 300);
}
async _doSearch(query) {
try {
const params = { query };
if (this.props.instanceId) {
params.instance_id = this.props.instanceId;
}
const results = await rpc(this.props.endpoint, params);
this.props.onResults(results || []);
} catch (err) {
console.error("[AjaxSearch] search error:", err);
this.props.onResults([]);
}
}
}

View File

@@ -1,232 +0,0 @@
/** @odoo-module **/
import { Component, useState, onWillStart, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { rpc } from "@web/core/network/rpc";
/**
* WooDashboard — OWL client action for the WooCommerce sync dashboard.
*
* Shows:
* - Orders pending sync
* - Last sync time (relative)
* - Errors in last 24 h
* - Products mapped / unmapped (progress bar)
* - Quick actions: Sync Now, View Conflicts, Open Mapping
*/
export class WooDashboard extends Component {
static template = "fusion_woocommerce.Dashboard";
static props = ["action", "*"];
setup() {
this.actionService = useService("action");
this.notification = useService("notification");
this.state = useState({
loading: true,
// Stats
pendingOrders: 0,
lastSync: null,
errors24h: 0,
mappedCount: 0,
totalProducts: 0,
// Instances
instances: [],
});
onWillStart(async () => {
await this._loadDashboard();
});
// Refresh every 60 s while mounted
this._refreshInterval = null;
onMounted(() => {
this._refreshInterval = setInterval(() => {
this._loadDashboard();
}, 60000);
});
}
// -------------------------------------------------------------------------
// Data
// -------------------------------------------------------------------------
async _loadDashboard() {
try {
await Promise.all([
this._loadInstances(),
this._loadPendingOrders(),
this._loadErrors(),
this._loadProductStats(),
this._loadLastSync(),
]);
} catch (err) {
console.error("[WooDashboard] _loadDashboard error:", err);
} finally {
this.state.loading = false;
}
}
async _loadInstances() {
const result = await rpc("/web/dataset/call_kw", {
model: "woo.instance",
method: "search_read",
args: [[]],
kwargs: { fields: ["id", "name", "state", "last_sync"], limit: 20 },
});
this.state.instances = result || [];
}
async _loadPendingOrders() {
const count = await rpc("/web/dataset/call_kw", {
model: "woo.order",
method: "search_count",
args: [[["state", "in", ["new", "confirmed"]]]],
kwargs: {},
});
this.state.pendingOrders = count || 0;
}
async _loadErrors() {
// Errors from woo.sync.log in the last 24 h
const since = new Date(Date.now() - 24 * 3600 * 1000);
const sinceStr = since.toISOString().replace("T", " ").substring(0, 19);
const count = await rpc("/web/dataset/call_kw", {
model: "woo.sync.log",
method: "search_count",
args: [[
["state", "=", "failed"],
["create_date", ">=", sinceStr],
]],
kwargs: {},
});
this.state.errors24h = count || 0;
}
async _loadProductStats() {
const [mapped, total] = await Promise.all([
rpc("/web/dataset/call_kw", {
model: "woo.product.map",
method: "search_count",
args: [[["state", "=", "mapped"]]],
kwargs: {},
}),
rpc("/web/dataset/call_kw", {
model: "woo.product.map",
method: "search_count",
args: [[]],
kwargs: {},
}),
]);
this.state.mappedCount = mapped || 0;
this.state.totalProducts = total || 0;
}
async _loadLastSync() {
// Get the most recent successful sync log entry
const result = await rpc("/web/dataset/call_kw", {
model: "woo.sync.log",
method: "search_read",
args: [[["state", "=", "success"]]],
kwargs: {
fields: ["create_date"],
limit: 1,
order: "create_date desc",
},
});
if (result && result.length) {
this.state.lastSync = result[0].create_date;
}
}
// -------------------------------------------------------------------------
// Computed / helpers
// -------------------------------------------------------------------------
get mappedPercent() {
if (!this.state.totalProducts) return 0;
return Math.round((this.state.mappedCount / this.state.totalProducts) * 100);
}
get lastSyncRelative() {
if (!this.state.lastSync) return "Never";
const past = new Date(this.state.lastSync.replace(" ", "T") + "Z");
const diffMs = Date.now() - past.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "Just now";
if (diffMin < 60) return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr} hour${diffHr > 1 ? "s" : ""} ago`;
const diffDay = Math.floor(diffHr / 24);
return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`;
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
async syncNow() {
try {
const instanceIds = this.state.instances.map((i) => i.id);
if (!instanceIds.length) {
this.notification.add("No WooCommerce instances configured.", { type: "warning" });
return;
}
await rpc("/web/dataset/call_kw", {
model: "woo.instance",
method: "action_sync",
args: [instanceIds],
kwargs: {},
});
this.notification.add("Sync started for all instances.", { type: "success" });
} catch (err) {
console.error("[WooDashboard] syncNow error:", err);
this.notification.add("Failed to start sync.", { type: "danger" });
}
}
openOrders() {
this.actionService.doAction("fusion_woocommerce.action_woo_order");
}
openSyncLogs() {
this.actionService.doAction({
type: "ir.actions.act_window",
name: "Sync Errors (Last 24 h)",
res_model: "woo.sync.log",
views: [[false, "list"], [false, "form"]],
domain: [["state", "=", "failed"]],
target: "current",
});
}
async clearErrors() {
const count = await rpc("/web/dataset/call_kw", {
model: "woo.sync.log",
method: "action_clear_errors",
args: [],
kwargs: {},
});
this.state.errors24h = 0;
this.notification.add(`${count} error log entries cleared.`, { type: "success" });
}
openConflicts() {
this.actionService.doAction("fusion_woocommerce.action_woo_conflict");
}
openMapping() {
this.actionService.doAction({
type: "ir.actions.client",
tag: "fusion_woocommerce.product_mapping",
name: "Product Mapping",
});
}
}
registry.category("actions").add("fusion_woocommerce.woo_dashboard", WooDashboard);

View File

@@ -1,151 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_woocommerce.Dashboard">
<div class="o_action o_client_action woo-dashboard">
<div class="woo-dashboard-title">WooCommerce Dashboard</div>
<div class="woo-dashboard-subtitle">
At-a-glance sync status across all WooCommerce instances.
</div>
<!-- Loading -->
<t t-if="state.loading">
<div class="woo-loading">
<div class="woo-spinner"/>
Loading dashboard…
</div>
</t>
<t t-else="">
<!-- Stat cards -->
<div class="woo-cards">
<!-- Pending orders -->
<div class="woo-card border-start border-warning border-3 woo-card-clickable"
t-on-click="openOrders">
<div class="woo-card-icon">🛒</div>
<div class="woo-card-value" t-esc="state.pendingOrders"/>
<div class="woo-card-label">Orders Pending Sync</div>
<div class="woo-card-sub">Click to view orders</div>
</div>
<!-- Last sync -->
<div class="woo-card border-start border-info border-3">
<div class="woo-card-icon">🔄</div>
<div class="woo-card-value" style="font-size:1.1rem;" t-esc="lastSyncRelative"/>
<div class="woo-card-label">Last Sync</div>
<div class="woo-card-sub">
<t t-if="state.instances.length">
<t t-esc="state.instances.length"/> instance<t t-if="state.instances.length !== 1">s</t> configured
</t>
<t t-else="">No instances configured</t>
</div>
</div>
<!-- Errors -->
<div class="woo-card border-start border-danger border-3 woo-card-clickable"
t-on-click="openSyncLogs">
<div class="woo-card-icon">⚠️</div>
<div class="woo-card-value" t-esc="state.errors24h"/>
<div class="woo-card-label">Errors (Last 24 h)</div>
<div class="woo-card-sub d-flex align-items-center gap-2">
<span>Click to view sync log</span>
<t t-if="state.errors24h > 0">
<button class="btn btn-sm btn-outline-danger woo-clear-btn"
t-on-click.stop="clearErrors"
title="Clear all error logs">
<i class="fa fa-trash-o"/> Clear
</button>
</t>
</div>
</div>
<!-- Products mapped -->
<div class="woo-card border-start border-success border-3">
<div class="woo-card-icon">🔗</div>
<div class="woo-card-value">
<t t-esc="mappedPercent"/>%
</div>
<div class="woo-card-label">Products Mapped</div>
<div class="woo-progress-wrap">
<div class="woo-progress-bar"
t-att-style="'width:' + mappedPercent + '%'"/>
</div>
<div class="woo-card-sub">
<t t-esc="state.mappedCount"/> / <t t-esc="state.totalProducts"/> products
</div>
</div>
</div>
<!-- Quick actions -->
<div class="woo-section-title">Quick Actions</div>
<div class="woo-quick-actions">
<button class="btn btn-primary" t-on-click="syncNow">
<i class="fa fa-refresh me-1"/> Sync Now
</button>
<button class="btn btn-warning" t-on-click="openConflicts">
<i class="fa fa-exclamation-triangle me-1"/> View Conflicts
</button>
<button class="btn btn-secondary" t-on-click="openMapping">
<i class="fa fa-th-list me-1"/> Open Product Mapping
</button>
<button class="btn btn-secondary" t-on-click="openOrders">
<i class="fa fa-shopping-cart me-1"/> View Orders
</button>
</div>
<!-- Instances table (if any) -->
<t t-if="state.instances.length">
<div class="woo-section-title mt-4">Instances</div>
<div class="woo-table-wrap">
<table class="woo-table">
<thead>
<tr>
<th>Instance</th>
<th>Status</th>
<th>Last Sync</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.instances" t-as="inst" t-key="inst.id">
<tr>
<td><strong><t t-esc="inst.name"/></strong></td>
<td>
<t t-if="inst.state === 'connected'">
<span class="badge text-bg-success">Connected</span>
</t>
<t t-elif="inst.state === 'error'">
<span class="badge text-bg-danger">Error</span>
</t>
<t t-else="">
<span class="badge text-bg-secondary">Draft</span>
</t>
</td>
<td>
<t t-if="inst.last_sync">
<t t-esc="inst.last_sync"/>
</t>
<t t-else="">
<span class="text-muted">Never</span>
</t>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</div>
</t>
</templates>

View File

@@ -1,555 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- ===================================================================
AjaxSearch
=================================================================== -->
<t t-name="fusion_woocommerce.AjaxSearch">
<div class="woo-search-wrap">
<span class="woo-search-icon fa fa-search"/>
<input
type="text"
class="woo-search-input"
t-att-placeholder="props.placeholder or 'Search…'"
t-att-value="state.query"
t-on-input="onInput"
/>
</div>
</t>
<!-- ===================================================================
ProductMapping — main client action
=================================================================== -->
<t t-name="fusion_woocommerce.ProductMapping">
<div class="o_action o_client_action">
<!-- Top bar -->
<div class="woo-topbar">
<!-- Instance selector -->
<select t-on-change="onInstanceChange">
<option value="">All Instances</option>
<t t-foreach="state.instances" t-as="inst" t-key="inst.id">
<option t-att-value="inst.id" t-att-selected="state.instanceId === inst.id">
<t t-esc="inst.name"/>
</option>
</t>
</select>
<!-- Fetch / Sync buttons -->
<button class="btn btn-secondary" t-on-click="fetchProducts"
t-att-disabled="state.loading">
<i class="fa fa-download me-1"/> Fetch Products
</button>
<button class="btn btn-primary" t-on-click="syncNow"
t-att-disabled="state.loading">
<i class="fa fa-refresh me-1"/> Sync Now
</button>
<button class="btn btn-secondary" t-on-click="refreshPrices"
t-att-disabled="state.loading">
<i class="fa fa-dollar me-1"/> Refresh Prices
</button>
<!-- Spacer -->
<div class="flex-grow-1"/>
<!-- Stats -->
<div class="woo-stat">
<span class="woo-stat-value" t-esc="state.mappedCount"/>
<span class="woo-stat-label">Mapped</span>
</div>
<div class="woo-stat">
<span class="woo-stat-value" t-esc="state.unmappedCount"/>
<span class="woo-stat-label">Unmapped</span>
</div>
<div class="woo-stat">
<span class="woo-stat-value" t-esc="state.conflictCount"/>
<span class="woo-stat-label">Conflicts</span>
</div>
</div>
<!-- Loading spinner -->
<t t-if="state.loading">
<div class="woo-loading">
<div class="woo-spinner"/>
Loading…
</div>
</t>
<t t-else="">
<div class="p-3">
<!-- Tab navigation -->
<div class="woo-tabs">
<button class="woo-tab" t-att-class="state.activeTab === 'mapped' ? 'active' : ''"
t-on-click="() => this.setTab('mapped')">
Mapped Products
<span class="ms-1 badge text-bg-success" t-esc="state.mappedTotal"/>
</button>
<button class="woo-tab" t-att-class="state.activeTab === 'unmatched' ? 'active' : ''"
t-on-click="() => this.setTab('unmatched')">
Unmatched Products
<span class="ms-1 badge text-bg-secondary" t-esc="state.unmatchedWooTotal"/>
</button>
<button class="woo-tab" t-att-class="state.activeTab === 'conflicts' ? 'active' : ''"
t-on-click="() => this.setTab('conflicts')">
Conflicts
<span class="ms-1 badge text-bg-warning" t-esc="state.conflictCount"/>
</button>
</div>
<!-- =====================================================
TAB: Mapped Products
===================================================== -->
<t t-if="state.activeTab === 'mapped'">
<div class="woo-map-actions">
<AjaxSearch
endpoint="'/woo/search/mapped'"
t-props="{ instanceId: state.instanceId, onResults: onMappedResults.bind(this), placeholder: 'Search mapped products…' }"
/>
<button class="btn btn-outline-danger btn-sm"
t-on-click="unmapSelected"
t-att-disabled="!state.selectedMapped.length">
<i class="fa fa-unlink me-1"/> Unmap Selected
</button>
<button class="btn btn-success btn-sm"
t-on-click="syncSelected"
t-att-disabled="!state.selectedMapped.length">
<i class="fa fa-refresh me-1"/> Sync Selected
</button>
<button class="btn btn-secondary btn-sm" t-on-click="bulkPriceOdooToWC">
<i class="fa fa-arrow-right me-1"/> All Prices Odoo → WC
</button>
<button class="btn btn-secondary btn-sm" t-on-click="bulkPriceWCToOdoo">
<i class="fa fa-arrow-left me-1"/> All Prices WC → Odoo
</button>
<button class="btn btn-secondary btn-sm" t-on-click="bulkSkuOdooToWC">
<i class="fa fa-arrow-right me-1"/> All SKUs Odoo → WC
</button>
<button class="btn btn-secondary btn-sm" t-on-click="bulkSkuWCToOdoo">
<i class="fa fa-arrow-left me-1"/> All SKUs WC → Odoo
</button>
</div>
<t t-if="!state.mappedProducts.length">
<div class="woo-empty">
<div class="woo-empty-icon">🔗</div>
<div class="woo-empty-text">No mapped products found.</div>
</div>
</t>
<t t-else="">
<div class="woo-table-wrap">
<table class="woo-table">
<thead>
<tr>
<th class="woo-check-col">
<input type="checkbox" t-on-change="toggleSelectAllMapped"/>
</th>
<th>WooCommerce Product</th>
<th>WC SKU</th>
<th class="text-center"><i class="fa fa-exchange" title="SKU Sync"/></th>
<th>Odoo SKU</th>
<th>Odoo Product</th>
<th>WC Standard</th>
<th>WC Sale</th>
<th class="text-center"><i class="fa fa-exchange" title="Price Sync"/></th>
<th>Odoo Price</th>
<th>Cost</th>
<th>Margin %</th>
<th>Instance</th>
<th>Price Sync</th>
<th>Inventory Sync</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.mappedProducts" t-as="p" t-key="p.id">
<tr t-att-class="isMappedSelected(p.id) ? 'selected' : ''">
<td class="woo-check-col">
<input type="checkbox"
t-att-checked="isMappedSelected(p.id)"
t-on-change="() => this.toggleSelectMapped(p.id)"/>
</td>
<td>
<t t-if="p.woo_permalink">
<a t-att-href="p.woo_permalink" target="_blank" class="woo-product-link">
<t t-esc="p.woo_product_name"/>
<i class="fa fa-external-link ms-1 woo-external-icon"/>
</a>
</t>
<t t-else=""><t t-esc="p.woo_product_name"/></t>
</td>
<td class="woo-editable-cell" t-on-click.stop="() => this.startEdit(p.id, 'wc_sku', p.woo_sku)">
<t t-if="this.isEditing(p.id, 'wc_sku')">
<input type="text" class="woo-edit-input woo-edit-input-text"
t-att-value="state.editValue"
t-on-input="onEditInput"
t-on-keydown="onEditKeydown"
t-on-blur="onEditBlur"
/>
</t>
<t t-else=""><span class="woo-code"><t t-esc="p.woo_sku"/></span></t>
</td>
<td class="text-center woo-price-sync-col">
<button class="woo-btn-icon" title="Push WC SKU to Odoo"
t-on-click.stop="() => this.pushSkuToOdoo(p.id)">
<i class="fa fa-arrow-right"/>
</button>
<button class="woo-btn-icon" title="Push Odoo SKU to WC"
t-on-click.stop="() => this.pushSkuToWC(p.id)">
<i class="fa fa-arrow-left"/>
</button>
</td>
<td class="woo-editable-cell" t-on-click.stop="() => this.startEdit(p.id, 'odoo_sku', p.odoo_default_code)">
<t t-if="this.isEditing(p.id, 'odoo_sku')">
<input type="text" class="woo-edit-input woo-edit-input-text"
t-att-value="state.editValue"
t-on-input="onEditInput"
t-on-keydown="onEditKeydown"
t-on-blur="onEditBlur"
/>
</t>
<t t-else=""><span class="woo-code"><t t-esc="p.odoo_default_code"/></span></t>
</td>
<td>
<t t-esc="p.odoo_product_name"/>
<t t-if="p.odoo_variant_count > 1 and !p.is_variation">
<button class="btn btn-sm ms-2"
t-att-class="p.needs_variant_push ? 'btn btn-primary btn-sm ms-2' : 'btn btn-secondary btn-sm ms-2'"
t-on-click.stop="() => this.pushVariantsToWC(p.id)"
title="Manage variants — create new or update existing">
<i class="fa fa-sitemap me-1"/>
<t t-esc="p.odoo_variant_count"/> variants
</button>
</t>
</td>
<td class="text-end woo-editable-cell" t-on-click.stop="() => this.startEdit(p.id, 'woo_regular', p.woo_regular_price)">
<t t-if="this.isEditing(p.id, 'woo_regular')">
<input type="number" step="0.01" min="0" class="woo-edit-input"
t-att-value="state.editValue"
t-on-input="onEditInput"
t-on-keydown="onEditKeydown"
t-on-blur="onEditBlur"
/>
</t>
<t t-else="" t-esc="this.formatPrice(p.woo_regular_price)"/>
</td>
<td class="text-end woo-editable-cell" t-on-click.stop="() => this.startEdit(p.id, 'woo_sale', p.woo_sale_price)">
<t t-if="this.isEditing(p.id, 'woo_sale')">
<input type="number" step="0.01" min="0" class="woo-edit-input"
t-att-value="state.editValue"
t-on-input="onEditInput"
t-on-keydown="onEditKeydown"
t-on-blur="onEditBlur"
/>
</t>
<t t-else="">
<t t-if="p.woo_sale_price">
<span class="text-success fw-bold" t-esc="this.formatPrice(p.woo_sale_price)"/>
</t>
<t t-else=""><span class="text-muted"></span></t>
</t>
</td>
<td class="text-center woo-price-sync-col">
<button class="woo-btn-icon" title="Push WC price to Odoo"
t-on-click.stop="() => this.pushPriceToOdoo(p.id)">
<i class="fa fa-arrow-right"/>
</button>
<button class="woo-btn-icon" title="Push Odoo price to WC"
t-on-click.stop="() => this.pushPriceToWC(p.id)">
<i class="fa fa-arrow-left"/>
</button>
</td>
<td class="text-end woo-editable-cell" t-on-click.stop="() => this.startEdit(p.id, 'odoo_price', p.odoo_price)">
<t t-if="this.isEditing(p.id, 'odoo_price')">
<input type="number" step="0.01" min="0" class="woo-edit-input"
t-att-value="state.editValue"
t-on-input="onEditInput"
t-on-keydown="onEditKeydown"
t-on-blur="onEditBlur"
/>
</t>
<t t-else="" t-esc="this.formatPrice(p.odoo_price)"/>
</td>
<td class="text-end woo-editable-cell" t-on-click.stop="() => this.startEdit(p.id, 'odoo_cost', p.odoo_cost)">
<t t-if="this.isEditing(p.id, 'odoo_cost')">
<input type="number" step="0.01" min="0" class="woo-edit-input"
t-att-value="state.editValue"
t-on-input="onEditInput"
t-on-keydown="onEditKeydown"
t-on-blur="onEditBlur"
/>
</t>
<t t-else="" t-esc="this.formatPrice(p.odoo_cost)"/>
</td>
<td class="text-end woo-editable-cell text-success fw-bold" t-on-click.stop="() => this.startEdit(p.id, 'margin', this.calcMargin(p.odoo_cost, p.odoo_price))">
<t t-if="this.isEditing(p.id, 'margin')">
<input type="number" step="1" min="0" max="99" class="woo-edit-input"
t-att-value="state.editValue"
t-on-input="onEditInput"
t-on-keydown="onEditKeydown"
t-on-blur="onEditBlur"
/>
</t>
<t t-else="" t-esc="this.formatMargin(p.odoo_cost, p.odoo_price)"/>
</td>
<td><t t-esc="p.instance_name"/></td>
<td>
<input type="checkbox"
t-att-checked="p.sync_price"
t-on-change="() => this.togglePriceSync(p.id, p.sync_price)"/>
</td>
<td>
<input type="checkbox"
t-att-checked="p.sync_inventory"
t-on-change="() => this.toggleInventorySync(p.id, p.sync_inventory)"/>
</td>
</tr>
</t>
</tbody>
</table>
</div>
<div class="woo-pagination">
<button class="btn btn-secondary btn-sm" t-on-click="mappedPrevPage"
t-att-disabled="state.mappedPage &lt;= 1">
<i class="fa fa-chevron-left"/> Prev
</button>
<span class="woo-pagination-info">
Page <t t-esc="state.mappedPage"/> of <t t-esc="mappedTotalPages"/>
(<t t-esc="state.mappedTotal"/> total)
</span>
<button class="btn btn-secondary btn-sm" t-on-click="mappedNextPage"
t-att-disabled="state.mappedPage &gt;= mappedTotalPages">
Next <i class="fa fa-chevron-right"/>
</button>
</div>
</t>
</t>
<!-- =====================================================
TAB: Unmatched Products
===================================================== -->
<t t-if="state.activeTab === 'unmatched'">
<!-- Map action bar -->
<div class="woo-map-actions">
<button class="btn btn-primary"
t-on-click="mapSelected"
t-att-disabled="!canMap">
<i class="fa fa-link me-1"/> Map Selected
</button>
<t t-if="!canMap">
<small class="text-muted align-self-center">
Select one Odoo product and one WooCommerce product to map them.
</small>
</t>
</div>
<div class="woo-split">
<!-- Odoo products panel -->
<div class="woo-split-panel">
<div class="woo-split-panel-header" style="flex-wrap: wrap; gap: 6px;">
<span>Odoo Products</span>
<div class="d-flex gap-2 align-items-center">
<button class="btn btn-secondary btn-sm"
t-on-click="openCategoryFilter"
title="Manage hidden categories">
<i class="fa fa-filter me-1"/>
Hidden
<t t-if="state.excludedCategoryCount">
(<t t-esc="state.excludedCategoryCount"/>)
</t>
</button>
<t t-if="state.excludedCategoryCount">
<button t-att-class="'btn btn-sm ' + (state.categoryFilterActive ? 'btn-primary' : 'btn-secondary')"
t-on-click="toggleCategoryFilter"
t-att-title="state.categoryFilterActive ? 'Filter active — click to show all' : 'Filter off — click to hide categories'">
<i t-att-class="'fa ' + (state.categoryFilterActive ? 'fa-eye-slash' : 'fa-eye')"/>
</button>
</t>
</div>
<AjaxSearch
endpoint="'/woo/search/odoo_products'"
t-props="{ instanceId: state.instanceId, onResults: onOdooResults.bind(this), placeholder: 'Search Odoo…' }"
/>
</div>
<div class="woo-split-list">
<t t-if="!state.odooProducts.length">
<div class="woo-empty">
<div class="woo-empty-text">No Odoo products found.</div>
</div>
</t>
<t t-foreach="state.odooProducts" t-as="op" t-key="op.id">
<div class="woo-split-item"
t-att-class="state.selectedOdooId === op.id ? 'selected' : ''"
t-on-click="() => this.selectOdoo(op.id)">
<div class="woo-split-item-name">
<t t-esc="op.name"/>
<t t-if="op.has_variants">
<span class="badge text-bg-secondary ms-2">
<t t-esc="op.variant_count"/> variants
</span>
</t>
</div>
<div class="woo-split-item-sub">
<t t-if="op.default_code">SKU: <t t-esc="op.default_code"/> · </t>
<t t-esc="this.formatPrice(op.list_price)"/>
<t t-if="op.categ_name"> · <t t-esc="op.categ_name"/></t>
</div>
</div>
</t>
</div>
<div class="woo-pagination">
<button class="btn btn-secondary btn-sm" t-on-click="unmatchedOdooPrevPage"
t-att-disabled="state.unmatchedOdooPage &lt;= 1">
<i class="fa fa-chevron-left"/> Prev
</button>
<span class="woo-pagination-info">
Page <t t-esc="state.unmatchedOdooPage"/> of <t t-esc="unmatchedOdooTotalPages"/>
(<t t-esc="state.unmatchedOdooTotal"/> total)
</span>
<button class="btn btn-secondary btn-sm" t-on-click="unmatchedOdooNextPage"
t-att-disabled="state.unmatchedOdooPage &gt;= unmatchedOdooTotalPages">
Next <i class="fa fa-chevron-right"/>
</button>
</div>
</div>
<!-- Divider -->
<div class="woo-split-divider">
<i class="fa fa-exchange text-muted"/>
</div>
<!-- WooCommerce products panel -->
<div class="woo-split-panel">
<div class="woo-split-panel-header">
<span>WooCommerce Products</span>
<AjaxSearch
endpoint="'/woo/search/woo_products'"
t-props="{ instanceId: state.instanceId, onResults: onWooResults.bind(this), placeholder: 'Search WooCommerce…' }"
/>
</div>
<div class="woo-split-list">
<t t-if="!state.wooProducts.length">
<div class="woo-empty">
<div class="woo-empty-text">No unmatched WooCommerce products.</div>
</div>
</t>
<t t-foreach="state.wooProducts" t-as="wp" t-key="wp.id">
<div class="woo-split-item"
t-att-class="state.selectedWooId === wp.id ? 'selected' : ''"
t-on-click="() => this.selectWoo(wp.id)">
<div class="woo-split-item-name"><t t-esc="wp.woo_product_name"/></div>
<div class="woo-split-item-sub">
<t t-if="wp.woo_sku">SKU: <t t-esc="wp.woo_sku"/> · </t>
<t t-esc="wp.woo_product_type"/>
<t t-if="wp.woo_category_name"> · <t t-esc="wp.woo_category_name"/></t>
</div>
<!-- Per-item actions -->
<div class="mt-1 d-flex gap-1">
<button class="btn btn-secondary btn-sm"
t-on-click.stop="() => this.createInOdoo(wp.id)">
<i class="fa fa-plus me-1"/>Create &amp; Edit in Odoo
</button>
<button class="btn btn-outline-danger btn-sm"
t-on-click.stop="() => this.ignoreWoo(wp.id)">
Ignore
</button>
</div>
</div>
</t>
</div>
<div class="woo-pagination">
<button class="btn btn-secondary btn-sm" t-on-click="unmatchedWooPrevPage"
t-att-disabled="state.unmatchedWooPage &lt;= 1">
<i class="fa fa-chevron-left"/> Prev
</button>
<span class="woo-pagination-info">
Page <t t-esc="state.unmatchedWooPage"/> of <t t-esc="unmatchedWooTotalPages"/>
(<t t-esc="state.unmatchedWooTotal"/> total)
</span>
<button class="btn btn-secondary btn-sm" t-on-click="unmatchedWooNextPage"
t-att-disabled="state.unmatchedWooPage &gt;= unmatchedWooTotalPages">
Next <i class="fa fa-chevron-right"/>
</button>
</div>
</div>
</div>
<!-- Odoo-only actions (create in WC) shown below list -->
<t t-if="state.selectedOdooId">
<div class="mt-2">
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.createInWC(state.selectedOdooId)">
<i class="fa fa-cloud-upload me-1"/> Create Selected in WooCommerce
</button>
</div>
</t>
</t>
<!-- =====================================================
TAB: Conflicts
===================================================== -->
<t t-if="state.activeTab === 'conflicts'">
<t t-if="!state.conflicts.length">
<div class="woo-empty">
<div class="woo-empty-icon"></div>
<div class="woo-empty-text">No pending conflicts. Everything is in sync!</div>
</div>
</t>
<t t-else="">
<div class="woo-map-actions">
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.resolveAllConflicts('use_odoo')">
Use Odoo for All
</button>
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.resolveAllConflicts('use_woo')">
Use WooCommerce for All
</button>
</div>
<div class="woo-table-wrap">
<table class="woo-table">
<thead>
<tr>
<th>Type</th>
<th>Field</th>
<th>Odoo Value</th>
<th>WooCommerce Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.conflicts" t-as="c" t-key="c.id">
<tr>
<td>
<span class="badge text-bg-warning">
<t t-esc="c.conflict_type"/>
</span>
</td>
<td><t t-esc="c.field_name"/></td>
<td><t t-esc="c.odoo_value"/></td>
<td><t t-esc="c.woo_value"/></td>
<td>
<div class="d-flex gap-1">
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.resolveConflict(c.id, 'use_odoo')">
Use Odoo
</button>
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.resolveConflict(c.id, 'use_woo')">
Use WooCommerce
</button>
</div>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Redirect action for Settings → WooCommerce -->
<record id="action_woo_config_settings" model="ir.actions.act_window">
<field name="name">WooCommerce Settings</field>
<field name="res_model">woo.instance</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Inherit sale.order form — add WooCommerce tab -->
<record id="sale_order_view_form_woo" model="ir.ui.view">
<field name="name">sale.order.form.woo</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="WooCommerce" name="woocommerce"
invisible="woo_order_count == 0">
<field name="woo_bind_ids" readonly="1">
<list>
<field name="instance_id"/>
<field name="woo_order_number"/>
<field name="woo_status"/>
<field name="state" widget="badge"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Inherit stock.picking form — add WC tracking fields -->
<record id="stock_picking_view_form_woo" model="ir.ui.view">
<field name="name">stock.picking.form.woo</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='extra']" position="after">
<page string="WooCommerce" name="woo_shipping" invisible="not is_woo_delivery">
<group>
<group string="Shipping Details">
<field name="woo_tracking_number"/>
<field name="woo_carrier_id"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== List View ===== -->
<record id="woo_category_map_view_list" model="ir.ui.view">
<field name="name">woo.category.map.list</field>
<field name="model">woo.category.map</field>
<field name="arch" type="xml">
<list>
<field name="instance_id"/>
<field name="odoo_category_id"/>
<field name="woo_category_name"/>
<field name="woo_category_slug"/>
<field name="woo_category_id"/>
</list>
</field>
</record>
<!-- ===== Form View ===== -->
<record id="woo_category_map_view_form" model="ir.ui.view">
<field name="name">woo.category.map.form</field>
<field name="model">woo.category.map</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group string="WooCommerce Category">
<field name="woo_category_id"/>
<field name="woo_category_name"/>
<field name="woo_category_slug"/>
</group>
<group string="Odoo Mapping">
<field name="instance_id"/>
<field name="odoo_category_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
</odoo>

View File

@@ -1,91 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Tree View ===== -->
<record id="woo_conflict_view_tree" model="ir.ui.view">
<field name="name">woo.conflict.tree</field>
<field name="model">woo.conflict</field>
<field name="arch" type="xml">
<list>
<field name="instance_id"/>
<field name="conflict_type"/>
<field name="field_name"/>
<field name="odoo_value"/>
<field name="woo_value"/>
<field name="resolution" widget="badge"
decoration-warning="resolution == 'pending'"
decoration-success="resolution != 'pending'"/>
</list>
</field>
</record>
<!-- ===== Form View ===== -->
<record id="woo_conflict_view_form" model="ir.ui.view">
<field name="name">woo.conflict.form</field>
<field name="model">woo.conflict</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_use_odoo" type="object"
string="Use Odoo Value" class="oe_highlight"
invisible="resolution != 'pending'"/>
<button name="action_use_woo" type="object"
string="Use WooCommerce Value"
invisible="resolution != 'pending'"/>
<field name="resolution" widget="statusbar"/>
</header>
<sheet>
<group>
<group string="Context">
<field name="instance_id"/>
<field name="conflict_type"/>
<field name="field_name"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group string="Values">
<field name="odoo_value"/>
<field name="woo_value"/>
</group>
</group>
<group string="Related Records">
<field name="map_id" invisible="conflict_type != 'product'"/>
<field name="customer_id" invisible="conflict_type != 'customer'"/>
<field name="order_id" invisible="conflict_type != 'order'"/>
<field name="resolved_by"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Search View ===== -->
<record id="woo_conflict_view_search" model="ir.ui.view">
<field name="name">woo.conflict.search</field>
<field name="model">woo.conflict</field>
<field name="arch" type="xml">
<search>
<field name="field_name"/>
<filter name="pending" string="Pending"
domain="[('resolution', '=', 'pending')]"/>
<filter name="resolved" string="Resolved"
domain="[('resolution', '!=', 'pending')]"/>
<separator/>
<filter name="group_type" string="Type"
context="{'group_by': 'conflict_type'}"/>
<filter name="group_instance" string="Instance"
context="{'group_by': 'instance_id'}"/>
<filter name="group_resolution" string="Resolution"
context="{'group_by': 'resolution'}"/>
</search>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_woo_conflict" model="ir.actions.act_window">
<field name="name">Sync Conflicts</field>
<field name="res_model">woo.conflict</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="woo_conflict_view_search"/>
</record>
</odoo>

View File

@@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Tree View ===== -->
<record id="woo_customer_view_tree" model="ir.ui.view">
<field name="name">woo.customer.tree</field>
<field name="model">woo.customer</field>
<field name="arch" type="xml">
<list>
<field name="instance_id"/>
<field name="partner_id"/>
<field name="woo_customer_id"/>
<field name="woo_email"/>
<field name="last_synced"/>
</list>
</field>
</record>
<!-- ===== Form View ===== -->
<record id="woo_customer_view_form" model="ir.ui.view">
<field name="name">woo.customer.form</field>
<field name="model">woo.customer</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="instance_id"/>
<field name="partner_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="woo_customer_id"/>
<field name="woo_email"/>
<field name="last_synced"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Search View ===== -->
<record id="woo_customer_view_search" model="ir.ui.view">
<field name="name">woo.customer.search</field>
<field name="model">woo.customer</field>
<field name="arch" type="xml">
<search>
<field name="partner_id"/>
<field name="woo_email"/>
<separator/>
<filter name="group_instance" string="Instance"
context="{'group_by': 'instance_id'}"/>
</search>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_woo_customer" model="ir.actions.act_window">
<field name="name">WooCommerce Customers</field>
<field name="res_model">woo.customer</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="woo_customer_view_search"/>
</record>
</odoo>

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Dashboard Client Action (Task 15) ===== -->
<record id="action_woo_dashboard" model="ir.actions.client">
<field name="name">WooCommerce Dashboard</field>
<field name="tag">fusion_woocommerce.woo_dashboard</field>
</record>
<!-- ===== Product Mapping Client Action (Task 14) ===== -->
<record id="action_woo_product_map_ui" model="ir.actions.client">
<field name="name">Product Mapping</field>
<field name="tag">fusion_woocommerce.product_mapping</field>
</record>
</odoo>

View File

@@ -1,242 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Tree View ===== -->
<record id="woo_instance_view_tree" model="ir.ui.view">
<field name="name">woo.instance.tree</field>
<field name="model">woo.instance</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="url"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="last_sync"/>
<field name="state" widget="badge"
decoration-success="state == 'connected'"
decoration-warning="state == 'draft'"
decoration-danger="state == 'error'"/>
</list>
</field>
</record>
<!-- ===== Form View ===== -->
<record id="woo_instance_view_form" model="ir.ui.view">
<field name="name">woo.instance.form</field>
<field name="model">woo.instance</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_test_connection" type="object"
string="Test Connection" class="oe_highlight"/>
<button name="action_generate_api_key" type="object"
string="Generate API Key"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,connected"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" icon="fa-link" type="object"
name="action_view_product_maps">
<field name="mapped_count" widget="statinfo"
string="Mapped"/>
</button>
<button class="oe_stat_button" icon="fa-chain-broken" type="object"
name="action_view_product_maps">
<field name="unmapped_count" widget="statinfo"
string="Unmapped"/>
</button>
<button class="oe_stat_button" icon="fa-exclamation-triangle" type="object"
name="action_view_sync_errors">
<field name="error_count" widget="statinfo"
string="Errors (24h)"/>
</button>
</div>
<div class="oe_title">
<h1><field name="name" placeholder="Instance Name"/></h1>
</div>
<group>
<group string="Connection">
<field name="url" placeholder="https://store.example.com"/>
<field name="consumer_key" password="True"/>
<field name="consumer_secret" password="True"/>
<field name="webhook_secret" password="True"/>
<field name="wc_api_version"/>
<field name="odoo_api_key" readonly="1" widget="CopyClipboardChar"/>
</group>
<group string="Company">
<field name="company_id" groups="base.group_multi_company"/>
<field name="last_sync"/>
</group>
</group>
<notebook>
<page string="Sync Settings" name="sync_settings">
<group>
<group>
<field name="sync_interval"/>
<field name="default_warehouse_id"/>
</group>
<group>
<field name="sync_products"/>
<field name="sync_orders"/>
<field name="sync_invoices"/>
<field name="sync_inventory"/>
<field name="sync_customers"/>
</group>
</group>
<separator string="Tax Mapping"/>
<div class="mb-2">
<button name="action_fetch_wc_tax_classes" type="object"
string="Fetch WC Tax Classes" class="oe_highlight"/>
</div>
<field name="tax_map_ids">
<list editable="bottom">
<field name="tax_id" string="Odoo Tax"
domain="[('type_tax_use', '=', 'sale')]"/>
<field name="woo_tax_class_name" string="WC Tax Class" readonly="1"/>
<field name="woo_tax_class" string="WC Slug" readonly="1"/>
</list>
</field>
<separator string="Pricelist Mapping"/>
<field name="pricelist_map_ids">
<list editable="bottom">
<field name="pricelist_id" string="Odoo Pricelist"/>
<field name="woo_role" string="WC Customer Role"/>
<field name="woo_role_name" string="WC Role Name"/>
</list>
</field>
</page>
<page string="Notifications" name="notifications">
<group>
<field name="notify_on_failure"/>
<field name="notify_user_ids" widget="many2many_tags"
invisible="not notify_on_failure"/>
</group>
</page>
<page string="Product Mappings" name="product_maps">
<field name="product_map_ids">
<list editable="bottom">
<field name="product_id"/>
<field name="woo_product_name"/>
<field name="woo_sku"/>
<field name="woo_product_type"/>
<field name="sync_price"/>
<field name="sync_inventory"/>
<field name="last_synced"/>
<field name="state" widget="badge"
decoration-success="state == 'mapped'"
decoration-warning="state == 'conflict'"
decoration-danger="state == 'error'"/>
</list>
</field>
</page>
<page string="Orders" name="orders">
<field name="order_ids">
<list>
<field name="woo_order_number"/>
<field name="sale_order_id"/>
<field name="woo_status"/>
<field name="state" widget="badge"/>
</list>
</field>
</page>
<page string="Sync Log" name="sync_log">
<field name="sync_log_ids" readonly="1">
<list>
<field name="create_date"/>
<field name="sync_type"/>
<field name="direction"/>
<field name="record_ref"/>
<field name="state" widget="badge"
decoration-success="state == 'success'"
decoration-danger="state == 'failed'"
decoration-warning="state == 'conflict'"/>
<field name="message"/>
</list>
</field>
</page>
<page string="Category Mapping" name="category_mapping">
<div class="mb-2">
<button name="action_fetch_wc_categories" type="object"
string="Fetch WC Categories" class="btn btn-primary"/>
</div>
<field name="category_map_ids">
<list editable="bottom">
<field name="odoo_category_id" string="Odoo Category"/>
<field name="woo_category_name" string="WC Category Name" readonly="1"/>
<field name="woo_category_slug" string="WC Category Slug" readonly="1"/>
<field name="woo_category_id" string="WC ID" readonly="1"/>
</list>
</field>
</page>
<page string="AI Settings" name="ai_settings">
<group string="AI Provider">
<group>
<field name="ai_provider"/>
<field name="ai_api_key" password="True"/>
<field name="ai_model"/>
</group>
<group string="GPS Coordinates">
<field name="geo_lat"/>
<field name="geo_lng"/>
<field name="geo_company_name"/>
<field name="geo_company_address"/>
<field name="geo_company_phone"/>
</group>
</group>
<separator string="AI Prompts"/>
<label for="prompt_product_title" string="Product Title Prompt"/>
<field name="prompt_product_title" nolabel="1" widget="text" placeholder="Prompt for generating product titles..."/>
<label for="prompt_short_description" string="Short Description Prompt"/>
<field name="prompt_short_description" nolabel="1" widget="text" placeholder="Prompt for generating short descriptions..."/>
<label for="prompt_long_description" string="Long Description Prompt"/>
<field name="prompt_long_description" nolabel="1" widget="text" placeholder="Prompt for generating long descriptions..."/>
<label for="prompt_meta_title" string="Meta Title Prompt"/>
<field name="prompt_meta_title" nolabel="1" widget="text" placeholder="Prompt for generating SEO meta titles..."/>
<label for="prompt_meta_description" string="Meta Description Prompt"/>
<field name="prompt_meta_description" nolabel="1" widget="text" placeholder="Prompt for generating SEO meta descriptions..."/>
<label for="prompt_image_alt" string="Image Alt Text Prompt"/>
<field name="prompt_image_alt" nolabel="1" widget="text" placeholder="Prompt for generating image alt text..."/>
<label for="prompt_image_caption" string="Image Caption Prompt"/>
<field name="prompt_image_caption" nolabel="1" widget="text" placeholder="Prompt for generating image captions..."/>
<label for="prompt_keywords" string="Keywords Prompt"/>
<field name="prompt_keywords" nolabel="1" widget="text" placeholder="Prompt for generating SEO keywords..."/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Search View ===== -->
<record id="woo_instance_view_search" model="ir.ui.view">
<field name="name">woo.instance.search</field>
<field name="model">woo.instance</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="url"/>
<filter name="connected" string="Connected"
domain="[('state', '=', 'connected')]"/>
<filter name="draft" string="Draft"
domain="[('state', '=', 'draft')]"/>
<filter name="error" string="Error"
domain="[('state', '=', 'error')]"/>
<separator/>
<filter name="group_state" string="State"
context="{'group_by': 'state'}"/>
<filter name="group_company" string="Company"
context="{'group_by': 'company_id'}"/>
</search>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_woo_instance" model="ir.actions.act_window">
<field name="name">WooCommerce Instances</field>
<field name="res_model">woo.instance</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="woo_instance_view_search"/>
</record>
</odoo>

View File

@@ -1,85 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Top-Level Menu ===== -->
<menuitem id="woo_menu_root"
name="WooCommerce"
web_icon="fusion_woocommerce,static/description/icon.png"
sequence="100"/>
<!-- ===== Dashboard ===== -->
<menuitem id="woo_menu_dashboard"
name="Dashboard"
parent="woo_menu_root"
action="action_woo_dashboard"
sequence="10"/>
<!-- ===== Operations ===== -->
<menuitem id="woo_menu_operations"
name="Operations"
parent="woo_menu_root"
sequence="20"/>
<menuitem id="woo_menu_product_map"
name="Product Mapping"
parent="woo_menu_operations"
action="action_woo_product_map_ui"
sequence="10"/>
<menuitem id="woo_menu_orders"
name="Orders"
parent="woo_menu_operations"
action="action_woo_order"
sequence="20"/>
<menuitem id="woo_menu_customers"
name="Customers"
parent="woo_menu_operations"
action="action_woo_customer"
sequence="30"/>
<menuitem id="woo_menu_returns"
name="Returns"
parent="woo_menu_operations"
action="action_woo_return"
sequence="40"/>
<!-- ===== Monitoring ===== -->
<menuitem id="woo_menu_monitoring"
name="Monitoring"
parent="woo_menu_root"
sequence="30"/>
<menuitem id="woo_menu_sync_log"
name="Sync Logs"
parent="woo_menu_monitoring"
action="action_woo_sync_log"
sequence="10"/>
<menuitem id="woo_menu_conflicts"
name="Conflicts"
parent="woo_menu_monitoring"
action="action_woo_conflict"
sequence="20"/>
<!-- ===== Configuration ===== -->
<menuitem id="woo_menu_config"
name="Configuration"
parent="woo_menu_root"
sequence="40"/>
<menuitem id="woo_menu_instances"
name="Instances"
parent="woo_menu_config"
action="action_woo_instance"
sequence="10"/>
<menuitem id="woo_menu_shipping_carriers"
name="Shipping Carriers"
parent="woo_menu_config"
action="action_woo_shipping_carrier"
sequence="20"/>
<!-- Tax and Pricelist mapping moved inline to Instance → Sync Settings -->
</odoo>

View File

@@ -1,125 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Tree View ===== -->
<record id="woo_order_view_tree" model="ir.ui.view">
<field name="name">woo.order.tree</field>
<field name="model">woo.order</field>
<field name="arch" type="xml">
<list>
<field name="woo_order_number"/>
<field name="instance_id"/>
<field name="sale_order_id"/>
<field name="woo_status"/>
<field name="state" widget="badge"
decoration-success="state == 'completed'"
decoration-info="state == 'confirmed'"
decoration-warning="state == 'shipped'"
decoration-danger="state == 'cancelled'"/>
</list>
</field>
</record>
<!-- ===== Form View ===== -->
<record id="woo_order_view_form" model="ir.ui.view">
<field name="name">woo.order.form</field>
<field name="model">woo.order</field>
<field name="arch" type="xml">
<form>
<header>
<field name="state" widget="statusbar"
statusbar_visible="new,confirmed,shipped,completed"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not sale_order_id">
<div class="o_stat_info">
<span class="o_stat_text">Sale Order</span>
</div>
</button>
<button name="action_view_deliveries" type="object"
class="oe_stat_button" icon="fa-truck"
invisible="delivery_count == 0">
<field name="delivery_count" widget="statinfo" string="Delivery"/>
</button>
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-pencil-square-o"
invisible="invoice_count == 0">
<field name="invoice_count" widget="statinfo" string="Invoices"/>
</button>
</div>
<div class="oe_title">
<h1>
<field name="woo_order_number" readonly="1" placeholder="WC Order #"/>
</h1>
</div>
<group>
<group string="WooCommerce">
<field name="woo_status"/>
<field name="instance_id"/>
</group>
<group string="Odoo">
<field name="sale_order_id"/>
<field name="invoice_id"/>
<field name="invoice_synced"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Shipments" name="shipments">
<field name="shipment_ids">
<list>
<field name="picking_id"/>
<field name="carrier_id"/>
<field name="tracking_number"/>
<field name="shipped_date"/>
<field name="is_backorder"/>
<field name="synced_to_woo"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Search View ===== -->
<record id="woo_order_view_search" model="ir.ui.view">
<field name="name">woo.order.search</field>
<field name="model">woo.order</field>
<field name="arch" type="xml">
<search>
<field name="woo_order_number"/>
<field name="sale_order_id"/>
<filter name="new" string="New"
domain="[('state', '=', 'new')]"/>
<filter name="confirmed" string="Confirmed"
domain="[('state', '=', 'confirmed')]"/>
<filter name="shipped" string="Shipped"
domain="[('state', '=', 'shipped')]"/>
<filter name="completed" string="Completed"
domain="[('state', '=', 'completed')]"/>
<filter name="cancelled" string="Cancelled"
domain="[('state', '=', 'cancelled')]"/>
<separator/>
<filter name="group_instance" string="Instance"
context="{'group_by': 'instance_id'}"/>
<filter name="group_state" string="State"
context="{'group_by': 'state'}"/>
</search>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_woo_order" model="ir.actions.act_window">
<field name="name">WooCommerce Orders</field>
<field name="res_model">woo.order</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="woo_order_view_search"/>
</record>
</odoo>

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Tree View (editable inline) ===== -->
<record id="woo_pricelist_map_view_tree" model="ir.ui.view">
<field name="name">woo.pricelist.map.tree</field>
<field name="model">woo.pricelist.map</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="instance_id"/>
<field name="pricelist_id"/>
<field name="woo_role"/>
<field name="woo_role_name"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_woo_pricelist_map" model="ir.actions.act_window">
<field name="name">Price List Mapping</field>
<field name="res_model">woo.pricelist.map</field>
<field name="view_mode">list</field>
</record>
</odoo>

View File

@@ -1,103 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Tree View ===== -->
<record id="woo_product_map_view_tree" model="ir.ui.view">
<field name="name">woo.product.map.tree</field>
<field name="model">woo.product.map</field>
<field name="arch" type="xml">
<list>
<field name="instance_id"/>
<field name="product_id"/>
<field name="woo_product_name"/>
<field name="woo_sku"/>
<field name="woo_product_type"/>
<field name="woo_category_name" optional="show"/>
<field name="sync_price"/>
<field name="sync_inventory"/>
<field name="last_synced"/>
<field name="state" widget="badge"
decoration-success="state == 'mapped'"
decoration-warning="state == 'conflict'"
decoration-danger="state == 'error'"/>
</list>
</field>
</record>
<!-- ===== Form View ===== -->
<record id="woo_product_map_view_form" model="ir.ui.view">
<field name="name">woo.product.map.form</field>
<field name="model">woo.product.map</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group string="Odoo">
<field name="instance_id"/>
<field name="product_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group string="WooCommerce">
<field name="woo_product_id"/>
<field name="woo_product_name"/>
<field name="woo_sku"/>
<field name="woo_product_type"/>
<field name="woo_category_id"/>
<field name="woo_category_name"/>
<field name="woo_parent_id"/>
<field name="is_variation"/>
</group>
</group>
<group>
<group string="Sync Options">
<field name="sync_price"/>
<field name="sync_inventory"/>
<field name="sync_images"/>
</group>
<group string="Status">
<field name="state"/>
<field name="last_synced"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Search View ===== -->
<record id="woo_product_map_view_search" model="ir.ui.view">
<field name="name">woo.product.map.search</field>
<field name="model">woo.product.map</field>
<field name="arch" type="xml">
<search>
<field name="product_id"/>
<field name="woo_product_name"/>
<field name="woo_sku"/>
<filter name="mapped" string="Mapped"
domain="[('state', '=', 'mapped')]"/>
<filter name="unmapped" string="Unmapped"
domain="[('state', '=', 'unmapped')]"/>
<filter name="conflict" string="Conflicts"
domain="[('state', '=', 'conflict')]"/>
<filter name="error" string="Errors"
domain="[('state', '=', 'error')]"/>
<separator/>
<filter name="group_instance" string="Instance"
context="{'group_by': 'instance_id'}"/>
<filter name="group_state" string="State"
context="{'group_by': 'state'}"/>
<filter name="group_type" string="Product Type"
context="{'group_by': 'woo_product_type'}"/>
</search>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_woo_product_map" model="ir.actions.act_window">
<field name="name">Product Mapping</field>
<field name="res_model">woo.product.map</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="woo_product_map_view_search"/>
</record>
</odoo>

View File

@@ -1,102 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Tree View ===== -->
<record id="woo_return_view_tree" model="ir.ui.view">
<field name="name">woo.return.tree</field>
<field name="model">woo.return</field>
<field name="arch" type="xml">
<list>
<field name="instance_id"/>
<field name="order_id"/>
<field name="picking_id"/>
<field name="reason"/>
<field name="state" widget="badge"
decoration-success="state == 'refunded'"
decoration-info="state == 'approved'"
decoration-warning="state == 'requested'"
decoration-danger="state == 'rejected'"/>
</list>
</field>
</record>
<!-- ===== Form View ===== -->
<record id="woo_return_view_form" model="ir.ui.view">
<field name="name">woo.return.form</field>
<field name="model">woo.return</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_approve" type="object" string="Approve"
class="oe_highlight"
invisible="state != 'requested'"/>
<button name="action_reject" type="object" string="Reject"
invisible="state != 'requested'"/>
<button name="action_receive" type="object" string="Receive"
class="oe_highlight"
invisible="state != 'approved'"/>
<button name="action_refund" type="object" string="Refund"
class="oe_highlight"
invisible="state != 'received'"/>
<field name="state" widget="statusbar"
statusbar_visible="requested,approved,received,refunded"/>
</header>
<sheet>
<group>
<group>
<field name="instance_id"/>
<field name="order_id"/>
<field name="picking_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="reason"/>
</group>
</group>
<notebook>
<page string="Return Lines" name="lines">
<field name="line_ids">
<list editable="bottom">
<field name="product_id"/>
<field name="quantity"/>
<field name="reason"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ===== Search View ===== -->
<record id="woo_return_view_search" model="ir.ui.view">
<field name="name">woo.return.search</field>
<field name="model">woo.return</field>
<field name="arch" type="xml">
<search>
<field name="order_id"/>
<filter name="requested" string="Requested"
domain="[('state', '=', 'requested')]"/>
<filter name="approved" string="Approved"
domain="[('state', '=', 'approved')]"/>
<filter name="refunded" string="Refunded"
domain="[('state', '=', 'refunded')]"/>
<separator/>
<filter name="group_state" string="State"
context="{'group_by': 'state'}"/>
<filter name="group_instance" string="Instance"
context="{'group_by': 'instance_id'}"/>
</search>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_woo_return" model="ir.actions.act_window">
<field name="name">WooCommerce Returns</field>
<field name="res_model">woo.return</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="woo_return_view_search"/>
</record>
</odoo>

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Tree View (editable inline) ===== -->
<record id="woo_shipping_carrier_view_tree" model="ir.ui.view">
<field name="name">woo.shipping.carrier.tree</field>
<field name="model">woo.shipping.carrier</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="name"/>
<field name="code"/>
<field name="tracking_url"/>
<field name="active"/>
</list>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_woo_shipping_carrier" model="ir.actions.act_window">
<field name="name">Shipping Carriers</field>
<field name="res_model">woo.shipping.carrier</field>
<field name="view_mode">list</field>
</record>
</odoo>

View File

@@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Tree View ===== -->
<record id="woo_sync_log_view_tree" model="ir.ui.view">
<field name="name">woo.sync.log.tree</field>
<field name="model">woo.sync.log</field>
<field name="arch" type="xml">
<list create="0" edit="0">
<field name="create_date" string="Date"/>
<field name="instance_id"/>
<field name="sync_type"/>
<field name="direction"/>
<field name="record_ref"/>
<field name="state" widget="badge"
decoration-success="state == 'success'"
decoration-danger="state == 'failed'"
decoration-warning="state == 'conflict'"/>
<field name="message"/>
</list>
</field>
</record>
<!-- ===== Form View (read-only) ===== -->
<record id="woo_sync_log_view_form" model="ir.ui.view">
<field name="name">woo.sync.log.form</field>
<field name="model">woo.sync.log</field>
<field name="arch" type="xml">
<form create="0" edit="0" delete="0">
<sheet>
<group>
<group>
<field name="create_date"/>
<field name="instance_id"/>
<field name="sync_type"/>
<field name="direction"/>
</group>
<group>
<field name="record_ref"/>
<field name="state"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group string="Message">
<field name="message" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Search View ===== -->
<record id="woo_sync_log_view_search" model="ir.ui.view">
<field name="name">woo.sync.log.search</field>
<field name="model">woo.sync.log</field>
<field name="arch" type="xml">
<search>
<field name="record_ref"/>
<field name="message"/>
<filter name="success" string="Success"
domain="[('state', '=', 'success')]"/>
<filter name="failed" string="Failed"
domain="[('state', '=', 'failed')]"/>
<filter name="conflict" string="Conflict"
domain="[('state', '=', 'conflict')]"/>
<separator/>
<filter name="today" string="Today"
domain="[('create_date', '&gt;=', (context_today()).strftime('%Y-%m-%d'))]"/>
<separator/>
<filter name="group_sync_type" string="Sync Type"
context="{'group_by': 'sync_type'}"/>
<filter name="group_direction" string="Direction"
context="{'group_by': 'direction'}"/>
<filter name="group_instance" string="Instance"
context="{'group_by': 'instance_id'}"/>
<filter name="group_state" string="State"
context="{'group_by': 'state'}"/>
</search>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_woo_sync_log" model="ir.actions.act_window">
<field name="name">Sync Logs</field>
<field name="res_model">woo.sync.log</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="woo_sync_log_view_search"/>
</record>
<!-- ===== Server Action: Purge Old Logs (appears in list view action menu) ===== -->
<record id="action_purge_old_sync_logs" model="ir.actions.server">
<field name="name">Purge Old Logs</field>
<field name="model_id" ref="model_woo_sync_log"/>
<field name="binding_model_id" ref="model_woo_sync_log"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">action = records.action_purge_old_logs()</field>
</record>
</odoo>

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Tree View (editable inline) ===== -->
<record id="woo_tax_map_view_tree" model="ir.ui.view">
<field name="name">woo.tax.map.tree</field>
<field name="model">woo.tax.map</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="instance_id"/>
<field name="tax_id"/>
<field name="woo_tax_class"/>
<field name="woo_tax_class_name"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_woo_tax_map" model="ir.actions.act_window">
<field name="name">Tax Mapping</field>
<field name="res_model">woo.tax.map</field>
<field name="view_mode">list</field>
</record>
</odoo>

View File

@@ -1,5 +0,0 @@
from . import woo_setup_wizard
from . import woo_product_fetch
from . import woo_product_create
from . import woo_category_filter
from . import woo_variant_push

View File

@@ -1,42 +0,0 @@
from odoo import api, fields, models
class WooCategoryFilter(models.TransientModel):
_name = 'woo.category.filter'
_description = 'Manage Hidden Categories'
instance_id = fields.Many2one('woo.instance', required=True)
category_ids = fields.Many2many(
'product.category', string='Categories to Hide',
help='Select categories you want to hide from the unmatched products list.',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
instance_id = self.env.context.get('default_instance_id')
if instance_id:
instance = self.env['woo.instance'].browse(instance_id)
res['category_ids'] = [(6, 0, instance.excluded_category_ids.ids)]
return res
def action_save(self):
"""Save the hidden categories to the instance."""
self.ensure_one()
self.instance_id.excluded_category_ids = [(6, 0, self.category_ids.ids)]
return {'type': 'ir.actions.act_window_close'}
def action_clear_all(self):
"""Remove all hidden categories."""
self.ensure_one()
self.category_ids = [(5, 0, 0)]
return self._reopen()
def _reopen(self):
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'views': [(False, 'form')],
'target': 'new',
}

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="woo_category_filter_form_view" model="ir.ui.view">
<field name="name">woo.category.filter.form</field>
<field name="model">woo.category.filter</field>
<field name="arch" type="xml">
<form string="Manage Hidden Categories">
<sheet>
<div class="alert alert-info" role="alert">
Select the product categories you want to hide from the unmatched products list.
These will persist across sessions. You can toggle them on/off from the product mapping screen.
</div>
<group>
<field name="instance_id" invisible="1"/>
<field name="category_ids" widget="many2many_tags"
options="{'no_create': True}"
placeholder="Search and select categories to hide..."/>
</group>
</sheet>
<footer>
<button name="action_save" type="object" string="Save" class="btn-primary"/>
<button name="action_clear_all" type="object" string="Clear All" class="btn-secondary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,706 +0,0 @@
import base64
import json
import logging
from odoo import api, fields, models
from odoo.exceptions import UserError
from ..lib.ai_service import AIService
from ..lib.image_processor import ImageProcessor
_logger = logging.getLogger(__name__)
class WooProductCreateVariantLine(models.TransientModel):
_name = 'woo.product.create.variant.line'
_description = 'Product Creation Variant Line'
wizard_id = fields.Many2one('woo.product.create.wizard', ondelete='cascade')
product_id = fields.Many2one('product.product', string='Variant', readonly=True)
variant_name = fields.Char(string='Variant', readonly=True)
attribute_values = fields.Char(string='Attributes', readonly=True)
sku = fields.Char(string='SKU')
sale_price = fields.Float(string='Price', digits='Product Price')
cost_price = fields.Float(string='Cost', digits='Product Price')
image = fields.Binary(string='Image')
include = fields.Boolean(string='Include', default=True)
class WooProductCreateWizard(models.TransientModel):
_name = 'woo.product.create.wizard'
_description = 'Create Product in WooCommerce'
# Step tracking
step = fields.Selection([
('basic', 'Basic Info'),
('images', 'Images'),
('content', 'Content & SEO'),
('review', 'Review & Create'),
], default='basic')
instance_id = fields.Many2one('woo.instance', required=True, string='WooCommerce Instance')
odoo_product_id = fields.Many2one('product.product', string='Odoo Product')
# Step 1: Basic Info
product_name = fields.Char(string='Product Name (Odoo - CAPS)')
wc_product_name = fields.Char(string='Product Name (WC - Title Case)')
odoo_category_id = fields.Many2one('product.category', string='Odoo Category')
wc_category_id = fields.Integer(string='WC Category ID')
wc_category_name = fields.Char(string='WC Category', readonly=True)
sale_price = fields.Float(string='Sale Price', digits='Product Price')
cost_price = fields.Float(string='Cost Price', digits='Product Price')
sku = fields.Char(string='Internal Reference / SKU')
sales_tax_id = fields.Many2one('account.tax', string='Sales Tax',
domain="[('type_tax_use', '=', 'sale')]")
purchase_tax_id = fields.Many2one('account.tax', string='Purchase Tax',
domain="[('type_tax_use', '=', 'purchase')]")
wc_tax_class = fields.Char(string='WC Tax Class')
# Step 2: Images
image_1 = fields.Binary(string='Image 1 (Featured)')
image_1_filename = fields.Char()
image_2 = fields.Binary(string='Image 2')
image_2_filename = fields.Char()
image_3 = fields.Binary(string='Image 3')
image_3_filename = fields.Char()
image_4 = fields.Binary(string='Image 4')
image_4_filename = fields.Char()
image_5 = fields.Binary(string='Image 5')
image_5_filename = fields.Char()
# Image AI metadata (stored as JSON text)
image_metadata = fields.Text(string='Image Metadata', default='[]')
images_geotagged = fields.Boolean(string='Images Geo-tagged')
# Step 3: Content & SEO
raw_product_info = fields.Text(string='Product Information',
help='Type everything you know about this product. The AI will use this to generate descriptions.')
ai_keywords = fields.Char(string='Keywords')
wc_title = fields.Char(string='WC Product Title')
short_description = fields.Html(string='Short Description')
long_description = fields.Html(string='Long Description')
meta_title = fields.Char(string='Meta Title')
meta_description = fields.Text(string='Meta Description')
seo_keywords = fields.Char(string='SEO Focus Keywords')
# Variant detection
has_variants = fields.Boolean(compute='_compute_has_variants')
variant_count = fields.Integer(compute='_compute_has_variants')
product_template_id = fields.Many2one('product.template', compute='_compute_has_variants')
# Variant line display
variant_line_ids = fields.One2many('woo.product.create.variant.line', 'wizard_id')
# Step 4 is review - uses fields above
# --- Variant Detection ---
@api.depends('odoo_product_id')
def _compute_has_variants(self):
for rec in self:
if rec.odoo_product_id:
tmpl = rec.odoo_product_id.product_tmpl_id
variants = tmpl.product_variant_ids
rec.product_template_id = tmpl.id
rec.has_variants = len(variants) > 1
rec.variant_count = len(variants)
else:
rec.product_template_id = False
rec.has_variants = False
rec.variant_count = 0
# --- Onchange / Defaults ---
@api.onchange('odoo_product_id')
def _onchange_odoo_product(self):
if self.odoo_product_id:
product = self.odoo_product_id
self.product_name = product.name.upper() if product.name else ''
self.wc_product_name = product.name.title() if product.name else ''
self.sale_price = product.list_price
self.cost_price = product.standard_price
self.sku = product.default_code or ''
self.odoo_category_id = product.categ_id.id if product.categ_id else False
# Auto-set sales tax
if product.taxes_id:
self.sales_tax_id = product.taxes_id[0].id
# Auto-set image
if product.image_1920:
self.image_1 = product.image_1920
# Populate variant lines
tmpl = product.product_tmpl_id
variants = tmpl.product_variant_ids
if len(variants) > 1:
lines = []
for variant in variants:
attr_values = ', '.join(
variant.product_template_attribute_value_ids.mapped('name')
)
lines.append((0, 0, {
'product_id': variant.id,
'variant_name': variant.display_name,
'attribute_values': attr_values,
'sku': variant.default_code or '',
'sale_price': variant.list_price,
'cost_price': variant.standard_price,
'image': variant.image_variant_1920 or False,
'include': True,
}))
self.variant_line_ids = lines
else:
self.variant_line_ids = [(5, 0, 0)]
@api.onchange('odoo_category_id')
def _onchange_category(self):
"""Auto-map Odoo category to WC category using woo.category.map."""
if self.odoo_category_id and self.instance_id:
mapping = self.env['woo.category.map'].search([
('instance_id', '=', self.instance_id.id),
('odoo_category_id', '=', self.odoo_category_id.id),
], limit=1)
if mapping:
self.wc_category_id = mapping.woo_category_id
self.wc_category_name = mapping.woo_category_name
@api.onchange('sales_tax_id')
def _onchange_sales_tax(self):
"""Auto-map sales tax to WC tax class and match purchase tax."""
if self.sales_tax_id and self.instance_id:
# Map to WC tax class
tax_map = self.env['woo.tax.map'].search([
('instance_id', '=', self.instance_id.id),
('tax_id', '=', self.sales_tax_id.id),
], limit=1)
if tax_map:
self.wc_tax_class = tax_map.woo_tax_class
# Match purchase tax by amount
purchase_tax = self.env['account.tax'].search([
('type_tax_use', '=', 'purchase'),
('amount', '=', self.sales_tax_id.amount),
('company_id', '=', self.instance_id.company_id.id),
], limit=1)
if purchase_tax:
self.purchase_tax_id = purchase_tax.id
# --- Navigation ---
def action_next(self):
self.ensure_one()
steps = ['basic', 'images', 'content', 'review']
idx = steps.index(self.step)
if idx < len(steps) - 1:
self.step = steps[idx + 1]
return self._reopen()
def action_back(self):
self.ensure_one()
steps = ['basic', 'images', 'content', 'review']
idx = steps.index(self.step)
if idx > 0:
self.step = steps[idx - 1]
return self._reopen()
def _reopen(self):
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'views': [(False, 'form')],
'target': 'new',
}
# --- AI Methods ---
def _get_ai_service(self):
"""Get AIService instance from the woo.instance settings."""
self.ensure_one()
inst = self.instance_id
if not inst.ai_provider or not inst.ai_api_key:
raise UserError(
"Please configure AI settings (Provider + API Key) on the WooCommerce instance first."
)
return AIService(inst.ai_provider, inst.ai_api_key, inst.ai_model or None)
def action_ai_generate_all(self):
"""Generate all content fields using AI."""
self.ensure_one()
ai = self._get_ai_service()
inst = self.instance_id
product_info = {
'name': self.product_name or '',
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'price': self.sale_price,
'sku': self.sku or '',
'raw_description': self.raw_product_info or '',
'keywords': self.ai_keywords or '',
}
prompts = {
'title': inst.prompt_product_title or '',
'short_desc': inst.prompt_short_description or '',
'long_desc': inst.prompt_long_description or '',
'meta_title': inst.prompt_meta_title or '',
'meta_desc': inst.prompt_meta_description or '',
'keywords': inst.prompt_keywords or '',
}
result = ai.generate_product_content(product_info, prompts)
self.wc_title = result.get('title', self.wc_product_name)
self.short_description = result.get('short_description', '')
self.long_description = result.get('long_description', '')
self.meta_title = result.get('meta_title', '')
self.meta_description = result.get('meta_description', '')
self.seo_keywords = result.get('keywords', self.ai_keywords or '')
return self._reopen()
def action_ai_generate_title(self):
self.ensure_one()
ai = self._get_ai_service()
product_info = {
'name': self.product_name,
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'raw_description': self.raw_product_info or '',
}
result = ai.generate_single_field(
product_info,
self.instance_id.prompt_product_title or 'Generate SEO product title in Title Case',
'title',
)
self.wc_title = result
return self._reopen()
def action_ai_generate_short_desc(self):
self.ensure_one()
ai = self._get_ai_service()
product_info = {
'name': self.product_name,
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'raw_description': self.raw_product_info or '',
}
result = ai.generate_single_field(
product_info,
self.instance_id.prompt_short_description or 'Write a short HTML product description',
'short_description',
)
self.short_description = result
return self._reopen()
def action_ai_generate_long_desc(self):
self.ensure_one()
ai = self._get_ai_service()
product_info = {
'name': self.product_name,
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'raw_description': self.raw_product_info or '',
'keywords': self.ai_keywords or '',
}
result = ai.generate_single_field(
product_info,
self.instance_id.prompt_long_description or 'Write a detailed HTML product description',
'long_description',
)
self.long_description = result
return self._reopen()
def action_ai_generate_meta(self):
self.ensure_one()
ai = self._get_ai_service()
product_info = {
'name': self.wc_title or self.product_name,
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'keywords': self.ai_keywords or '',
}
self.meta_title = ai.generate_single_field(
product_info,
self.instance_id.prompt_meta_title or 'Generate SEO meta title under 60 chars',
'meta_title',
)
self.meta_description = ai.generate_single_field(
product_info,
self.instance_id.prompt_meta_description or 'Generate SEO meta description under 160 chars',
'meta_description',
)
return self._reopen()
def action_ai_generate_keywords(self):
self.ensure_one()
ai = self._get_ai_service()
product_info = {
'name': self.wc_title or self.product_name,
'category': self.odoo_category_id.complete_name if self.odoo_category_id else '',
'raw_description': self.raw_product_info or '',
}
self.seo_keywords = ai.generate_single_field(
product_info,
self.instance_id.prompt_keywords or 'Generate SEO focus keywords',
'keywords',
)
return self._reopen()
# --- Image AI ---
def action_ai_tag_images(self):
"""Generate AI metadata for all uploaded images."""
self.ensure_one()
ai = self._get_ai_service()
inst = self.instance_id
category_name = self.odoo_category_id.complete_name if self.odoo_category_id else ''
metadata = []
for i in range(1, 6):
img = getattr(self, f'image_{i}', None)
if img:
meta = ai.generate_image_metadata(
self.product_name or '',
category_name,
inst.prompt_image_alt or 'Descriptive alt text under 125 chars',
inst.prompt_image_caption or 'Short product image caption',
)
meta['index'] = i
metadata.append(meta)
self.image_metadata = json.dumps(metadata)
return self._reopen()
def action_geo_tag_images(self):
"""Write EXIF geo-tag data to all uploaded images."""
self.ensure_one()
inst = self.instance_id
for i in range(1, 6):
img = getattr(self, f'image_{i}', None)
if img:
tagged = ImageProcessor.geo_tag_image(
img.decode('utf-8') if isinstance(img, bytes) else img,
inst.geo_company_name,
inst.geo_company_address,
inst.geo_company_phone,
inst.geo_lat,
inst.geo_lng,
)
setattr(self, f'image_{i}', tagged)
self.images_geotagged = True
return self._reopen()
# --- Create Product ---
def action_create_product(self):
"""Create the product in WooCommerce and update Odoo."""
self.ensure_one()
inst = self.instance_id
client = inst._get_client()
# --- Update/Create Odoo product ---
odoo_product = self.odoo_product_id
odoo_vals = {
'name': self.product_name or (self.wc_title or '').upper(),
'list_price': self.sale_price,
'standard_price': self.cost_price,
'default_code': self.sku or '',
'type': 'consu',
}
if self.odoo_category_id:
odoo_vals['categ_id'] = self.odoo_category_id.id
if self.sales_tax_id:
odoo_vals['taxes_id'] = [(6, 0, [self.sales_tax_id.id])]
if self.purchase_tax_id:
odoo_vals['supplier_taxes_id'] = [(6, 0, [self.purchase_tax_id.id])]
if odoo_product:
odoo_product.write(odoo_vals)
else:
odoo_product = self.env['product.product'].create(odoo_vals)
# Set product image
if self.image_1:
odoo_product.image_1920 = self.image_1
# --- Prepare WC product data ---
wc_data = {
'name': self.wc_title or (self.product_name or '').title(),
'type': 'simple',
'regular_price': str(self.sale_price),
'sku': self.sku or '',
'short_description': self.short_description or '',
'description': self.long_description or '',
'manage_stock': False,
'status': 'publish',
}
# Category
if self.wc_category_id:
wc_data['categories'] = [{'id': self.wc_category_id}]
# Tax class
if self.wc_tax_class:
wc_data['tax_class'] = self.wc_tax_class
# SEO meta data — compatible with Rank Math, Yoast, AIOSEO, SEOPress
seo_meta = []
if self.meta_title:
seo_meta.extend([
{'key': 'rank_math_title', 'value': self.meta_title},
{'key': '_yoast_wpseo_title', 'value': self.meta_title},
{'key': '_aioseo_title', 'value': self.meta_title},
{'key': '_seopress_titles_title', 'value': self.meta_title},
])
if self.meta_description:
seo_meta.extend([
{'key': 'rank_math_description', 'value': self.meta_description},
{'key': '_yoast_wpseo_metadesc', 'value': self.meta_description},
{'key': '_aioseo_description', 'value': self.meta_description},
{'key': '_seopress_titles_desc', 'value': self.meta_description},
])
if self.seo_keywords:
seo_meta.extend([
{'key': 'rank_math_focus_keyword', 'value': self.seo_keywords},
{'key': '_yoast_wpseo_focuskw', 'value': self.seo_keywords},
{'key': '_aioseo_keywords', 'value': self.seo_keywords},
{'key': '_seopress_analysis_target_kw', 'value': self.seo_keywords},
])
if seo_meta:
wc_data['meta_data'] = seo_meta
# --- Upload images to WC ---
image_metadata = []
try:
image_metadata = json.loads(self.image_metadata or '[]')
except (json.JSONDecodeError, TypeError):
pass
# Set product images via Odoo's public URL — WC downloads them directly
# WC consumer key/secret cannot authenticate against /wp/v2/media (401)
wc_images = []
odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
if odoo_base and self.product_template_id:
tmpl_id = self.product_template_id.id
for i in range(1, 6):
img_data = getattr(self, f'image_{i}', None)
if not img_data:
continue
filename = getattr(self, f'image_{i}_filename', '') or f'product_image_{i}.jpg'
img_meta = next((m for m in image_metadata if m.get('index') == i), {})
img_url = f"{odoo_base}/web/image/product.template/{tmpl_id}/image_1920/{filename}"
wc_img = {
'src': img_url,
'name': img_meta.get('title', filename),
'alt': img_meta.get('alt_text', ''),
}
if i == 1:
wc_img['position'] = 0
wc_images.append(wc_img)
if wc_images:
wc_data['images'] = wc_images
# --- Handle variable vs simple product ---
if self.has_variants:
tmpl = self.product_template_id
# Build WC attributes from Odoo attribute lines
wc_attributes = []
for attr_line in tmpl.attribute_line_ids:
attr_name = attr_line.attribute_id.name
attr_values = attr_line.value_ids.mapped('name')
# Find or create WC attribute
wc_attr = self._find_or_create_wc_attribute(client, attr_name)
# Create terms for each value
wc_terms = []
for val_name in attr_values:
term = self._find_or_create_wc_attribute_term(client, wc_attr['id'], val_name)
wc_terms.append(term['name'])
wc_attributes.append({
'id': wc_attr['id'],
'name': attr_name,
'position': 0,
'visible': True,
'variation': True,
'options': wc_terms,
})
wc_data['type'] = 'variable'
wc_data['attributes'] = wc_attributes
# Remove regular_price for variable products (set on variations)
wc_data.pop('regular_price', None)
# Create the parent product
try:
wc_product = client.create_product(wc_data)
wc_product_id = wc_product['id']
wc_permalink = wc_product.get('permalink', '')
except Exception as e:
raise UserError("Failed to create WooCommerce variable product: %s" % str(e))
# Create parent product map
self.env['woo.product.map'].create({
'instance_id': inst.id,
'product_id': odoo_product.id,
'woo_product_id': wc_product_id,
'woo_product_name': wc_data['name'],
'woo_sku': self.sku or '',
'woo_regular_price': 0,
'woo_sale_price': 0,
'woo_permalink': wc_permalink,
'woo_product_type': 'variable',
'state': 'mapped',
'company_id': inst.company_id.id,
})
# Build WC attribute ID lookup
wc_attr_id_map = {a['name'].upper(): a['id'] for a in wc_attributes}
# Create variations
variation_count = 0
for line in self.variant_line_ids:
if not line.include:
continue
variant = line.product_id
# Build variation attributes with WC IDs
var_attributes = []
for ptav in variant.product_template_attribute_value_ids:
attr_name = ptav.attribute_id.name
wc_aid = wc_attr_id_map.get(attr_name.upper(), 0)
entry = {'option': ptav.name}
if wc_aid:
entry['id'] = wc_aid
else:
entry['name'] = attr_name
var_attributes.append(entry)
var_data = {
'regular_price': str(line.sale_price),
'sku': line.sku or '',
'attributes': var_attributes,
'manage_stock': True,
'stock_quantity': int(variant.qty_available),
}
# Variant image — pass Odoo's public URL, WC downloads it directly
if line.image:
odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
if odoo_base and variant.id:
var_filename = f"variant_{line.sku or variant.id}.png"
img_url = f"{odoo_base}/web/image/product.product/{variant.id}/image_1920/{var_filename}"
var_data['image'] = {
'src': img_url,
'name': var_filename,
'alt': line.variant_name or '',
}
# Tax class
if self.wc_tax_class:
var_data['tax_class'] = self.wc_tax_class
try:
wc_variation = client.create_product_variation(wc_product_id, var_data)
# Create variation product map
self.env['woo.product.map'].create({
'instance_id': inst.id,
'product_id': variant.id,
'woo_product_id': wc_variation['id'],
'woo_product_name': line.variant_name,
'woo_sku': line.sku or '',
'woo_regular_price': line.sale_price,
'woo_sale_price': 0,
'woo_permalink': wc_permalink,
'woo_product_type': 'simple',
'woo_parent_id': wc_product_id,
'is_variation': True,
'state': 'mapped',
'company_id': inst.company_id.id,
})
variation_count += 1
except Exception as e:
_logger.error("Failed to create variation for %s: %s", line.variant_name, e)
inst._log_sync(
'product', 'odoo_to_woo', tmpl.name, 'success',
'Created variable product with %d variations' % variation_count,
)
success_msg = 'Variable product "%s" created with %d variations!' % (wc_data['name'], variation_count)
else:
# --- Simple product creation ---
try:
wc_product = client.create_product(wc_data)
wc_product_id = wc_product['id']
wc_permalink = wc_product.get('permalink', '')
except Exception as e:
raise UserError("Failed to create WooCommerce product: %s" % str(e))
# Create product mapping
self.env['woo.product.map'].create({
'instance_id': inst.id,
'product_id': odoo_product.id,
'woo_product_id': wc_product_id,
'woo_product_name': wc_data['name'],
'woo_sku': self.sku or '',
'woo_regular_price': self.sale_price,
'woo_sale_price': 0.0,
'woo_permalink': wc_permalink,
'woo_product_type': 'simple',
'state': 'mapped',
'company_id': inst.company_id.id,
})
inst._log_sync(
'product', 'odoo_to_woo', odoo_product.name, 'success',
'Created WC product #%s' % wc_product_id,
)
success_msg = 'Product "%s" created in WooCommerce successfully!' % wc_data['name']
# Close wizard and show success
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Product Created',
'message': success_msg,
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}
# --- Variant Helper Methods ---
def _find_or_create_wc_attribute(self, client, attr_name):
"""Find or create a WC product attribute by name."""
try:
attrs = client.get_product_attributes()
for a in attrs:
if a.get('name', '').lower() == attr_name.lower():
return a
except Exception:
pass
# Create new attribute
return client.create_product_attribute({
'name': attr_name,
'slug': attr_name.lower().replace(' ', '-'),
'type': 'select',
'order_by': 'menu_order',
})
def _find_or_create_wc_attribute_term(self, client, attr_id, term_name):
"""Find or create a WC attribute term."""
try:
terms = client.get_attribute_terms(attr_id)
for t in terms:
if t.get('name', '').lower() == term_name.lower():
return t
except Exception:
pass
# Create new term
return client.create_attribute_term(attr_id, {
'name': term_name,
})

View File

@@ -1,193 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="woo_product_create_wizard_form" model="ir.ui.view">
<field name="name">woo.product.create.wizard.form</field>
<field name="model">woo.product.create.wizard</field>
<field name="arch" type="xml">
<form string="Create Product in WooCommerce">
<sheet>
<!-- Step indicator using Odoo statusbar widget -->
<field name="step" widget="statusbar"
statusbar_visible="basic,images,content,review"/>
<field name="instance_id" invisible="1"/>
<field name="wc_category_id" invisible="1"/>
<!-- ========== STEP 1: Basic Info ========== -->
<div invisible="step != 'basic'">
<group>
<group string="Product">
<field name="odoo_product_id"/>
<field name="product_name" placeholder="PRODUCT NAME IN CAPS"/>
<field name="wc_product_name" placeholder="Product Name In Title Case"/>
<field name="sku" string="Internal Reference"/>
</group>
<group string="Pricing &amp; Tax">
<field name="sale_price"/>
<field name="cost_price"/>
<field name="sales_tax_id"/>
<field name="purchase_tax_id"/>
<field name="wc_tax_class" readonly="1"/>
</group>
</group>
<group>
<group string="Category">
<field name="odoo_category_id"/>
<field name="wc_category_name" readonly="1"/>
</group>
</group>
<!-- Variant info (visible when product has variants) -->
<div invisible="not has_variants" class="mt-3">
<separator string="Product Variants"/>
<div class="alert alert-info" role="alert">
This product has <field name="variant_count" readonly="1" class="d-inline"/> variants.
They will be created as WooCommerce variations.
</div>
<field name="variant_line_ids">
<list editable="bottom">
<field name="include" widget="boolean_toggle"/>
<field name="variant_name" readonly="1"/>
<field name="attribute_values" readonly="1"/>
<field name="sku"/>
<field name="sale_price"/>
<field name="cost_price"/>
<field name="image" widget="image" options="{'size': [64, 64]}"/>
</list>
</field>
</div>
<field name="has_variants" invisible="1"/>
<field name="product_template_id" invisible="1"/>
</div>
<!-- ========== STEP 2: Images ========== -->
<div invisible="step != 'images'">
<div class="mb-3">
<button name="action_ai_tag_images" type="object"
string="AI Tag Images" class="oe_highlight me-2"
icon="fa-magic"/>
<button name="action_geo_tag_images" type="object"
string="Geo-tag Images" class="btn-secondary"
icon="fa-map-marker"/>
<field name="images_geotagged" invisible="1"/>
</div>
<group>
<group>
<field name="image_1" widget="image" string="Featured Image"/>
<field name="image_1_filename" invisible="1"/>
<field name="image_2" widget="image" string="Image 2"/>
<field name="image_2_filename" invisible="1"/>
<field name="image_3" widget="image" string="Image 3"/>
<field name="image_3_filename" invisible="1"/>
</group>
<group>
<field name="image_4" widget="image" string="Image 4"/>
<field name="image_4_filename" invisible="1"/>
<field name="image_5" widget="image" string="Image 5"/>
<field name="image_5_filename" invisible="1"/>
</group>
</group>
<field name="image_metadata" invisible="1"/>
</div>
<!-- ========== STEP 3: Content & SEO ========== -->
<div invisible="step != 'content'">
<separator string="Product Information for AI"/>
<field name="raw_product_info" nolabel="1" widget="text"
placeholder="Type everything you know about this product — features, materials, use cases, target audience, dimensions, etc. The AI will use this to generate all descriptions."/>
<group>
<field name="ai_keywords" string="Keywords"
placeholder="mobility, wheelchair, medical equipment, ..."/>
</group>
<div class="mb-3">
<button name="action_ai_generate_all" type="object"
string="Generate All with AI" class="oe_highlight"
icon="fa-magic"/>
</div>
<separator string="Product Title"/>
<group>
<field name="wc_title" string="WC Product Title"/>
<button name="action_ai_generate_title" type="object"
string="AI Generate" class="oe_link"
icon="fa-magic"/>
</group>
<separator string="Short Description"/>
<button name="action_ai_generate_short_desc" type="object"
string="AI Generate" class="oe_link mb-1"
icon="fa-magic"/>
<field name="short_description" widget="html" nolabel="1"/>
<separator string="Long Description"/>
<button name="action_ai_generate_long_desc" type="object"
string="AI Generate" class="oe_link mb-1"
icon="fa-magic"/>
<field name="long_description" widget="html" nolabel="1"/>
<separator string="SEO Meta Data"/>
<div class="mb-2">
<button name="action_ai_generate_meta" type="object"
string="AI Generate Meta" class="oe_link"
icon="fa-magic"/>
<button name="action_ai_generate_keywords" type="object"
string="AI Generate Keywords" class="oe_link ms-3"
icon="fa-magic"/>
</div>
<group>
<field name="meta_title" placeholder="SEO title (under 60 characters)"/>
<field name="meta_description" widget="text"
placeholder="SEO description (under 160 characters)"/>
<field name="seo_keywords" placeholder="keyword1, keyword2, keyword3"/>
</group>
</div>
<!-- ========== STEP 4: Review & Create ========== -->
<div invisible="step != 'review'">
<group>
<group string="Product">
<field name="product_name" readonly="1"/>
<field name="wc_title" readonly="1" string="WC Title"/>
<field name="sku" readonly="1"/>
</group>
<group string="Pricing &amp; Tax">
<field name="sale_price" readonly="1"/>
<field name="cost_price" readonly="1"/>
<field name="sales_tax_id" readonly="1"/>
<field name="wc_tax_class" readonly="1"/>
</group>
</group>
<group>
<group string="Category">
<field name="odoo_category_id" readonly="1"/>
<field name="wc_category_name" readonly="1"/>
</group>
<group string="SEO">
<field name="meta_title" readonly="1"/>
<field name="meta_description" readonly="1"/>
<field name="seo_keywords" readonly="1"/>
</group>
</group>
</div>
</sheet>
<footer>
<button string="Back" type="object" name="action_back"
class="btn-secondary"
invisible="step == 'basic'"
icon="fa-arrow-left"/>
<button string="Next" type="object" name="action_next"
class="oe_highlight"
invisible="step == 'review'"
icon="fa-arrow-right"/>
<button string="Create in WooCommerce" type="object" name="action_create_product"
class="oe_highlight"
invisible="step != 'review'"
icon="fa-cloud-upload"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,177 +0,0 @@
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
wc_categories = wc_product.get('categories', [])
wc_cat_id = wc_categories[0].get('id', 0) if wc_categories else 0
wc_cat_name = wc_categories[0].get('name', '') if wc_categories else ''
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,
'woo_category_id': wc_cat_id,
'woo_category_name': wc_cat_name,
'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

@@ -1,61 +0,0 @@
<?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"
readonly="state != 'draft'"/>
<field name="state" readonly="1"/>
</group>
<group string="Results"
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"
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"
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"
invisible="state != 'draft'"/>
<button type="object" name="action_open_mapping"
string="Open Product Mapping"
class="btn-primary"
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

@@ -1,145 +0,0 @@
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

@@ -1,130 +0,0 @@
<?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"
invisible="step == 'connection'">
&#8592; Back
</button>
<span class="badge bg-primary me-1"
invisible="step != 'connection'">
Step 1: Connection
</span>
<span class="badge bg-secondary me-1"
invisible="step == 'connection'">
Step 1: Connection &#10003;
</span>
<span class="badge bg-primary me-1"
invisible="step != 'config'">
Step 2: Configuration
</span>
<span class="badge bg-secondary me-1"
invisible="step in ('connection', 'config')">
Step 2: Configuration &#10003;
</span>
<span class="badge bg-muted me-1"
invisible="step in ('config', 'done')">
Step 2: Configuration
</span>
<span class="badge bg-primary me-1"
invisible="step != 'done'">
Step 3: Done
</span>
<span class="badge bg-muted me-1"
invisible="step == 'done'">
Step 3: Done
</span>
</div>
<!-- Step 1: Connection -->
<group 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"
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"
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 invisible="step != 'config'">
<group string="Sync Configuration">
<field name="default_warehouse_id"/>
<field name="sync_interval"/>
</group>
</group>
<!-- Step 3: Done -->
<group 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"
invisible="step != 'connection'"/>
<button type="object" name="action_next_step"
string="Next"
class="btn-primary"
invisible="step != 'connection'"/>
<!-- Config step buttons -->
<button type="object" name="action_complete"
string="Complete Setup"
class="btn-primary"
invisible="step != 'config'"/>
<!-- Done step buttons -->
<button type="object" name="action_fetch_products"
string="Fetch Products"
class="btn-primary"
invisible="step != 'done'"/>
<button type="object" name="action_open_instance"
string="Open Instance"
class="btn-secondary"
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>

View File

@@ -1,344 +0,0 @@
import base64
import json
import logging
from odoo import api, fields, models
from odoo.exceptions import UserError
from ..lib.image_processor import ImageProcessor
_logger = logging.getLogger(__name__)
class WooVariantPushWizard(models.TransientModel):
_name = 'woo.variant.push.wizard'
_description = 'Push Variants to WooCommerce'
instance_id = fields.Many2one('woo.instance', required=True)
product_map_id = fields.Many2one('woo.product.map', required=True, string='Product Mapping')
product_template_id = fields.Many2one('product.template', string='Product Template', readonly=True)
product_name = fields.Char(readonly=True)
woo_product_id = fields.Integer(readonly=True)
line_ids = fields.One2many('woo.variant.push.line', 'wizard_id', string='Variants')
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
map_id = self.env.context.get('default_product_map_id')
if map_id:
pm = self.env['woo.product.map'].browse(map_id)
if pm.exists() and pm.product_id:
tmpl = pm.product_id.product_tmpl_id
res['instance_id'] = pm.instance_id.id
res['product_template_id'] = tmpl.id
res['product_name'] = tmpl.name
res['woo_product_id'] = pm.woo_product_id
# Populate variant lines — only active variants with attribute values
lines = []
active_variants = tmpl.product_variant_ids.filtered(
lambda v: v.active and v.product_template_attribute_value_ids
)
for variant in active_variants:
already_mapped = self.env['woo.product.map'].search([
('instance_id', '=', pm.instance_id.id),
('product_id', '=', variant.id),
('is_variation', '=', True),
], limit=1)
attr_values = ', '.join(
variant.product_template_attribute_value_ids.mapped('name')
)
is_first = len(lines) == 0
# For already-synced variants, don't pre-fill image
# (to avoid re-uploading the same image on every sync)
# User can upload a new image if they want to change it
pre_image = False
if not already_mapped:
pre_image = variant.image_variant_1920 or variant.image_1920 or False
lines.append((0, 0, {
'product_id': variant.id,
'variant_name': variant.display_name,
'attribute_values': attr_values,
'sku': already_mapped.woo_sku if already_mapped else (variant.default_code or ''),
'regular_price': already_mapped.woo_regular_price if already_mapped else variant.list_price,
'sale_price': already_mapped.woo_sale_price if already_mapped else 0.0,
'cost_price': variant.standard_price,
'image': pre_image,
'include': True,
'is_default': is_first,
'already_synced': bool(already_mapped),
'wc_variation_id': already_mapped.woo_product_id if already_mapped else 0,
'map_id': already_mapped.id if already_mapped else 0,
}))
res['line_ids'] = lines
return res
def action_push(self):
"""Push selected variants to WooCommerce."""
self.ensure_one()
pm = self.product_map_id
inst = pm.instance_id
client = inst._get_client()
tmpl = self.product_template_id
_logger.info(
"Variant push wizard: %d lines total, fields: %s",
len(self.line_ids),
[(l.variant_name, l.already_synced, l.wc_variation_id, l.map_id) for l in self.line_ids],
)
lines_new = self.line_ids.filtered(lambda l: l.include and not l.already_synced)
lines_update = self.line_ids.filtered(lambda l: l.include and l.already_synced)
_logger.info("Variant push: %d new, %d update", len(lines_new), len(lines_update))
if not lines_new and not lines_update:
raise UserError("No variants selected.")
# Step 1: Build WC attributes from Odoo attribute lines
wc_attributes = []
for attr_line in tmpl.attribute_line_ids:
attr_name = attr_line.attribute_id.name
attr_values = attr_line.value_ids.mapped('name')
wc_attr = pm._find_or_create_wc_attribute(client, attr_name)
wc_terms = []
for val_name in attr_values:
term = pm._find_or_create_wc_attribute_term(client, wc_attr['id'], val_name)
wc_terms.append(term['name'])
wc_attributes.append({
'id': wc_attr['id'],
'name': attr_name,
'position': 0,
'visible': True,
'variation': True,
'options': wc_terms,
})
# Build a lookup: Odoo attribute name → WC attribute ID
wc_attr_id_map = {}
for wc_attr in wc_attributes:
wc_attr_id_map[wc_attr['name'].upper()] = wc_attr['id']
# Step 2: Update product as variable with attributes + set default
# Use the variant marked as default, or first included
default_line = self.line_ids.filtered(lambda l: l.include and l.is_default)[:1]
if not default_line:
default_line = self.line_ids.filtered('include')[:1]
default_attrs = []
if default_line and default_line.product_id:
for ptav in default_line.product_id.product_template_attribute_value_ids:
attr_name = ptav.attribute_id.name
wc_aid = wc_attr_id_map.get(attr_name.upper(), 0)
entry = {'option': ptav.name}
if wc_aid:
entry['id'] = wc_aid
else:
entry['name'] = attr_name
default_attrs.append(entry)
parent_update = {
'type': 'variable',
'attributes': wc_attributes,
}
if default_attrs:
parent_update['default_attributes'] = default_attrs
try:
client.update_product(pm.woo_product_id, parent_update)
pm.woo_product_type = 'variable'
except Exception as e:
raise UserError("Failed to update WC product: %s" % str(e))
# Step 3: Create NEW variations
created = 0
updated = 0
errors = []
for line in lines_new:
variant = line.product_id
# Build variation attributes
var_attributes = []
for ptav in variant.product_template_attribute_value_ids:
var_attributes.append({
'name': ptav.attribute_id.name,
'option': ptav.name,
})
var_data = {
'regular_price': str(line.regular_price),
'sku': line.sku or '',
'attributes': var_attributes,
'manage_stock': True,
'stock_quantity': int(variant.qty_available),
}
# Sale price
if line.sale_price > 0:
var_data['sale_price'] = str(line.sale_price)
# Tax class
wc_tax_class = self.env['woo.tax.map'].get_wc_tax_class(
inst, variant.taxes_id[:1].id if variant.taxes_id else False
)
if wc_tax_class:
var_data['tax_class'] = wc_tax_class
# Save wizard image to Odoo product, then pass URL to WC
if line.image and len(line.image) > 100:
variant.sudo().write({'image_1920': line.image})
self.env.cr.commit()
odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
if odoo_base:
img_name = (line.sku or variant.default_code or 'variant') + '.png'
import time
cache_bust = int(time.time())
img_url = f"{odoo_base}/web/image/product.product/{variant.id}/image_1920/{img_name}?t={cache_bust}"
var_data['image'] = {
'src': img_url,
'name': img_name,
'alt': line.variant_name or '',
}
try:
wc_variation = client.create_product_variation(pm.woo_product_id, var_data)
self.env['woo.product.map'].create({
'instance_id': inst.id,
'product_id': variant.id,
'woo_product_id': wc_variation['id'],
'woo_product_name': line.variant_name,
'woo_sku': line.sku or '',
'woo_regular_price': line.regular_price,
'woo_sale_price': line.sale_price,
'woo_permalink': pm.woo_permalink or '',
'woo_product_type': 'simple',
'woo_parent_id': pm.woo_product_id,
'is_variation': True,
'state': 'mapped',
'company_id': inst.company_id.id,
})
created += 1
except Exception as e:
errors.append('%s: %s' % (line.variant_name, str(e)))
_logger.error("Failed to create variation: %s", e)
# Step 4: UPDATE existing variations
for line in lines_update:
variant = line.product_id
wc_var_id = line.wc_variation_id
if not wc_var_id:
continue
# Build variation attributes with WC attribute IDs
var_attributes = []
for ptav in variant.product_template_attribute_value_ids:
attr_name = ptav.attribute_id.name
wc_attr_id = wc_attr_id_map.get(attr_name.upper(), 0)
attr_entry = {'option': ptav.name}
if wc_attr_id:
attr_entry['id'] = wc_attr_id
else:
attr_entry['name'] = attr_name
var_attributes.append(attr_entry)
var_data = {
'regular_price': str(line.regular_price),
'sku': line.sku or '',
'attributes': var_attributes,
'manage_stock': True,
'stock_quantity': int(variant.qty_available),
}
if line.sale_price > 0:
var_data['sale_price'] = str(line.sale_price)
else:
var_data['sale_price'] = ''
wc_tax_class = self.env['woo.tax.map'].get_wc_tax_class(
inst, variant.taxes_id[:1].id if variant.taxes_id else False
)
if wc_tax_class:
var_data['tax_class'] = wc_tax_class
# Serve image directly from wizard line via custom endpoint
if line.image:
# Commit the line data so the image endpoint can read it
self.env.cr.commit()
odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
if odoo_base:
img_name = (line.sku or variant.default_code or 'variant') + '.png'
img_url = f"{odoo_base}/woo/image/{line.id}/{img_name}"
var_data['image'] = {
'src': img_url,
'name': img_name,
'alt': line.variant_name or '',
}
_logger.info("Variant image URL: %s", img_url)
# Also save to Odoo product for future reference
variant.sudo().write({'image_variant_1920': line.image})
try:
client.update_product_variation(pm.woo_product_id, wc_var_id, var_data)
# Update local map record
if line.map_id:
map_rec = self.env['woo.product.map'].browse(line.map_id)
if map_rec.exists():
map_rec.write({
'woo_sku': line.sku or '',
'woo_regular_price': line.regular_price,
'woo_sale_price': line.sale_price,
})
updated += 1
except Exception as e:
errors.append('Update %s: %s' % (line.variant_name, str(e)))
_logger.error("Failed to update variation: %s", e)
parts = []
if created:
parts.append('%d created' % created)
if updated:
parts.append('%d updated' % updated)
summary = ', '.join(parts) if parts else 'No changes'
inst._log_sync('product', 'odoo_to_woo', tmpl.name, 'success',
'Variants: %s for WC product #%s' % (summary, pm.woo_product_id))
msg = 'Variants: %s.' % summary
if errors:
msg += '\n\nErrors:\n' + '\n'.join(errors)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Variants Pushed',
'message': msg,
'type': 'success' if not errors else 'warning',
'sticky': bool(errors),
'next': {'type': 'ir.actions.act_window_close'},
},
}
class WooVariantPushLine(models.TransientModel):
_name = 'woo.variant.push.line'
_description = 'Variant Push Line'
wizard_id = fields.Many2one('woo.variant.push.wizard', ondelete='cascade')
product_id = fields.Many2one('product.product', string='Variant')
variant_name = fields.Char(string='Variant')
attribute_values = fields.Char(string='Attributes')
sku = fields.Char(string='SKU')
regular_price = fields.Float(string='Standard Price', digits='Product Price')
sale_price = fields.Float(string='Sale Price', digits='Product Price')
cost_price = fields.Float(string='Cost', digits='Product Price')
image = fields.Binary(string='Image', attachment=False)
include = fields.Boolean(string='Include', default=True)
is_default = fields.Boolean(string='Default')
already_synced = fields.Boolean(string='Already Synced')
wc_variation_id = fields.Integer(string='WC Variation ID')
map_id = fields.Integer(string='Map Record ID')

View File

@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="woo_variant_push_wizard_form" model="ir.ui.view">
<field name="name">woo.variant.push.wizard.form</field>
<field name="model">woo.variant.push.wizard</field>
<field name="arch" type="xml">
<form string="Push Variants to WooCommerce">
<sheet>
<field name="instance_id" invisible="1"/>
<field name="product_map_id" invisible="1"/>
<group>
<group>
<field name="product_name" string="Product"/>
<field name="woo_product_id" string="WC Product ID"/>
</group>
<group>
<field name="product_template_id" string="Odoo Template" readonly="1"/>
</group>
</group>
<separator string="Variants to Push"/>
<div class="alert alert-info" role="alert">
Review and edit each variant's pricing, SKU, and image.
New variants will be created, already synced variants will be updated.
Uncheck "Include" to skip a variant.
</div>
<field name="line_ids">
<list editable="bottom">
<field name="include" widget="boolean_toggle"/>
<field name="is_default" string="Default" widget="boolean_toggle" force_save="1"/>
<field name="product_id" column_invisible="1"/>
<field name="variant_name" readonly="1" force_save="1"/>
<field name="attribute_values" readonly="1" force_save="1"/>
<field name="sku"/>
<field name="regular_price" string="Standard Price"/>
<field name="sale_price"/>
<field name="cost_price" readonly="1" force_save="1"/>
<field name="image" widget="image" options="{'size': [48, 48]}"/>
<field name="already_synced" column_invisible="1" force_save="1"/>
<field name="wc_variation_id" column_invisible="1" force_save="1"/>
<field name="map_id" column_invisible="1" force_save="1"/>
</list>
</field>
</sheet>
<footer>
<button name="action_push" type="object"
string="Save &amp; Sync to WooCommerce" class="oe_highlight"
icon="fa-cloud-upload"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>