Split 49 modules/suites into independent git repos; untrack from monorepo
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:
@@ -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`
|
||||
@@ -1,4 +0,0 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import wizard
|
||||
from . import lib
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
from . import webhook
|
||||
from . import api
|
||||
from . import product_search
|
||||
@@ -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.',
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
from .woo_api_client import WooApiClient
|
||||
from .ai_service import AIService
|
||||
from .image_processor import ImageProcessor
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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']
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 ''
|
||||
@@ -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,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 |
@@ -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; }
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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 <= 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 >= 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 <= 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 >= 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 & 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 <= 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 >= 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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', '>=', (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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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'">
|
||||
← 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 ✓
|
||||
</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 ✓
|
||||
</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>
|
||||
@@ -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')
|
||||
@@ -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 & Sync to WooCommerce" class="oe_highlight"
|
||||
icon="fa-cloud-upload"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user