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

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

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

Binary file not shown.

View File

@@ -1,11 +0,0 @@
#!/bin/bash
echo "=== Recently created products ==="
PGPASSWORD='DevSecure2025!' psql -h db -U odoo -d westin-v19 -c "SELECT id, name, default_code, list_price, create_date FROM product_product ORDER BY create_date DESC LIMIT 5;"
echo ""
echo "=== Recent woo.product.map changes ==="
PGPASSWORD='DevSecure2025!' psql -h db -U odoo -d westin-v19 -c "SELECT id, woo_product_name, woo_sku, product_id, state, write_date FROM woo_product_map ORDER BY write_date DESC LIMIT 5;"
echo ""
echo "=== Unmapped WC products ==="
PGPASSWORD='DevSecure2025!' psql -h db -U odoo -d westin-v19 -c "SELECT id, woo_product_name, woo_sku, product_id, state FROM woo_product_map WHERE state='unmapped' LIMIT 5;"

View File

@@ -1,68 +0,0 @@
# Fusion WooDoo — WordPress Plugin
## What This Is
Thin WordPress/WooCommerce plugin that pairs with the `fusion_woocommerce` Odoo module. Receives data from Odoo, displays documents in the customer My Account portal, and fires webhooks to notify Odoo of WC events.
## Architecture
- **Receives from Odoo**: REST endpoints accept order status, invoices, deliveries, messages
- **Sends to Odoo**: WooCommerce webhooks fire on order/product/customer events
- **Displays to customers**: My Account tabs for sales orders, invoices, deliveries, returns, order timeline
## Key Files
```
fusion-woodoo.php — Plugin entry, activation/deactivation hooks
includes/class-fusion-woodoo.php — Singleton main class, loads all includes
includes/class-admin-settings.php — WP admin settings (Odoo URL, API key, toggles)
includes/class-rest-endpoints.php — REST API endpoints (receive from Odoo)
includes/class-webhooks.php — WC webhook registration/lifecycle
includes/class-my-account.php — My Account tab registration
includes/class-order-timeline.php — Visual order status timeline
includes/class-returns.php — Return/RMA request handling
includes/class-api-client.php — PHP client for calling Odoo API
templates/my-account/ — Customer portal templates
assets/css/my-account.css — Portal styles
assets/js/my-account.js — Portal JS (AJAX, reorder, returns)
```
## Authentication
- **Odoo → WP plugin**: Odoo API key as bearer token in Authorization header
- **WP plugin → Odoo**: Not direct — uses WC webhooks which Odoo validates via HMAC
## PDF Storage
- Invoices: `wp-content/uploads/fusion-woodoo/invoices/` (`.htaccess` protected)
- Deliveries: `wp-content/uploads/fusion-woodoo/deliveries/` (`.htaccess` protected)
- Served via PHP handler that validates user owns the order
## WordPress Options
- `fusion_woodoo_odoo_url` — Odoo instance URL
- `fusion_woodoo_api_key` — API key for Odoo auth
- `fusion_woodoo_show_sales_orders` — Toggle sales orders tab
- `fusion_woodoo_show_invoices` — Toggle invoices tab
- `fusion_woodoo_show_deliveries` — Toggle deliveries tab
- `fusion_woodoo_show_returns` — Toggle returns tab
## WooCommerce Order Meta Keys
- `_odoo_order_id`, `_odoo_invoice_id` — Linked Odoo record IDs
- `_odoo_invoice_pdf`, `_odoo_delivery_pdf` — PDF file paths
- `_odoo_tracking_number`, `_odoo_shipping_carrier` — Shipping info
- `_odoo_order_status` — Status for timeline display
- `_odoo_messages` — JSON array of customer-visible messages
## Deployment
```bash
# Deploy to westin WordPress
sshpass -p '9896924728Kk@@##' scp -r fusion-woo-odoo/fusion-woodoo/* westin@192.168.1.152:/tmp/fusion-woodoo/
sshpass -p '9896924728Kk@@##' ssh westin@192.168.1.152 "echo '9896924728Kk@@##' | sudo -S cp -r /tmp/fusion-woodoo/* /home/westinwp/htdocs/westinhealthcare.ca/wp-content/plugins/fusion-woodoo/"
```
## Webhook Lifecycle
- Registered on plugin activation / settings save
- Unregistered on plugin deactivation
- Re-registered when Odoo URL changes
- Topics: order.created, order.updated, product.updated, customer.created, customer.updated
## Requirements
- WordPress 6.0+
- WooCommerce 8.0+
- PHP 8.0+
- Odoo 19 with fusion_woocommerce module installed

View File

@@ -1,74 +0,0 @@
/* Fusion WooDoo — Admin Styles */
.fusion-woodoo-admin h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.fusion-woodoo-header {
margin-bottom: 20px;
color: #555;
}
.fusion-woodoo-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 20px 24px;
margin-bottom: 20px;
max-width: 900px;
}
.fusion-woodoo-card h2 {
font-size: 1.1rem;
margin-top: 0;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
/* Status badges */
.fusion-woodoo-status {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.fusion-woodoo-status--ok {
background: #e8f5e9;
color: #2e7d32;
}
.fusion-woodoo-status--error {
background: #fce4ec;
color: #c62828;
}
/* Test connection result */
.fusion-woodoo-test-result {
font-weight: 500;
font-size: 0.9rem;
}
.fusion-woodoo-test-result.success {
color: #2e7d32;
}
.fusion-woodoo-test-result.error {
color: #c62828;
}
/* Webhook table */
.fusion-woodoo-card .widefat {
border-collapse: collapse;
width: 100%;
margin-top: 12px;
}
.fusion-woodoo-card .widefat th,
.fusion-woodoo-card .widefat td {
padding: 8px 12px;
font-size: 0.875rem;
}

View File

@@ -1,376 +0,0 @@
/* Fusion WooDoo — My Account / Frontend Styles */
/* ── Portal wrapper ─────────────────────────────────────────── */
.fusion-woodoo-portal {
font-size: 0.95rem;
}
.fusion-woodoo-portal h2 {
font-size: 1.3rem;
margin-bottom: 16px;
}
.fusion-woodoo-portal h3 {
font-size: 1.1rem;
margin: 24px 0 12px;
}
/* ── Tables ─────────────────────────────────────────────────── */
.fusion-woodoo-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 24px;
}
.fusion-woodoo-table th,
.fusion-woodoo-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.fusion-woodoo-table thead th {
background: #f8f9fa;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
}
.fusion-woodoo-table tbody tr:hover {
background: #fafafa;
}
/* ── Status badges ──────────────────────────────────────────── */
.fusion-woodoo-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.78rem;
font-weight: 600;
background: #e9ecef;
color: #495057;
}
.fusion-woodoo-badge--sale,
.fusion-woodoo-badge--done,
.fusion-woodoo-badge--posted,
.fusion-woodoo-badge--paid {
background: #e8f5e9;
color: #2e7d32;
}
.fusion-woodoo-badge--draft,
.fusion-woodoo-badge--pending {
background: #fff8e1;
color: #f57f17;
}
.fusion-woodoo-badge--cancel {
background: #fce4ec;
color: #c62828;
}
.fusion-woodoo-badge--processing {
background: #e3f2fd;
color: #1565c0;
}
/* ── Utility ────────────────────────────────────────────────── */
.fusion-woodoo-empty {
color: #888;
font-style: italic;
padding: 16px 0;
}
.fusion-woodoo-muted {
color: #aaa;
}
.fusion-woodoo-notice {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.fusion-woodoo-notice--success {
background: #e8f5e9;
color: #2e7d32;
border-left: 4px solid #43a047;
}
.fusion-woodoo-notice--error {
background: #fce4ec;
color: #c62828;
border-left: 4px solid #e53935;
}
.fusion-woodoo-notice--warning {
background: #fff8e1;
color: #f57f17;
border-left: 4px solid #ffb300;
}
/* ── PDF button ─────────────────────────────────────────────── */
.fusion-woodoo-btn-pdf {
font-size: 0.82rem !important;
padding: 4px 10px !important;
}
/* ── Forms ──────────────────────────────────────────────────── */
.fusion-woodoo-form {
max-width: 580px;
}
.fusion-woodoo-form-row {
margin-bottom: 18px;
}
.fusion-woodoo-form-row label {
display: block;
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
}
.fusion-woodoo-form-row select,
.fusion-woodoo-form-row textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
font-family: inherit;
background: #fff;
}
.fusion-woodoo-form-row textarea {
resize: vertical;
}
/* Item checkboxes in return form */
.fw-return-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
}
.fw-return-item:last-child {
border-bottom: none;
}
.fw-return-item input[type="checkbox"] {
flex-shrink: 0;
}
.fw-return-item-qty {
width: 60px;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
}
/* ── Order Timeline ─────────────────────────────────────────── */
.fusion-woodoo-timeline-wrap {
margin: 28px 0 12px;
padding: 20px 0;
border-top: 1px solid #f0f0f0;
}
.fusion-woodoo-timeline-wrap h3 {
font-size: 1rem;
margin-bottom: 20px;
color: #333;
}
.fusion-woodoo-timeline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
}
.fusion-woodoo-timeline__step {
display: flex;
flex-direction: column;
align-items: center;
min-width: 80px;
}
.fusion-woodoo-timeline__dot {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid #ccc;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: all 0.2s ease;
}
.fusion-woodoo-timeline__step--done .fusion-woodoo-timeline__dot {
background: #43a047;
border-color: #43a047;
color: #fff;
}
.fusion-woodoo-timeline__step--done .fusion-woodoo-timeline__dot svg {
width: 14px;
height: 14px;
}
.fusion-woodoo-timeline__step--active .fusion-woodoo-timeline__dot {
background: #1976d2;
border-color: #1976d2;
box-shadow: 0 0 0 4px rgba(25, 118, 210, 0.15);
}
.fusion-woodoo-timeline__step--pending .fusion-woodoo-timeline__dot {
background: #f5f5f5;
border-color: #ddd;
}
.fusion-woodoo-timeline__label {
margin-top: 8px;
font-size: 0.78rem;
text-align: center;
color: #555;
max-width: 80px;
}
.fusion-woodoo-timeline__step--active .fusion-woodoo-timeline__label {
color: #1976d2;
font-weight: 600;
}
.fusion-woodoo-timeline__step--done .fusion-woodoo-timeline__label {
color: #2e7d32;
}
.fusion-woodoo-timeline__tracking {
display: block;
font-size: 0.75rem;
color: #777;
margin-top: 4px;
max-width: 100px;
word-break: break-all;
}
.fusion-woodoo-timeline__connector {
flex: 1;
height: 2px;
background: #ddd;
margin-bottom: 28px;
min-width: 20px;
}
.fusion-woodoo-timeline__connector--done {
background: #43a047;
}
/* ── Communication / Messages ───────────────────────────────── */
.fusion-woodoo-communication {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.fusion-woodoo-messages {
display: flex;
flex-direction: column;
gap: 12px;
}
.fusion-woodoo-message {
border: 1px solid #ebebeb;
border-radius: 6px;
padding: 12px 16px;
background: #fafafa;
}
.fusion-woodoo-message--email {
border-left: 4px solid #1976d2;
}
.fusion-woodoo-message--note {
border-left: 4px solid #f9a825;
background: #fffde7;
}
.fusion-woodoo-message__meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
font-size: 0.82rem;
color: #666;
}
.fusion-woodoo-message__author {
font-weight: 600;
color: #333;
}
.fusion-woodoo-message__type-badge {
padding: 1px 7px;
border-radius: 10px;
background: #e3f2fd;
color: #1565c0;
font-size: 0.75rem;
font-weight: 500;
}
.fusion-woodoo-message__type-badge--note {
background: #fff8e1;
color: #f57f17;
}
.fusion-woodoo-message__body {
font-size: 0.9rem;
line-height: 1.6;
color: #444;
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 640px) {
.fusion-woodoo-table th,
.fusion-woodoo-table td {
padding: 8px 8px;
font-size: 0.85rem;
}
.fusion-woodoo-timeline {
flex-direction: column;
align-items: flex-start;
gap: 0;
}
.fusion-woodoo-timeline__step {
flex-direction: row;
align-items: flex-start;
gap: 12px;
min-width: unset;
}
.fusion-woodoo-timeline__connector {
width: 2px;
height: 24px;
flex: none;
margin: 0 13px 0;
min-width: unset;
}
.fusion-woodoo-timeline__label {
text-align: left;
max-width: unset;
margin-top: 4px;
}
}

View File

@@ -1,32 +0,0 @@
/* Fusion WooDoo — Admin JS */
(function ($) {
'use strict';
$(function () {
const $btn = $('#fusion-woodoo-test-connection');
const $result = $('#fusion-woodoo-test-result');
$btn.on('click', function () {
$btn.prop('disabled', true).text('Testing…');
$result.hide().removeClass('success error');
$.post(fusionWooDooAdmin.ajaxUrl, {
action: 'fusion_woodoo_test_connection',
nonce: fusionWooDooAdmin.nonce,
})
.done(function (res) {
if (res.success) {
$result.addClass('success').text(res.data.message).show();
} else {
$result.addClass('error').text(res.data.message || 'Connection failed.').show();
}
})
.fail(function () {
$result.addClass('error').text('Request failed. Check your network connection.').show();
})
.always(function () {
$btn.prop('disabled', false).text('Test Connection');
});
});
});
}(jQuery));

View File

@@ -1,134 +0,0 @@
/* Fusion WooDoo — My Account JS */
(function ($) {
'use strict';
/* ── Return form ──────────────────────────────────────────── */
var ordersData = {};
try {
var raw = document.getElementById('fw-orders-data');
if (raw) ordersData = JSON.parse(raw.textContent || '{}');
} catch (e) {}
// Populate item list when order changes
$(document).on('change', '#fw-order-select', function () {
var orderId = $(this).val();
var $container = $('#fw-items-container');
var $list = $('#fw-items-list');
$list.empty();
if (!orderId || !ordersData[orderId]) {
$container.hide();
return;
}
var items = ordersData[orderId];
if (!items.length) {
$container.hide();
return;
}
items.forEach(function (item) {
var $row = $('<div class="fw-return-item"></div>');
$row.append(
$('<input type="checkbox" />').attr({
name: 'items[' + item.product_id + '][select]',
'data-product-id': item.product_id,
value: '1',
})
);
$row.append($('<span></span>').text(item.name));
$row.append(
$('<input type="number" class="fw-return-item-qty" />').attr({
name: 'items[' + item.product_id + '][qty]',
min: 1,
max: item.qty,
value: item.qty,
disabled: true,
})
);
$list.append($row);
});
$container.show();
});
// Enable/disable qty field with checkbox
$(document).on('change', '.fw-return-item input[type="checkbox"]', function () {
var $qty = $(this).closest('.fw-return-item').find('.fw-return-item-qty');
$qty.prop('disabled', !this.checked);
});
// Submit return form via AJAX
$(document).on('submit', '#fusion-woodoo-return-form', function (e) {
e.preventDefault();
var $form = $(this);
var $notice = $('#fusion-woodoo-return-notice');
var $submit = $form.find('[type=submit]');
// Collect selected items
var items = [];
$form.find('.fw-return-item input[type="checkbox"]:checked').each(function () {
var productId = $(this).data('product-id');
var qty = parseInt($(this).closest('.fw-return-item').find('.fw-return-item-qty').val(), 10) || 1;
items.push({ product_id: productId, qty: qty });
});
var payload = {
action: 'fusion_woodoo_submit_return',
nonce: fusionWooDoo.nonce,
order_id: $form.find('[name=order_id]').val(),
reason: $form.find('[name=reason]').val(),
items: items,
};
$submit.prop('disabled', true).text('Submitting…');
$notice.hide().removeClass('fusion-woodoo-notice--success fusion-woodoo-notice--error');
$.post(fusionWooDoo.ajaxUrl, payload)
.done(function (res) {
if (res.success) {
$notice.addClass('fusion-woodoo-notice--success').text(res.data.message).show();
$form[0].reset();
$('#fw-items-container').hide();
} else {
$notice.addClass('fusion-woodoo-notice--error').text(res.data.message || 'Submission failed.').show();
}
})
.fail(function () {
$notice.addClass('fusion-woodoo-notice--error').text('Request failed. Please try again.').show();
})
.always(function () {
$submit.prop('disabled', false).text('Submit Return Request');
$('html, body').animate({ scrollTop: $notice.offset().top - 40 }, 300);
});
});
/* ── Reorder button ───────────────────────────────────────── */
$(document).on('click', '.fusion-woodoo-reorder', function () {
var $btn = $(this);
var orderId = $btn.data('order-id');
var nonce = $btn.data('nonce');
$btn.prop('disabled', true).text('Adding…');
$.post(fusionWooDoo.ajaxUrl, {
action: 'fusion_woodoo_reorder',
nonce: nonce,
order_id: orderId,
})
.done(function (res) {
if (res.success && res.data.cart_url) {
window.location.href = res.data.cart_url;
} else {
alert(res.data.message || 'Could not reorder. Please try again.');
$btn.prop('disabled', false).text('Reorder');
}
})
.fail(function () {
alert('Request failed. Please try again.');
$btn.prop('disabled', false).text('Reorder');
});
});
}(jQuery));

View File

@@ -1,51 +0,0 @@
<?php
/**
* Plugin Name: Fusion WooDoo
* Plugin URI: https://fusionsoft.ca
* Description: Seamless Odoo integration for WooCommerce — sync products, orders, invoices, and inventory.
* Version: 1.0.0
* Author: Fusion Central
* Author URI: https://fusionsoft.ca
* Requires at least: 6.0
* Requires PHP: 8.0
* WC requires at least: 8.0
* WC tested up to: 9.0
* Text Domain: fusion-woodoo
* Domain Path: /languages
* License: GPL v2 or later
*/
if (!defined('ABSPATH')) exit;
define('FUSION_WOODOO_VERSION', '1.0.0');
define('FUSION_WOODOO_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('FUSION_WOODOO_PLUGIN_URL', plugin_dir_url(__FILE__));
add_action('plugins_loaded', function() {
if (!class_exists('WooCommerce')) {
add_action('admin_notices', function() {
echo '<div class="error"><p><strong>Fusion WooDoo</strong> requires WooCommerce to be installed and active.</p></div>';
});
return;
}
require_once FUSION_WOODOO_PLUGIN_DIR . 'includes/class-fusion-woodoo.php';
Fusion_WooDoo::instance();
});
register_activation_hook(__FILE__, function() {
$dirs = ['invoices', 'deliveries'];
foreach ($dirs as $dir) {
$path = wp_upload_dir()['basedir'] . '/fusion-woodoo/' . $dir;
wp_mkdir_p($path);
file_put_contents($path . '/.htaccess', 'deny from all');
}
flush_rewrite_rules();
});
register_deactivation_hook(__FILE__, function() {
if (file_exists(FUSION_WOODOO_PLUGIN_DIR . 'includes/class-webhooks.php')) {
require_once FUSION_WOODOO_PLUGIN_DIR . 'includes/class-webhooks.php';
Fusion_WooDoo_Webhooks::unregister_all();
}
flush_rewrite_rules();
});

View File

@@ -1,138 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_Admin_Settings {
const OPTION_ODOO_URL = 'fusion_woodoo_odoo_url';
const OPTION_API_KEY = 'fusion_woodoo_api_key';
const OPTION_SHOW_ORDERS = 'fusion_woodoo_show_orders';
const OPTION_SHOW_INVOICES = 'fusion_woodoo_show_invoices';
const OPTION_SHOW_DELIVERIES = 'fusion_woodoo_show_deliveries';
const OPTION_SHOW_RETURNS = 'fusion_woodoo_show_returns';
public function __construct() {
add_action('admin_menu', [$this, 'add_settings_page']);
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_post_fusion_woodoo_save', [$this, 'handle_save']);
add_action('wp_ajax_fusion_woodoo_test_connection', [$this, 'ajax_test_connection']);
add_action('update_option_' . self::OPTION_ODOO_URL, [$this, 'on_url_changed'], 10, 0);
}
public function add_settings_page(): void {
add_submenu_page(
'woocommerce',
__('Fusion WooDoo', 'fusion-woodoo'),
__('Fusion WooDoo', 'fusion-woodoo'),
'manage_woocommerce',
'fusion-woodoo-settings',
[$this, 'render_settings_page']
);
}
public function register_settings(): void {
$options = [
self::OPTION_ODOO_URL,
self::OPTION_API_KEY,
self::OPTION_SHOW_ORDERS,
self::OPTION_SHOW_INVOICES,
self::OPTION_SHOW_DELIVERIES,
self::OPTION_SHOW_RETURNS,
];
foreach ($options as $option) {
register_setting('fusion_woodoo_settings', $option, ['sanitize_callback' => 'sanitize_text_field']);
}
add_settings_section(
'fusion_woodoo_connection',
__('Odoo Connection', 'fusion-woodoo'),
null,
'fusion-woodoo-settings'
);
add_settings_field(
self::OPTION_ODOO_URL,
__('Odoo URL', 'fusion-woodoo'),
[$this, 'field_odoo_url'],
'fusion-woodoo-settings',
'fusion_woodoo_connection'
);
add_settings_field(
self::OPTION_API_KEY,
__('API Key', 'fusion-woodoo'),
[$this, 'field_api_key'],
'fusion-woodoo-settings',
'fusion_woodoo_connection'
);
add_settings_section(
'fusion_woodoo_portal',
__('Customer Portal Tabs', 'fusion-woodoo'),
null,
'fusion-woodoo-settings'
);
$toggles = [
self::OPTION_SHOW_ORDERS => __('Show Sales Orders tab', 'fusion-woodoo'),
self::OPTION_SHOW_INVOICES => __('Show Invoices tab', 'fusion-woodoo'),
self::OPTION_SHOW_DELIVERIES => __('Show Deliveries tab', 'fusion-woodoo'),
self::OPTION_SHOW_RETURNS => __('Show Returns tab', 'fusion-woodoo'),
];
foreach ($toggles as $option => $label) {
add_settings_field(
$option,
$label,
[$this, 'field_checkbox'],
'fusion-woodoo-settings',
'fusion_woodoo_portal',
['option' => $option]
);
}
}
public function field_odoo_url(): void {
$value = esc_attr(get_option(self::OPTION_ODOO_URL, ''));
echo '<input type="url" name="' . self::OPTION_ODOO_URL . '" value="' . $value . '" class="regular-text" placeholder="https://erp.example.com" />';
echo '<p class="description">' . __('Base URL of your Odoo instance — no trailing slash.', 'fusion-woodoo') . '</p>';
}
public function field_api_key(): void {
$value = esc_attr(get_option(self::OPTION_API_KEY, ''));
echo '<input type="password" name="' . self::OPTION_API_KEY . '" value="' . $value . '" class="regular-text" autocomplete="new-password" />';
echo '<p class="description">' . __('API key from the Fusion WooCommerce Odoo module.', 'fusion-woodoo') . '</p>';
}
public function field_checkbox(array $args): void {
$option = $args['option'];
$checked = checked(1, get_option($option, 1), false);
echo '<label><input type="checkbox" name="' . esc_attr($option) . '" value="1" ' . $checked . ' /> ' . __('Enabled', 'fusion-woodoo') . '</label>';
}
public function render_settings_page(): void {
$template = FUSION_WOODOO_PLUGIN_DIR . 'templates/admin/settings.php';
if (file_exists($template)) {
include $template;
}
}
public function on_url_changed(): void {
if (class_exists('Fusion_WooDoo_Webhooks')) {
Fusion_WooDoo_Webhooks::unregister_all();
(new Fusion_WooDoo_Webhooks())->register_webhooks();
}
}
public function ajax_test_connection(): void {
check_ajax_referer('fusion_woodoo_admin_nonce', 'nonce');
if (!current_user_can('manage_woocommerce')) {
wp_send_json_error(['message' => __('Permission denied.', 'fusion-woodoo')]);
}
$client = new Fusion_WooDoo_API_Client();
$result = $client->test_connection();
if ($result['success']) {
wp_send_json_success(['message' => __('Connection successful! Odoo is reachable.', 'fusion-woodoo')]);
} else {
wp_send_json_error(['message' => $result['error'] ?: __('Connection failed.', 'fusion-woodoo')]);
}
}
}

View File

@@ -1,91 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_API_Client {
private string $odoo_url;
private string $api_key;
public function __construct() {
$this->odoo_url = rtrim(get_option('fusion_woodoo_odoo_url', ''), '/');
$this->api_key = get_option('fusion_woodoo_api_key', '');
}
/**
* Make a POST request to an Odoo endpoint.
*
* @param string $endpoint e.g. '/woo/api/order/status'
* @param array $data
* @return array{success: bool, data: mixed, error: string}
*/
public function request(string $endpoint, array $data = []): array {
if (empty($this->odoo_url) || empty($this->api_key)) {
return ['success' => false, 'data' => null, 'error' => 'Odoo URL or API key not configured.'];
}
$url = $this->odoo_url . $endpoint;
$response = wp_remote_post($url, [
'timeout' => 15,
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $this->api_key,
],
'body' => wp_json_encode($data),
'data_format' => 'body',
]);
if (is_wp_error($response)) {
return ['success' => false, 'data' => null, 'error' => $response->get_error_message()];
}
$code = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($code >= 200 && $code < 300) {
return ['success' => true, 'data' => $body, 'error' => ''];
}
$error_msg = $body['message'] ?? $body['error'] ?? 'Unexpected response from Odoo (HTTP ' . $code . ').';
return ['success' => false, 'data' => $body, 'error' => $error_msg];
}
/**
* Test connectivity to Odoo.
*/
public function test_connection(): array {
return $this->request('/woo/api/order/status', ['ping' => true]);
}
/**
* Get order status from Odoo.
*
* @param int|string $order_id WooCommerce order ID
*/
public function get_order_status(int|string $order_id): array {
return $this->request('/woo/api/order/status', ['order_id' => $order_id]);
}
/**
* Submit a return request to Odoo.
*
* @param int|string $order_id
* @param array $items [['product_id' => ..., 'qty' => ...], ...]
* @param string $reason
*/
public function submit_return(int|string $order_id, array $items, string $reason): array {
return $this->request('/woo/api/return/create', [
'order_id' => $order_id,
'items' => $items,
'reason' => $reason,
]);
}
/**
* Fetch existing returns for a customer.
*
* @param int|string $customer_id WooCommerce user ID
*/
public function get_returns(int|string $customer_id): array {
return $this->request('/woo/api/return/list', ['customer_id' => $customer_id]);
}
}

View File

@@ -1,104 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo {
private static ?Fusion_WooDoo $instance = null;
public static function instance(): Fusion_WooDoo {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->load_includes();
$this->init_hooks();
}
private function load_includes(): void {
$includes = [
'class-api-client.php',
'class-admin-settings.php',
'class-rest-endpoints.php',
'class-webhooks.php',
'class-my-account.php',
'class-order-timeline.php',
'class-returns.php',
];
foreach ($includes as $file) {
$path = FUSION_WOODOO_PLUGIN_DIR . 'includes/' . $file;
if (file_exists($path)) {
require_once $path;
}
}
}
private function init_hooks(): void {
add_action('init', [$this, 'load_textdomain']);
add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_assets']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
// Instantiate components
new Fusion_WooDoo_Admin_Settings();
new Fusion_WooDoo_REST_Endpoints();
new Fusion_WooDoo_Webhooks();
new Fusion_WooDoo_My_Account();
new Fusion_WooDoo_Order_Timeline();
new Fusion_WooDoo_Returns();
}
public function load_textdomain(): void {
load_plugin_textdomain(
'fusion-woodoo',
false,
dirname(plugin_basename(FUSION_WOODOO_PLUGIN_DIR . 'fusion-woodoo.php')) . '/languages'
);
}
public function enqueue_frontend_assets(): void {
if (is_account_page() || is_woocommerce()) {
wp_enqueue_style(
'fusion-woodoo-my-account',
FUSION_WOODOO_PLUGIN_URL . 'assets/css/my-account.css',
[],
FUSION_WOODOO_VERSION
);
wp_enqueue_script(
'fusion-woodoo-my-account',
FUSION_WOODOO_PLUGIN_URL . 'assets/js/my-account.js',
['jquery'],
FUSION_WOODOO_VERSION,
true
);
wp_localize_script('fusion-woodoo-my-account', 'fusionWooDoo', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('fusion_woodoo_nonce'),
]);
}
}
public function enqueue_admin_assets(string $hook): void {
if (strpos($hook, 'fusion-woodoo') === false) {
return;
}
wp_enqueue_style(
'fusion-woodoo-admin',
FUSION_WOODOO_PLUGIN_URL . 'assets/css/admin.css',
[],
FUSION_WOODOO_VERSION
);
wp_enqueue_script(
'fusion-woodoo-admin',
FUSION_WOODOO_PLUGIN_URL . 'assets/js/admin.js',
['jquery'],
FUSION_WOODOO_VERSION,
true
);
wp_localize_script('fusion-woodoo-admin', 'fusionWooDooAdmin', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('fusion_woodoo_admin_nonce'),
]);
}
}

View File

@@ -1,127 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_My_Account {
const ENDPOINTS = [
'odoo-sales-orders' => 'Sales Orders',
'odoo-invoices' => 'Invoices',
'odoo-deliveries' => 'Deliveries',
'odoo-returns' => 'Returns',
];
const OPTION_MAP = [
'odoo-sales-orders' => 'fusion_woodoo_show_orders',
'odoo-invoices' => 'fusion_woodoo_show_invoices',
'odoo-deliveries' => 'fusion_woodoo_show_deliveries',
'odoo-returns' => 'fusion_woodoo_show_returns',
];
const TEMPLATE_MAP = [
'odoo-sales-orders' => 'sales-orders.php',
'odoo-invoices' => 'invoices.php',
'odoo-deliveries' => 'deliveries.php',
'odoo-returns' => 'returns.php',
];
public function __construct() {
add_action('init', [$this, 'add_rewrite_endpoints']);
add_filter('woocommerce_account_menu_items', [$this, 'add_menu_items']);
foreach (array_keys(self::ENDPOINTS) as $endpoint) {
add_action('woocommerce_account_' . $endpoint . '_endpoint', [$this, 'render_endpoint_content']);
}
// AJAX handler for PDF downloads
add_action('wp_ajax_fusion_woodoo_download_pdf', [$this, 'ajax_download_pdf']);
}
public function add_rewrite_endpoints(): void {
foreach (array_keys(self::ENDPOINTS) as $endpoint) {
add_rewrite_endpoint($endpoint, EP_ROOT | EP_PAGES);
}
}
public function add_menu_items(array $items): array {
$new_items = [];
foreach ($items as $key => $label) {
$new_items[$key] = $label;
// Insert Odoo tabs after 'orders'
if ($key === 'orders') {
foreach (self::ENDPOINTS as $endpoint => $title) {
if (get_option(self::OPTION_MAP[$endpoint], 1)) {
$new_items[$endpoint] = __($title, 'fusion-woodoo');
}
}
}
}
return $new_items;
}
/**
* Generic renderer — dispatches to the correct template.
*/
public function render_endpoint_content(): void {
// Determine which endpoint triggered this action
$current = null;
foreach (array_keys(self::ENDPOINTS) as $endpoint) {
if (did_action('woocommerce_account_' . $endpoint . '_endpoint')) {
$current = $endpoint;
break;
}
}
if (!$current || !isset(self::TEMPLATE_MAP[$current])) {
return;
}
$template = FUSION_WOODOO_PLUGIN_DIR . 'templates/my-account/' . self::TEMPLATE_MAP[$current];
if (file_exists($template)) {
include $template;
}
}
/**
* AJAX: serve a stored PDF file to the logged-in customer who owns the order.
*/
public function ajax_download_pdf(): void {
check_ajax_referer('fusion_woodoo_nonce', 'nonce');
$order_id = (int) ($_GET['order_id'] ?? 0);
$type = sanitize_key($_GET['type'] ?? 'invoice');
if (!$order_id || !is_user_logged_in()) {
wp_die(__('Access denied.', 'fusion-woodoo'), 403);
}
$order = wc_get_order($order_id);
if (!$order || $order->get_customer_id() !== get_current_user_id()) {
wp_die(__('Access denied.', 'fusion-woodoo'), 403);
}
$meta_key = $type === 'delivery' ? '_odoo_delivery_pdf' : '_odoo_invoice_pdf';
$rel_path = $order->get_meta($meta_key);
if (empty($rel_path)) {
wp_die(__('PDF not found.', 'fusion-woodoo'), 404);
}
$upload = wp_upload_dir();
$file_path = $upload['basedir'] . $rel_path;
if (!file_exists($file_path) || !is_file($file_path)) {
wp_die(__('PDF file not found on server.', 'fusion-woodoo'), 404);
}
// Security: ensure path stays within uploads dir
$real_base = realpath($upload['basedir']);
$real_file = realpath($file_path);
if (!$real_file || strpos($real_file, $real_base) !== 0) {
wp_die(__('Access denied.', 'fusion-woodoo'), 403);
}
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . basename($real_file) . '"');
header('Content-Length: ' . filesize($real_file));
readfile($real_file);
exit;
}
}

View File

@@ -1,42 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_Order_Timeline {
/**
* Timeline stages in order. Key = _odoo_order_status value(s) that map to this stage.
*/
const STAGES = [
'confirmed' => 'Confirmed',
'processing' => 'Processing',
'shipped' => 'Shipped',
'delivered' => 'Delivered',
'done' => 'Completed',
];
public function __construct() {
add_action('woocommerce_order_details_after_order_table', [$this, 'render_timeline']);
}
public function render_timeline(WC_Order $order): void {
$odoo_status = strtolower((string) $order->get_meta('_odoo_order_status'));
if (empty($odoo_status)) {
return;
}
$tracking_number = $order->get_meta('_odoo_tracking_number');
$shipping_carrier = $order->get_meta('_odoo_shipping_carrier');
$stage_keys = array_keys(self::STAGES);
$current_index = array_search($odoo_status, $stage_keys);
if ($current_index === false) {
$current_index = 0;
}
$template = FUSION_WOODOO_PLUGIN_DIR . 'templates/my-account/order-timeline.php';
if (file_exists($template)) {
include $template;
}
}
}

View File

@@ -1,189 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_REST_Endpoints {
public function __construct() {
add_action('rest_api_init', [$this, 'register_routes']);
}
public function register_routes(): void {
$namespace = 'fusion-woodoo/v1';
register_rest_route($namespace, '/order/update', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handle_order_update'],
'permission_callback' => [$this, 'verify_api_key'],
'args' => [
'order_id' => ['required' => true, 'validate_callback' => 'is_numeric'],
'status' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'tracking_number' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
'shipping_carrier' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
],
]);
register_rest_route($namespace, '/order/invoice', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handle_order_invoice'],
'permission_callback' => [$this, 'verify_api_key'],
'args' => [
'order_id' => ['required' => true, 'validate_callback' => 'is_numeric'],
'pdf_data' => ['required' => true],
'filename' => ['required' => false, 'sanitize_callback' => 'sanitize_file_name'],
],
]);
register_rest_route($namespace, '/order/delivery', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handle_order_delivery'],
'permission_callback' => [$this, 'verify_api_key'],
'args' => [
'order_id' => ['required' => true, 'validate_callback' => 'is_numeric'],
'pdf_data' => ['required' => true],
'filename' => ['required' => false, 'sanitize_callback' => 'sanitize_file_name'],
],
]);
register_rest_route($namespace, '/order/messages', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handle_order_messages'],
'permission_callback' => [$this, 'verify_api_key'],
'args' => [
'order_id' => ['required' => true, 'validate_callback' => 'is_numeric'],
'messages' => ['required' => true],
],
]);
}
/**
* Validate Bearer token from Authorization header.
*/
public function verify_api_key(WP_REST_Request $request): bool|WP_Error {
$auth_header = $request->get_header('authorization');
if (empty($auth_header) || !str_starts_with($auth_header, 'Bearer ')) {
return new WP_Error('missing_auth', __('Authorization header missing or malformed.', 'fusion-woodoo'), ['status' => 401]);
}
$provided_key = trim(substr($auth_header, 7));
$stored_key = get_option('fusion_woodoo_api_key', '');
if (empty($stored_key) || !hash_equals($stored_key, $provided_key)) {
return new WP_Error('invalid_api_key', __('Invalid API key.', 'fusion-woodoo'), ['status' => 403]);
}
return true;
}
/**
* POST /order/update — update order status and tracking info.
*/
public function handle_order_update(WP_REST_Request $request): WP_REST_Response|WP_Error {
$order_id = (int) $request->get_param('order_id');
$order = wc_get_order($order_id);
if (!$order) {
return new WP_Error('order_not_found', __('Order not found.', 'fusion-woodoo'), ['status' => 404]);
}
if ($status = $request->get_param('status')) {
$order->update_meta_data('_odoo_order_status', sanitize_text_field($status));
}
if ($tracking = $request->get_param('tracking_number')) {
$order->update_meta_data('_odoo_tracking_number', sanitize_text_field($tracking));
}
if ($carrier = $request->get_param('shipping_carrier')) {
$order->update_meta_data('_odoo_shipping_carrier', sanitize_text_field($carrier));
}
$order->save();
return rest_ensure_response(['success' => true, 'order_id' => $order_id]);
}
/**
* POST /order/invoice — store base64-encoded invoice PDF.
*/
public function handle_order_invoice(WP_REST_Request $request): WP_REST_Response|WP_Error {
return $this->save_pdf($request, 'invoices', '_odoo_invoice_pdf');
}
/**
* POST /order/delivery — store base64-encoded delivery PDF.
*/
public function handle_order_delivery(WP_REST_Request $request): WP_REST_Response|WP_Error {
return $this->save_pdf($request, 'deliveries', '_odoo_delivery_pdf');
}
/**
* POST /order/messages — store Odoo messages array against the order.
*/
public function handle_order_messages(WP_REST_Request $request): WP_REST_Response|WP_Error {
$order_id = (int) $request->get_param('order_id');
$order = wc_get_order($order_id);
if (!$order) {
return new WP_Error('order_not_found', __('Order not found.', 'fusion-woodoo'), ['status' => 404]);
}
$messages = $request->get_param('messages');
if (!is_array($messages)) {
return new WP_Error('invalid_messages', __('Messages must be a JSON array.', 'fusion-woodoo'), ['status' => 400]);
}
// Sanitize each message entry
$sanitized = array_map(function($msg) {
return [
'author' => sanitize_text_field($msg['author'] ?? ''),
'date' => sanitize_text_field($msg['date'] ?? ''),
'body' => wp_kses_post($msg['body'] ?? ''),
'type' => sanitize_text_field($msg['type'] ?? 'note'),
];
}, $messages);
$order->update_meta_data('_odoo_messages', $sanitized);
$order->save();
return rest_ensure_response(['success' => true, 'count' => count($sanitized)]);
}
/**
* Shared logic to decode and store a PDF file.
*/
private function save_pdf(WP_REST_Request $request, string $folder, string $meta_key): WP_REST_Response|WP_Error {
$order_id = (int) $request->get_param('order_id');
$order = wc_get_order($order_id);
if (!$order) {
return new WP_Error('order_not_found', __('Order not found.', 'fusion-woodoo'), ['status' => 404]);
}
$pdf_data = $request->get_param('pdf_data');
$decoded = base64_decode($pdf_data, true);
if ($decoded === false) {
return new WP_Error('invalid_pdf', __('Invalid base64-encoded PDF data.', 'fusion-woodoo'), ['status' => 400]);
}
$filename = $request->get_param('filename') ?: ($folder . '-' . $order_id . '-' . time() . '.pdf');
$filename = sanitize_file_name($filename);
$upload = wp_upload_dir();
$save_dir = $upload['basedir'] . '/fusion-woodoo/' . $folder . '/';
if (!file_exists($save_dir)) {
wp_mkdir_p($save_dir);
file_put_contents($save_dir . '.htaccess', 'deny from all');
}
$file_path = $save_dir . $filename;
$bytes = file_put_contents($file_path, $decoded);
if ($bytes === false) {
return new WP_Error('file_write_error', __('Could not save PDF to disk.', 'fusion-woodoo'), ['status' => 500]);
}
$relative = str_replace($upload['basedir'], '', $file_path);
$order->update_meta_data($meta_key, $relative);
$order->save();
return rest_ensure_response(['success' => true, 'path' => $relative]);
}
}

View File

@@ -1,103 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_Returns {
public function __construct() {
add_action('wp_ajax_fusion_woodoo_submit_return', [$this, 'ajax_submit_return']);
}
/**
* Get the last N orders for the current customer.
*
* @param int $limit
* @return WC_Order[]
*/
public function get_customer_orders(int $limit = 20): array {
if (!is_user_logged_in()) {
return [];
}
return wc_get_orders([
'customer' => get_current_user_id(),
'limit' => $limit,
'status' => ['completed', 'processing', 'on-hold'],
'orderby' => 'date',
'order' => 'DESC',
]);
}
/**
* Fetch existing returns for the current customer from Odoo.
*
* @return array
*/
public function get_existing_returns(): array {
if (!is_user_logged_in()) {
return [];
}
$client = new Fusion_WooDoo_API_Client();
$result = $client->get_returns(get_current_user_id());
return $result['success'] ? ($result['data']['returns'] ?? []) : [];
}
/**
* AJAX handler for return form submission.
*/
public function ajax_submit_return(): void {
check_ajax_referer('fusion_woodoo_nonce', 'nonce');
if (!is_user_logged_in()) {
wp_send_json_error(['message' => __('You must be logged in to submit a return.', 'fusion-woodoo')]);
}
$order_id = (int) ($_POST['order_id'] ?? 0);
$reason = sanitize_textarea_field($_POST['reason'] ?? '');
$items = $_POST['items'] ?? [];
if (!$order_id) {
wp_send_json_error(['message' => __('Please select an order.', 'fusion-woodoo')]);
}
if (empty($items) || !is_array($items)) {
wp_send_json_error(['message' => __('Please select at least one item to return.', 'fusion-woodoo')]);
}
if (empty($reason)) {
wp_send_json_error(['message' => __('Please provide a reason for the return.', 'fusion-woodoo')]);
}
// Verify the order belongs to the current user
$order = wc_get_order($order_id);
if (!$order || $order->get_customer_id() !== get_current_user_id()) {
wp_send_json_error(['message' => __('Order not found or access denied.', 'fusion-woodoo')]);
}
// Sanitize and validate items
$sanitized_items = [];
foreach ($items as $item) {
$product_id = (int) ($item['product_id'] ?? 0);
$qty = max(1, (int) ($item['qty'] ?? 1));
if ($product_id > 0) {
$sanitized_items[] = ['product_id' => $product_id, 'qty' => $qty];
}
}
if (empty($sanitized_items)) {
wp_send_json_error(['message' => __('No valid items selected for return.', 'fusion-woodoo')]);
}
$client = new Fusion_WooDoo_API_Client();
$result = $client->submit_return($order_id, $sanitized_items, $reason);
if ($result['success']) {
wp_send_json_success([
'message' => __('Return request submitted successfully. Our team will review it shortly.', 'fusion-woodoo'),
'return_id' => $result['data']['return_id'] ?? null,
]);
} else {
wp_send_json_error([
'message' => $result['error'] ?: __('Failed to submit return. Please try again or contact support.', 'fusion-woodoo'),
]);
}
}
}

View File

@@ -1,106 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
class Fusion_WooDoo_Webhooks {
const WEBHOOK_PREFIX = 'fusion-woodoo-';
public function __construct() {
add_action('woocommerce_settings_saved', [$this, 'maybe_register']);
}
/**
* Register all required WooCommerce webhooks pointing at the configured Odoo URL.
*/
public function register_webhooks(): void {
$odoo_url = rtrim(get_option('fusion_woodoo_odoo_url', ''), '/');
if (empty($odoo_url)) {
return;
}
$webhooks = [
[
'name' => self::WEBHOOK_PREFIX . 'order-created',
'topic' => 'order.created',
'api_url' => $odoo_url . '/woo/webhook/order',
],
[
'name' => self::WEBHOOK_PREFIX . 'order-updated',
'topic' => 'order.updated',
'api_url' => $odoo_url . '/woo/webhook/order',
],
[
'name' => self::WEBHOOK_PREFIX . 'product-updated',
'topic' => 'product.updated',
'api_url' => $odoo_url . '/woo/webhook/product',
],
[
'name' => self::WEBHOOK_PREFIX . 'customer-created',
'topic' => 'customer.created',
'api_url' => $odoo_url . '/woo/webhook/customer',
],
[
'name' => self::WEBHOOK_PREFIX . 'customer-updated',
'topic' => 'customer.updated',
'api_url' => $odoo_url . '/woo/webhook/customer',
],
];
// Remove stale webhooks first to avoid duplicates
self::unregister_all();
foreach ($webhooks as $wh_data) {
$webhook = new WC_Webhook();
$webhook->set_name($wh_data['name']);
$webhook->set_topic($wh_data['topic']);
$webhook->set_delivery_url($wh_data['api_url']);
$webhook->set_secret(get_option('fusion_woodoo_api_key', wp_generate_password(32)));
$webhook->set_status('active');
$webhook->save();
}
}
/**
* Delete all fusion-woodoo webhooks from WooCommerce.
*/
public static function unregister_all(): void {
$data_store = WC_Data_Store::load('webhook');
$all_ids = $data_store->get_webhooks_ids('active');
foreach ($all_ids as $id) {
$webhook = wc_get_webhook($id);
if ($webhook && str_starts_with($webhook->get_name(), self::WEBHOOK_PREFIX)) {
$webhook->delete(true);
}
}
}
/**
* Ping the Odoo URL and return whether it is reachable.
*/
public function check_stale(): bool {
$odoo_url = get_option('fusion_woodoo_odoo_url', '');
if (empty($odoo_url)) {
return false;
}
$response = wp_remote_get(rtrim($odoo_url, '/') . '/web/health', [
'timeout' => 10,
'sslverify' => apply_filters('fusion_woodoo_sslverify', true),
]);
return !is_wp_error($response) && wp_remote_retrieve_response_code($response) < 500;
}
/**
* Re-register webhooks when Odoo URL changes (triggered from settings).
*/
public function maybe_register(): void {
if (
isset($_POST[Fusion_WooDoo_Admin_Settings::OPTION_ODOO_URL]) &&
!empty($_POST[Fusion_WooDoo_Admin_Settings::OPTION_ODOO_URL])
) {
$this->register_webhooks();
}
}
}

View File

@@ -1,91 +0,0 @@
=== Fusion WooDoo ===
Contributors: fusioncentral
Tags: woocommerce, odoo, integration, erp, orders, invoices, inventory, sync
Requires at least: 6.0
Tested up to: 6.7
Requires PHP: 8.0
Stable tag: 1.0.0
WC requires at least: 8.0
WC tested up to: 9.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Seamless Odoo 19 integration for WooCommerce — sync orders, invoices, deliveries, and inventory in real time.
== Description ==
Fusion WooDoo bridges your WooCommerce store with an Odoo 19 instance running the `fusion_woocommerce` module. It provides:
* **Real-time order sync** via WooCommerce webhooks → Odoo
* **Status & tracking push-back** from Odoo → WooCommerce order meta
* **Customer portal tabs** in My Account: Sales Orders, Invoices, Deliveries, Returns
* **Visual order timeline** on the order detail page (Confirmed → Processing → Shipped → Delivered → Completed)
* **Invoice & delivery PDF storage** — Odoo pushes base64-encoded PDFs; customers download securely
* **Return requests** — customers submit returns from their account; routed to Odoo automatically
* **Odoo message thread** display on order detail pages
* **Admin settings page** under WooCommerce with one-click connection test and webhook status dashboard
== Requirements ==
* WordPress 6.0 or higher
* WooCommerce 8.0 or higher
* PHP 8.0 or higher
* Odoo 19 with the `fusion_woocommerce` Odoo module installed and configured
== Installation ==
1. Upload the `fusion-woodoo` folder to `/wp-content/plugins/`.
2. Activate the plugin through the **Plugins** screen in WordPress.
3. Go to **WooCommerce → Fusion WooDoo** in the WordPress admin.
4. Enter your Odoo base URL (e.g. `https://erp.example.com`) and the API key generated in the Odoo `fusion_woocommerce` module settings.
5. Save — webhooks will be registered automatically.
6. Click **Test Connection** to verify Odoo is reachable.
== Frequently Asked Questions ==
= Where do I get the API key? =
In Odoo, open the `fusion_woocommerce` module settings and copy the generated API key. Paste it into **WooCommerce → Fusion WooDoo → API Key**.
= Which WooCommerce webhooks does the plugin register? =
Five webhooks: `order.created`, `order.updated`, `product.updated`, `customer.created`, and `customer.updated`. All point to your Odoo instance. They are removed automatically on plugin deactivation.
= Where are invoice and delivery PDFs stored? =
Under `wp-content/uploads/fusion-woodoo/invoices/` and `.../deliveries/`. Both directories are protected by `.htaccess` (deny from all). PDFs are served only to the order owner via a signed AJAX request.
= Can customers reorder from the Sales Orders tab? =
Yes — the Reorder button adds all items from a previous order to the WooCommerce cart.
= How do I hide portal tabs I don't need? =
Go to **WooCommerce → Fusion WooDoo** and uncheck the portal tab toggles you want to hide.
== Screenshots ==
1. Admin settings page — connection details and webhook status.
2. My Account — Sales Orders tab.
3. My Account — Invoices tab with PDF download.
4. My Account — Deliveries tab with tracking links.
5. My Account — Returns tab with request form.
6. Order detail page — visual timeline.
== Changelog ==
= 1.0.0 =
* Initial release.
* Admin settings with connection test and webhook status.
* WP REST API endpoints: order/update, order/invoice, order/delivery, order/messages.
* WooCommerce webhook registration for orders, products, and customers.
* My Account portal tabs: Sales Orders, Invoices, Deliveries, Returns.
* Visual order timeline on order detail pages.
* Secure PDF download handler.
* Return request form with Odoo submission.
* Odoo message thread display.
== Upgrade Notice ==
= 1.0.0 =
Initial release — no upgrade steps required.

View File

@@ -1,85 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
$webhooks_active = class_exists('Fusion_WooDoo_Webhooks') ? (new Fusion_WooDoo_Webhooks())->check_stale() : false;
?>
<div class="wrap fusion-woodoo-admin">
<h1><?php esc_html_e('Fusion WooDoo Settings', 'fusion-woodoo'); ?></h1>
<div class="fusion-woodoo-header">
<p><?php esc_html_e('Configure your Odoo connection and customer portal settings below.', 'fusion-woodoo'); ?></p>
</div>
<div class="fusion-woodoo-card">
<form method="post" action="options.php">
<?php
settings_fields('fusion_woodoo_settings');
do_settings_sections('fusion-woodoo-settings');
submit_button(__('Save Settings', 'fusion-woodoo'));
?>
</form>
</div>
<div class="fusion-woodoo-card">
<h2><?php esc_html_e('Connection Status', 'fusion-woodoo'); ?></h2>
<p>
<strong><?php esc_html_e('Odoo URL:', 'fusion-woodoo'); ?></strong>
<?php echo esc_html(get_option('fusion_woodoo_odoo_url', __('Not configured', 'fusion-woodoo'))); ?>
</p>
<p>
<strong><?php esc_html_e('Odoo Reachable:', 'fusion-woodoo'); ?></strong>
<?php if ($webhooks_active): ?>
<span class="fusion-woodoo-status fusion-woodoo-status--ok"><?php esc_html_e('Yes', 'fusion-woodoo'); ?></span>
<?php else: ?>
<span class="fusion-woodoo-status fusion-woodoo-status--error"><?php esc_html_e('Not confirmed', 'fusion-woodoo'); ?></span>
<?php endif; ?>
</p>
<button type="button" id="fusion-woodoo-test-connection" class="button button-secondary">
<?php esc_html_e('Test Connection', 'fusion-woodoo'); ?>
</button>
<span id="fusion-woodoo-test-result" class="fusion-woodoo-test-result" style="display:none;margin-left:12px;"></span>
</div>
<div class="fusion-woodoo-card">
<h2><?php esc_html_e('Webhook Status', 'fusion-woodoo'); ?></h2>
<p><?php esc_html_e('Webhooks push real-time order, product, and customer updates from WooCommerce to Odoo.', 'fusion-woodoo'); ?></p>
<?php
$data_store = WC_Data_Store::load('webhook');
$all_ids = $data_store->get_webhooks_ids('active');
$wh_list = [];
foreach ($all_ids as $id) {
$wh = wc_get_webhook($id);
if ($wh && str_starts_with($wh->get_name(), 'fusion-woodoo-')) {
$wh_list[] = $wh;
}
}
?>
<?php if (empty($wh_list)): ?>
<p><em><?php esc_html_e('No Fusion WooDoo webhooks registered. Save a valid Odoo URL to register them automatically.', 'fusion-woodoo'); ?></em></p>
<?php else: ?>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e('Name', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Topic', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Delivery URL', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Status', 'fusion-woodoo'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($wh_list as $wh): ?>
<tr>
<td><?php echo esc_html($wh->get_name()); ?></td>
<td><?php echo esc_html($wh->get_topic()); ?></td>
<td><?php echo esc_html($wh->get_delivery_url()); ?></td>
<td>
<span class="fusion-woodoo-status fusion-woodoo-status--<?php echo $wh->get_status() === 'active' ? 'ok' : 'error'; ?>">
<?php echo esc_html(ucfirst($wh->get_status())); ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>

View File

@@ -1,36 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Renders the Odoo message thread for a given order.
* Include from the order details page — $order must be in scope.
*
* @var WC_Order $order
*/
$messages = $order->get_meta('_odoo_messages');
if (empty($messages) || !is_array($messages)) {
return;
}
?>
<div class="fusion-woodoo-communication">
<h3><?php esc_html_e('Messages', 'fusion-woodoo'); ?></h3>
<div class="fusion-woodoo-messages">
<?php foreach ($messages as $msg):
$type = sanitize_key($msg['type'] ?? 'note');
?>
<div class="fusion-woodoo-message fusion-woodoo-message--<?php echo esc_attr($type); ?>">
<div class="fusion-woodoo-message__meta">
<span class="fusion-woodoo-message__author"><?php echo esc_html($msg['author'] ?? __('Odoo', 'fusion-woodoo')); ?></span>
<span class="fusion-woodoo-message__date"><?php echo esc_html($msg['date'] ?? ''); ?></span>
<?php if ($type === 'email'): ?>
<span class="fusion-woodoo-message__type-badge"><?php esc_html_e('Email', 'fusion-woodoo'); ?></span>
<?php elseif ($type === 'note'): ?>
<span class="fusion-woodoo-message__type-badge fusion-woodoo-message__type-badge--note"><?php esc_html_e('Note', 'fusion-woodoo'); ?></span>
<?php endif; ?>
</div>
<div class="fusion-woodoo-message__body">
<?php echo wp_kses_post($msg['body'] ?? ''); ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>

View File

@@ -1,105 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
$client = new Fusion_WooDoo_API_Client();
$result = $client->request('/woo/api/delivery/list', ['customer_id' => get_current_user_id()]);
$deliveries = $result['success'] ? ($result['data']['deliveries'] ?? []) : [];
// Also pull locally stored delivery PDFs
$wc_orders = wc_get_orders([
'customer' => get_current_user_id(),
'limit' => 50,
'meta_key' => '_odoo_delivery_pdf',
]);
?>
<div class="fusion-woodoo-portal">
<h2><?php esc_html_e('Deliveries', 'fusion-woodoo'); ?></h2>
<?php if (empty($deliveries) && empty($wc_orders)): ?>
<p class="fusion-woodoo-empty"><?php esc_html_e('No deliveries found.', 'fusion-woodoo'); ?></p>
<?php else: ?>
<table class="fusion-woodoo-table">
<thead>
<tr>
<th><?php esc_html_e('Reference', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Order', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Scheduled Date', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Carrier', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Tracking', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Status', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('PDF', 'fusion-woodoo'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($deliveries as $del): ?>
<tr>
<td><?php echo esc_html($del['name'] ?? '—'); ?></td>
<td><?php echo esc_html($del['wc_order_id'] ? '#' . $del['wc_order_id'] : '—'); ?></td>
<td><?php echo esc_html($del['scheduled_date'] ?? '—'); ?></td>
<td><?php echo esc_html($del['carrier'] ?? '—'); ?></td>
<td>
<?php if (!empty($del['tracking_number'])): ?>
<?php if (!empty($del['tracking_url'])): ?>
<a href="<?php echo esc_url($del['tracking_url']); ?>" target="_blank" rel="noopener">
<?php echo esc_html($del['tracking_number']); ?>
</a>
<?php else: ?>
<?php echo esc_html($del['tracking_number']); ?>
<?php endif; ?>
<?php else: ?>
<span class="fusion-woodoo-muted"><?php esc_html_e('N/A', 'fusion-woodoo'); ?></span>
<?php endif; ?>
</td>
<td>
<span class="fusion-woodoo-badge fusion-woodoo-badge--<?php echo esc_attr($del['state'] ?? 'draft'); ?>">
<?php echo esc_html(ucfirst($del['state'] ?? '—')); ?>
</span>
</td>
<td>
<?php if (!empty($del['wc_order_id'])): ?>
<a href="<?php echo esc_url(add_query_arg([
'action' => 'fusion_woodoo_download_pdf',
'type' => 'delivery',
'order_id' => (int) $del['wc_order_id'],
'nonce' => wp_create_nonce('fusion_woodoo_nonce'),
], admin_url('admin-ajax.php'))); ?>" class="button fusion-woodoo-btn-pdf">
<?php esc_html_e('Download PDF', 'fusion-woodoo'); ?>
</a>
<?php else: ?>
<span class="fusion-woodoo-muted">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php foreach ($wc_orders as $order):
$tracking_number = $order->get_meta('_odoo_tracking_number');
$shipping_carrier = $order->get_meta('_odoo_shipping_carrier');
?>
<tr>
<td><?php esc_html_e('Delivery', 'fusion-woodoo'); ?></td>
<td><?php echo esc_html('#' . $order->get_order_number()); ?></td>
<td><?php echo esc_html($order->get_date_completed() ? $order->get_date_completed()->date_i18n('Y-m-d') : '—'); ?></td>
<td><?php echo esc_html($shipping_carrier ?: '—'); ?></td>
<td><?php echo esc_html($tracking_number ?: '—'); ?></td>
<td><span class="fusion-woodoo-badge"><?php echo esc_html(ucfirst($order->get_status())); ?></span></td>
<td>
<?php if (!empty($order->get_meta('_odoo_delivery_pdf'))): ?>
<a href="<?php echo esc_url(add_query_arg([
'action' => 'fusion_woodoo_download_pdf',
'type' => 'delivery',
'order_id' => $order->get_id(),
'nonce' => wp_create_nonce('fusion_woodoo_nonce'),
], admin_url('admin-ajax.php'))); ?>" class="button fusion-woodoo-btn-pdf">
<?php esc_html_e('Download PDF', 'fusion-woodoo'); ?>
</a>
<?php else: ?>
<span class="fusion-woodoo-muted">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>

View File

@@ -1,88 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
// Load invoices from Odoo
$client = new Fusion_WooDoo_API_Client();
$result = $client->request('/woo/api/invoice/list', ['customer_id' => get_current_user_id()]);
$invoices = $result['success'] ? ($result['data']['invoices'] ?? []) : [];
// Also pull locally stored invoices from WC orders
$wc_orders = wc_get_orders([
'customer' => get_current_user_id(),
'limit' => 50,
'meta_key' => '_odoo_invoice_pdf',
]);
?>
<div class="fusion-woodoo-portal">
<h2><?php esc_html_e('Invoices', 'fusion-woodoo'); ?></h2>
<?php if (empty($invoices) && empty($wc_orders)): ?>
<p class="fusion-woodoo-empty"><?php esc_html_e('No invoices found.', 'fusion-woodoo'); ?></p>
<?php else: ?>
<table class="fusion-woodoo-table">
<thead>
<tr>
<th><?php esc_html_e('Invoice #', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Order', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Date', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Amount Due', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Status', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('PDF', 'fusion-woodoo'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($invoices as $inv): ?>
<tr>
<td><?php echo esc_html($inv['name'] ?? '—'); ?></td>
<td><?php echo esc_html($inv['wc_order_id'] ?? '—'); ?></td>
<td><?php echo esc_html($inv['invoice_date'] ?? '—'); ?></td>
<td><?php echo '$' . number_format((float) ($inv['amount_due'] ?? 0), 2); ?></td>
<td>
<span class="fusion-woodoo-badge fusion-woodoo-badge--<?php echo esc_attr($inv['state'] ?? 'draft'); ?>">
<?php echo esc_html(ucfirst($inv['state'] ?? '—')); ?>
</span>
</td>
<td>
<?php if (!empty($inv['wc_order_id'])): ?>
<a href="<?php echo esc_url(add_query_arg([
'action' => 'fusion_woodoo_download_pdf',
'type' => 'invoice',
'order_id' => (int) $inv['wc_order_id'],
'nonce' => wp_create_nonce('fusion_woodoo_nonce'),
], admin_url('admin-ajax.php'))); ?>" class="button fusion-woodoo-btn-pdf">
<?php esc_html_e('Download PDF', 'fusion-woodoo'); ?>
</a>
<?php else: ?>
<span class="fusion-woodoo-muted"><?php esc_html_e('Unavailable', 'fusion-woodoo'); ?></span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php foreach ($wc_orders as $order):
if (in_array($order->get_id(), array_column($invoices, 'wc_order_id'))) continue;
$has_pdf = !empty($order->get_meta('_odoo_invoice_pdf'));
if (!$has_pdf) continue;
?>
<tr>
<td><?php esc_html_e('Invoice', 'fusion-woodoo'); ?></td>
<td><?php echo esc_html('#' . $order->get_order_number()); ?></td>
<td><?php echo esc_html($order->get_date_created() ? $order->get_date_created()->date_i18n('Y-m-d') : '—'); ?></td>
<td><?php echo '$' . number_format((float) $order->get_total(), 2); ?></td>
<td><span class="fusion-woodoo-badge"><?php echo esc_html(ucfirst($order->get_status())); ?></span></td>
<td>
<a href="<?php echo esc_url(add_query_arg([
'action' => 'fusion_woodoo_download_pdf',
'type' => 'invoice',
'order_id' => $order->get_id(),
'nonce' => wp_create_nonce('fusion_woodoo_nonce'),
], admin_url('admin-ajax.php'))); ?>" class="button fusion-woodoo-btn-pdf">
<?php esc_html_e('Download PDF', 'fusion-woodoo'); ?>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>

View File

@@ -1,49 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Variables available from class-order-timeline.php:
* @var WC_Order $order
* @var string $odoo_status
* @var string $tracking_number
* @var string $shipping_carrier
* @var int $current_index
* @var array $stage_keys (from Fusion_WooDoo_Order_Timeline::STAGES)
*/
$stages = Fusion_WooDoo_Order_Timeline::STAGES;
?>
<div class="fusion-woodoo-timeline-wrap">
<h3><?php esc_html_e('Order Status', 'fusion-woodoo'); ?></h3>
<div class="fusion-woodoo-timeline">
<?php foreach ($stages as $key => $label):
$idx = array_search($key, array_keys($stages));
$is_done = $idx < $current_index;
$is_active = $idx === $current_index;
$state_class = $is_done ? 'done' : ($is_active ? 'active' : 'pending');
?>
<div class="fusion-woodoo-timeline__step fusion-woodoo-timeline__step--<?php echo esc_attr($state_class); ?>">
<div class="fusion-woodoo-timeline__dot">
<?php if ($is_done): ?>
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<?php endif; ?>
</div>
<div class="fusion-woodoo-timeline__label">
<?php echo esc_html(__($label, 'fusion-woodoo')); ?>
<?php if ($is_active && $key === 'shipped' && ($tracking_number || $shipping_carrier)): ?>
<span class="fusion-woodoo-timeline__tracking">
<?php if ($shipping_carrier): ?>
<?php echo esc_html($shipping_carrier); ?>
<?php endif; ?>
<?php if ($tracking_number): ?>
— <?php esc_html_e('Tracking:', 'fusion-woodoo'); ?>
<strong><?php echo esc_html($tracking_number); ?></strong>
<?php endif; ?>
</span>
<?php endif; ?>
</div>
</div>
<?php if ($idx < count($stages) - 1): ?>
<div class="fusion-woodoo-timeline__connector fusion-woodoo-timeline__connector--<?php echo $is_done ? 'done' : 'pending'; ?>"></div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>

View File

@@ -1,99 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
$returns_handler = new Fusion_WooDoo_Returns();
$customer_orders = $returns_handler->get_customer_orders();
$existing = $returns_handler->get_existing_returns();
?>
<div class="fusion-woodoo-portal fusion-woodoo-returns">
<h2><?php esc_html_e('Returns', 'fusion-woodoo'); ?></h2>
<?php if (!empty($existing)): ?>
<h3><?php esc_html_e('Your Return Requests', 'fusion-woodoo'); ?></h3>
<table class="fusion-woodoo-table">
<thead>
<tr>
<th><?php esc_html_e('Return #', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Order', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Date', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Reason', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Status', 'fusion-woodoo'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($existing as $ret): ?>
<tr>
<td><?php echo esc_html($ret['name'] ?? '—'); ?></td>
<td><?php echo esc_html($ret['wc_order_id'] ? '#' . $ret['wc_order_id'] : '—'); ?></td>
<td><?php echo esc_html($ret['date'] ?? '—'); ?></td>
<td><?php echo esc_html($ret['reason'] ?? '—'); ?></td>
<td>
<span class="fusion-woodoo-badge fusion-woodoo-badge--<?php echo esc_attr($ret['state'] ?? 'pending'); ?>">
<?php echo esc_html(ucfirst($ret['state'] ?? 'Pending')); ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h3><?php esc_html_e('Submit a New Return', 'fusion-woodoo'); ?></h3>
<?php if (empty($customer_orders)): ?>
<p class="fusion-woodoo-empty"><?php esc_html_e('No eligible orders found for return.', 'fusion-woodoo'); ?></p>
<?php else: ?>
<div id="fusion-woodoo-return-notice" style="display:none;" class="fusion-woodoo-notice"></div>
<form id="fusion-woodoo-return-form" class="fusion-woodoo-form">
<?php wp_nonce_field('fusion_woodoo_nonce', 'nonce'); ?>
<div class="fusion-woodoo-form-row">
<label for="fw-order-select"><?php esc_html_e('Select Order', 'fusion-woodoo'); ?></label>
<select id="fw-order-select" name="order_id" required>
<option value=""><?php esc_html_e('— Choose an order —', 'fusion-woodoo'); ?></option>
<?php foreach ($customer_orders as $order): ?>
<option value="<?php echo esc_attr($order->get_id()); ?>">
#<?php echo esc_html($order->get_order_number()); ?>
— <?php echo esc_html($order->get_date_created() ? $order->get_date_created()->date_i18n('Y-m-d') : ''); ?>
(<?php echo '$' . number_format((float) $order->get_total(), 2); ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div id="fw-items-container" class="fusion-woodoo-form-row" style="display:none;">
<label><?php esc_html_e('Select Items to Return', 'fusion-woodoo'); ?></label>
<div id="fw-items-list"></div>
</div>
<div class="fusion-woodoo-form-row">
<label for="fw-return-reason"><?php esc_html_e('Reason for Return', 'fusion-woodoo'); ?></label>
<textarea id="fw-return-reason" name="reason" rows="4" required placeholder="<?php esc_attr_e('Please describe why you are returning these items.', 'fusion-woodoo'); ?>"></textarea>
</div>
<button type="submit" class="button button-primary">
<?php esc_html_e('Submit Return Request', 'fusion-woodoo'); ?>
</button>
</form>
<script type="application/json" id="fw-orders-data">
<?php
$orders_data = [];
foreach ($customer_orders as $order) {
$items = [];
foreach ($order->get_items() as $item) {
$items[] = [
'product_id' => $item->get_product_id(),
'variation_id' => $item->get_variation_id(),
'name' => $item->get_name(),
'qty' => $item->get_quantity(),
];
}
$orders_data[$order->get_id()] = $items;
}
echo wp_json_encode($orders_data);
?>
</script>
<?php endif; ?>
</div>

View File

@@ -1,56 +0,0 @@
<?php
if (!defined('ABSPATH')) exit;
$client = new Fusion_WooDoo_API_Client();
$result = $client->request('/woo/api/sales/list', ['customer_id' => get_current_user_id()]);
$orders = $result['success'] ? ($result['data']['orders'] ?? []) : [];
?>
<div class="fusion-woodoo-portal">
<h2><?php esc_html_e('Sales Orders', 'fusion-woodoo'); ?></h2>
<?php if (empty($orders)): ?>
<p class="fusion-woodoo-empty"><?php esc_html_e('No sales orders found.', 'fusion-woodoo'); ?></p>
<?php else: ?>
<table class="fusion-woodoo-table woocommerce-orders-table">
<thead>
<tr>
<th><?php esc_html_e('Order #', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Date', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Status', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Total', 'fusion-woodoo'); ?></th>
<th><?php esc_html_e('Actions', 'fusion-woodoo'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($orders as $order): ?>
<tr>
<td><?php echo esc_html($order['name'] ?? '—'); ?></td>
<td><?php echo esc_html($order['date_order'] ?? '—'); ?></td>
<td>
<span class="fusion-woodoo-badge fusion-woodoo-badge--<?php echo esc_attr($order['state'] ?? 'draft'); ?>">
<?php echo esc_html(ucfirst($order['state'] ?? '—')); ?>
</span>
</td>
<td><?php echo '$' . number_format((float) ($order['amount_total'] ?? 0), 2); ?></td>
<td>
<?php if (!empty($order['wc_order_id'])): ?>
<button
class="button fusion-woodoo-reorder"
data-order-id="<?php echo esc_attr($order['wc_order_id']); ?>"
data-nonce="<?php echo esc_attr(wp_create_nonce('fusion_woodoo_nonce')); ?>">
<?php esc_html_e('Reorder', 'fusion-woodoo'); ?>
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if (!$result['success']): ?>
<div class="fusion-woodoo-notice fusion-woodoo-notice--warning">
<p><?php esc_html_e('Could not load sales orders from Odoo at this time. Please try again later.', 'fusion-woodoo'); ?></p>
</div>
<?php endif; ?>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,439 +0,0 @@
# Graph Report - /Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo (2026-04-22)
## Corpus Check
- 54 files · ~29,134 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 526 nodes · 866 edges · 61 communities detected
- Extraction: 74% EXTRACTED · 26% INFERRED · 0% AMBIGUOUS · INFERRED: 229 edges (avg confidence: 0.71)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
- [[_COMMUNITY_Community 0|Community 0]]
- [[_COMMUNITY_Community 1|Community 1]]
- [[_COMMUNITY_Community 2|Community 2]]
- [[_COMMUNITY_Community 3|Community 3]]
- [[_COMMUNITY_Community 4|Community 4]]
- [[_COMMUNITY_Community 5|Community 5]]
- [[_COMMUNITY_Community 6|Community 6]]
- [[_COMMUNITY_Community 7|Community 7]]
- [[_COMMUNITY_Community 8|Community 8]]
- [[_COMMUNITY_Community 9|Community 9]]
- [[_COMMUNITY_Community 10|Community 10]]
- [[_COMMUNITY_Community 11|Community 11]]
- [[_COMMUNITY_Community 12|Community 12]]
- [[_COMMUNITY_Community 13|Community 13]]
- [[_COMMUNITY_Community 14|Community 14]]
- [[_COMMUNITY_Community 15|Community 15]]
- [[_COMMUNITY_Community 16|Community 16]]
- [[_COMMUNITY_Community 17|Community 17]]
- [[_COMMUNITY_Community 18|Community 18]]
- [[_COMMUNITY_Community 19|Community 19]]
- [[_COMMUNITY_Community 20|Community 20]]
- [[_COMMUNITY_Community 21|Community 21]]
- [[_COMMUNITY_Community 22|Community 22]]
- [[_COMMUNITY_Community 23|Community 23]]
- [[_COMMUNITY_Community 24|Community 24]]
- [[_COMMUNITY_Community 25|Community 25]]
- [[_COMMUNITY_Community 26|Community 26]]
- [[_COMMUNITY_Community 27|Community 27]]
- [[_COMMUNITY_Community 28|Community 28]]
- [[_COMMUNITY_Community 29|Community 29]]
- [[_COMMUNITY_Community 30|Community 30]]
- [[_COMMUNITY_Community 31|Community 31]]
- [[_COMMUNITY_Community 32|Community 32]]
- [[_COMMUNITY_Community 33|Community 33]]
- [[_COMMUNITY_Community 34|Community 34]]
- [[_COMMUNITY_Community 35|Community 35]]
- [[_COMMUNITY_Community 36|Community 36]]
- [[_COMMUNITY_Community 37|Community 37]]
- [[_COMMUNITY_Community 38|Community 38]]
- [[_COMMUNITY_Community 39|Community 39]]
- [[_COMMUNITY_Community 40|Community 40]]
- [[_COMMUNITY_Community 41|Community 41]]
- [[_COMMUNITY_Community 42|Community 42]]
- [[_COMMUNITY_Community 43|Community 43]]
- [[_COMMUNITY_Community 44|Community 44]]
- [[_COMMUNITY_Community 45|Community 45]]
- [[_COMMUNITY_Community 46|Community 46]]
- [[_COMMUNITY_Community 47|Community 47]]
- [[_COMMUNITY_Community 48|Community 48]]
- [[_COMMUNITY_Community 49|Community 49]]
- [[_COMMUNITY_Community 50|Community 50]]
- [[_COMMUNITY_Community 51|Community 51]]
- [[_COMMUNITY_Community 52|Community 52]]
- [[_COMMUNITY_Community 53|Community 53]]
- [[_COMMUNITY_Community 54|Community 54]]
- [[_COMMUNITY_Community 55|Community 55]]
- [[_COMMUNITY_Community 56|Community 56]]
- [[_COMMUNITY_Community 57|Community 57]]
- [[_COMMUNITY_Community 58|Community 58]]
- [[_COMMUNITY_Community 59|Community 59]]
- [[_COMMUNITY_Community 60|Community 60]]
## God Nodes (most connected - your core abstractions)
1. `WooApiClient` - 82 edges
2. `ProductMapping` - 65 edges
3. `WooInstance` - 31 edges
4. `AIService` - 20 edges
5. `WooProductCreateWizard` - 18 edges
6. `WooDashboard` - 16 edges
7. `ImageProcessor` - 16 edges
8. `WooProductMap` - 14 edges
9. `WooOrder` - 10 edges
10. `Fusion_WooDoo_Admin_Settings` - 10 edges
## Surprising Connections (you probably didn't know these)
- `Push selected variants to WooCommerce.` --uses--> `ImageProcessor` [INFERRED]
/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/wizard/woo_variant_push.py → /Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/lib/image_processor.py
- `Fetch all WooCommerce categories and display for mapping.` --uses--> `WooApiClient` [INFERRED]
/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py → /Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py
- `Fetch WooCommerce tax classes for mapping.` --uses--> `WooApiClient` [INFERRED]
/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py → /Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py
- `Return a WooApiClient instance for this WooCommerce connection.` --uses--> `WooApiClient` [INFERRED]
/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py → /Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py
- `Test the WooCommerce connection and update state.` --uses--> `WooApiClient` [INFERRED]
/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py → /Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/lib/woo_api_client.py
## Communities
### Community 0 - "Community 0"
Cohesion: 0.06
Nodes (31): _find_or_create(), WooCustomer, _cron_health_check(), _cron_sync_customers(), _cron_sync_inventory(), _cron_sync_orders(), _cron_sync_products(), Sync product prices between Odoo and WooCommerce. (+23 more)
### Community 1 - "Community 1"
Cohesion: 0.05
Nodes (1): ProductMapping
### Community 2 - "Community 2"
Cohesion: 0.05
Nodes (25): Receive order.created / order.updated from WooCommerce., Receive product.updated from WooCommerce., Receive customer.created / customer.updated from WooCommerce., Return True if the IP is within rate limits, False if exceeded., _CircuitBreaker, Per-host circuit breaker: CLOSED → OPEN after N failures, auto-resets after, Create multiple variations at once using WC batch endpoint., Simple token-bucket rate limiter. Tokens refill at *rate* per second up to (+17 more)
### Community 3 - "Community 3"
Cohesion: 0.07
Nodes (25): AIService, Args: provider: 'claude' or 'openai' api_key: API key fo, Generate a single field using the given prompt., Generate SEO metadata for a product image. Returns: dict wi, Generate text using the configured AI provider., Generate all product content at once. Args: product_info: d, AI content generation service supporting Claude and OpenAI., _decimal_to_dms() (+17 more)
### Community 4 - "Community 4"
Cohesion: 0.06
Nodes (18): AccountMove, Override to auto-push invoice PDF to WooCommerce on posting., Override to auto-create shipment and push tracking to WC., StockPicking, _onchange_woo_status(), Push shipping/tracking info to WooCommerce and update status., Mark WC order as completed., Render invoice PDF and push to WC via custom plugin endpoint. (+10 more)
### Community 5 - "Community 5"
Cohesion: 0.08
Nodes (14): Push all Odoo prices to WooCommerce for mapped products., Pull all WC prices to Odoo for mapped products., Push all Odoo SKUs to WooCommerce., Pull all WC SKUs to Odoo., Set the WC standard (regular) price directly., Set the WC sale price directly., Create an Odoo product from WC mapping data, link the mapping, and retur, Copy WC SKU to Odoo internal reference. (+6 more)
### Community 6 - "Community 6"
Cohesion: 0.11
Nodes (3): Fusion_WooDoo_API_Client, Fusion_WooDoo_Returns, WooSetupWizard
### Community 7 - "Community 7"
Cohesion: 0.16
Nodes (12): _check_rate_limit(), _normalize_url(), Strip trailing slashes and lowercase for comparison., Receive inbound WooCommerce webhook deliveries., Find a woo.instance matching the webhook source URL., Return 200 for WooCommerce webhook test deliveries., Common handler for all webhook endpoints. - Rate limits by IP -, webhook_customer() (+4 more)
### Community 8 - "Community 8"
Cohesion: 0.15
Nodes (1): WooDashboard
### Community 9 - "Community 9"
Cohesion: 0.14
Nodes (2): Fusion_WooDoo_Admin_Settings, Fusion_WooDoo_Webhooks
### Community 10 - "Community 10"
Cohesion: 0.29
Nodes (8): order_documents(), order_messages(), order_status(), REST endpoints consumed by the WooCommerce WordPress plugin., Validate Bearer token from Authorization header against woo.instance.odoo_api_ke, Look up a woo.order by WC order ID for a given instance., return_create(), WooApiController
### Community 11 - "Community 11"
Cohesion: 0.24
Nodes (5): Resolve conflict by pushing Odoo value to WooCommerce., Resolve conflict by pulling WooCommerce value into Odoo., Server action: resolve all selected conflicts with Odoo values., Server action: resolve all selected conflicts with WC values., WooConflict
### Community 12 - "Community 12"
Cohesion: 0.24
Nodes (1): Fusion_WooDoo_REST_Endpoints
### Community 13 - "Community 13"
Cohesion: 0.28
Nodes (1): Fusion_WooDoo
### Community 14 - "Community 14"
Cohesion: 0.29
Nodes (4): default_get(), Save the hidden categories to the instance., Remove all hidden categories., WooCategoryFilter
### Community 15 - "Community 15"
Cohesion: 0.29
Nodes (2): AJAX search endpoints used by the product mapping UI., WooProductSearchController
### Community 16 - "Community 16"
Cohesion: 0.29
Nodes (1): Fusion_WooDoo_My_Account
### Community 17 - "Community 17"
Cohesion: 0.33
Nodes (2): Manual purge: delete success logs > 7 days, error logs > 30 days., WooSyncLog
### Community 18 - "Community 18"
Cohesion: 0.4
Nodes (1): AjaxSearch
### Community 19 - "Community 19"
Cohesion: 0.5
Nodes (1): Fusion_WooDoo_Order_Timeline
### Community 20 - "Community 20"
Cohesion: 0.67
Nodes (1): WooProductFetch
### Community 21 - "Community 21"
Cohesion: 0.67
Nodes (1): WooPricelistMap
### Community 22 - "Community 22"
Cohesion: 0.67
Nodes (1): SaleOrder
### Community 23 - "Community 23"
Cohesion: 0.67
Nodes (1): ResPartner
### Community 24 - "Community 24"
Cohesion: 1.0
Nodes (1): WooShipment
### Community 25 - "Community 25"
Cohesion: 1.0
Nodes (1): WooShippingCarrier
### Community 26 - "Community 26"
Cohesion: 1.0
Nodes (1): WooCategoryMap
### Community 27 - "Community 27"
Cohesion: 1.0
Nodes (0):
### Community 28 - "Community 28"
Cohesion: 1.0
Nodes (0):
### Community 29 - "Community 29"
Cohesion: 1.0
Nodes (0):
### Community 30 - "Community 30"
Cohesion: 1.0
Nodes (0):
### Community 31 - "Community 31"
Cohesion: 1.0
Nodes (0):
### Community 32 - "Community 32"
Cohesion: 1.0
Nodes (1): Return the Odoo product.pricelist mapped to a WC customer role. Args:
### Community 33 - "Community 33"
Cohesion: 1.0
Nodes (1): Return the Odoo account.tax mapped to a WC tax class. Args:
### Community 34 - "Community 34"
Cohesion: 1.0
Nodes (1): Return the WC tax class slug mapped to an Odoo tax. Args: i
### Community 35 - "Community 35"
Cohesion: 1.0
Nodes (1): Find or create a woo.customer + res.partner for the given email. Args:
### Community 36 - "Community 36"
Cohesion: 1.0
Nodes (1): Purge success/conflict logs older than 30 days, errors older than 90.
### Community 37 - "Community 37"
Cohesion: 1.0
Nodes (1): Clear all failed sync log entries. Called from dashboard.
### Community 38 - "Community 38"
Cohesion: 1.0
Nodes (1): Write EXIF metadata with company info and GPS coordinates. Args:
### Community 39 - "Community 39"
Cohesion: 1.0
Nodes (1): Convert decimal degrees to EXIF DMS format (degrees, minutes, seconds as rationa
### Community 40 - "Community 40"
Cohesion: 1.0
Nodes (1): Prepare image data for WooCommerce upload. Returns dict ready for WC pr
### Community 41 - "Community 41"
Cohesion: 1.0
Nodes (1): Verify a WooCommerce webhook HMAC-SHA256 signature.
### Community 42 - "Community 42"
Cohesion: 1.0
Nodes (1): Search Odoo products by name or internal reference (SKU). Params:
### Community 43 - "Community 43"
Cohesion: 1.0
Nodes (1): Search unmapped WooCommerce products from the woo.product.map model. Pa
### Community 44 - "Community 44"
Cohesion: 1.0
Nodes (1): Return all Odoo product categories for filtering.
### Community 45 - "Community 45"
Cohesion: 1.0
Nodes (1): Search mapped WooCommerce ↔ Odoo product pairs. Params: que
### Community 46 - "Community 46"
Cohesion: 1.0
Nodes (1): Serve a variant image from the transient wizard line. Used by WC to down
### Community 47 - "Community 47"
Cohesion: 1.0
Nodes (1): Fetch invoice and delivery PDF URLs for a WooCommerce order. Expected p
### Community 48 - "Community 48"
Cohesion: 1.0
Nodes (1): Fetch order status and timeline data for a WooCommerce order. Expected
### Community 49 - "Community 49"
Cohesion: 1.0
Nodes (1): Fetch customer-visible messages for a WooCommerce order. Expected paylo
### Community 50 - "Community 50"
Cohesion: 1.0
Nodes (1): Submit a return request from the WooCommerce plugin. Expected payload:
### Community 51 - "Community 51"
Cohesion: 1.0
Nodes (0):
### Community 52 - "Community 52"
Cohesion: 1.0
Nodes (0):
### Community 53 - "Community 53"
Cohesion: 1.0
Nodes (0):
### Community 54 - "Community 54"
Cohesion: 1.0
Nodes (0):
### Community 55 - "Community 55"
Cohesion: 1.0
Nodes (0):
### Community 56 - "Community 56"
Cohesion: 1.0
Nodes (0):
### Community 57 - "Community 57"
Cohesion: 1.0
Nodes (0):
### Community 58 - "Community 58"
Cohesion: 1.0
Nodes (0):
### Community 59 - "Community 59"
Cohesion: 1.0
Nodes (0):
### Community 60 - "Community 60"
Cohesion: 1.0
Nodes (0):
## Knowledge Gaps
- **82 isolated node(s):** `Save the hidden categories to the instance.`, `Remove all hidden categories.`, `Set woo_status and auto-map to Odoo state.`, `Push shipping/tracking info to WooCommerce and update status.`, `Mark WC order as completed.` (+77 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **Thin community `Community 24`** (2 nodes): `woo_shipment.py`, `WooShipment`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 25`** (2 nodes): `woo_shipping_carrier.py`, `WooShippingCarrier`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 26`** (2 nodes): `woo_category_map.py`, `WooCategoryMap`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 27`** (1 nodes): `__init__.py`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 28`** (1 nodes): `__init__.py`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 29`** (1 nodes): `__init__.py`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 30`** (1 nodes): `__init__.py`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 31`** (1 nodes): `__manifest__.py`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 32`** (1 nodes): `Return the Odoo product.pricelist mapped to a WC customer role. Args:`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 33`** (1 nodes): `Return the Odoo account.tax mapped to a WC tax class. Args:`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 34`** (1 nodes): `Return the WC tax class slug mapped to an Odoo tax. Args: i`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 35`** (1 nodes): `Find or create a woo.customer + res.partner for the given email. Args:`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 36`** (1 nodes): `Purge success/conflict logs older than 30 days, errors older than 90.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 37`** (1 nodes): `Clear all failed sync log entries. Called from dashboard.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 38`** (1 nodes): `Write EXIF metadata with company info and GPS coordinates. Args:`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 39`** (1 nodes): `Convert decimal degrees to EXIF DMS format (degrees, minutes, seconds as rationa`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 40`** (1 nodes): `Prepare image data for WooCommerce upload. Returns dict ready for WC pr`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 41`** (1 nodes): `Verify a WooCommerce webhook HMAC-SHA256 signature.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 42`** (1 nodes): `Search Odoo products by name or internal reference (SKU). Params:`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 43`** (1 nodes): `Search unmapped WooCommerce products from the woo.product.map model. Pa`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 44`** (1 nodes): `Return all Odoo product categories for filtering.`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 45`** (1 nodes): `Search mapped WooCommerce ↔ Odoo product pairs. Params: que`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 46`** (1 nodes): `Serve a variant image from the transient wizard line. Used by WC to down`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 47`** (1 nodes): `Fetch invoice and delivery PDF URLs for a WooCommerce order. Expected p`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 48`** (1 nodes): `Fetch order status and timeline data for a WooCommerce order. Expected`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 49`** (1 nodes): `Fetch customer-visible messages for a WooCommerce order. Expected paylo`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 50`** (1 nodes): `Submit a return request from the WooCommerce plugin. Expected payload:`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 51`** (1 nodes): `fusion-woodoo.php`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 52`** (1 nodes): `settings.php`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 53`** (1 nodes): `returns.php`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 54`** (1 nodes): `deliveries.php`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 55`** (1 nodes): `order-timeline.php`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 56`** (1 nodes): `sales-orders.php`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 57`** (1 nodes): `communication.php`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 58`** (1 nodes): `invoices.php`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 59`** (1 nodes): `my-account.js`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 60`** (1 nodes): `admin.js`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `WooApiClient` connect `Community 2` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 7`?**
_High betweenness centrality (0.170) - this node is a cross-community bridge._
- **Why does `AIService` connect `Community 3` to `Community 4`?**
_High betweenness centrality (0.034) - this node is a cross-community bridge._
- **Why does `WooInstance` connect `Community 0` to `Community 2`, `Community 5`?**
_High betweenness centrality (0.033) - this node is a cross-community bridge._
- **Are the 48 inferred relationships involving `WooApiClient` (e.g. with `WooSetupWizard` and `WooInstance`) actually correct?**
_`WooApiClient` has 48 INFERRED edges - model-reasoned connections that need verification._
- **Are the 12 inferred relationships involving `AIService` (e.g. with `WooProductCreateVariantLine` and `WooProductCreateWizard`) actually correct?**
_`AIService` has 12 INFERRED edges - model-reasoned connections that need verification._
- **Are the 2 inferred relationships involving `WooProductCreateWizard` (e.g. with `AIService` and `ImageProcessor`) actually correct?**
_`WooProductCreateWizard` has 2 INFERRED edges - model-reasoned connections that need verification._
- **What connects `Save the hidden categories to the instance.`, `Remove all hidden categories.`, `Set woo_status and auto-map to Odoo state.` to the rest of the system?**
_82 weakly-connected nodes found - possible documentation gaps or missing edges._

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_models_res_partner_py", "label": "res_partner.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/res_partner.py", "source_location": "L1"}, {"id": "res_partner_respartner", "label": "ResPartner", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/res_partner.py", "source_location": "L4"}, {"id": "res_partner_compute_is_woo_customer", "label": "_compute_is_woo_customer()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/res_partner.py", "source_location": "L11"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_models_res_partner_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/res_partner.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_models_res_partner_py", "target": "res_partner_respartner", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/res_partner.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_models_res_partner_py", "target": "res_partner_compute_is_woo_customer", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/res_partner.py", "source_location": "L11", "weight": 1.0}], "raw_calls": [{"caller_nid": "res_partner_compute_is_woo_customer", "callee": "bool", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/models/res_partner.py", "source_location": "L13"}]}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_lib_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/lib/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_lib_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_lib_woo_api_client_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/lib/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_lib_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_lib_ai_service_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/lib/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_lib_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_lib_image_processor_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/lib/__init__.py", "source_location": "L3", "weight": 1.0}], "raw_calls": []}

View File

@@ -1 +0,0 @@
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_woo_odoo_fusion_woocommerce_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion-woo-odoo/fusion_woocommerce/__init__.py", "source_location": "L4", "weight": 1.0}], "raw_calls": []}

Some files were not shown because too many files have changed in this diff Show More