diff --git a/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py b/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py index f09f2997..9df081c7 100644 --- a/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py +++ b/fusion-woo-odoo/fusion_woocommerce/controllers/product_search.py @@ -21,7 +21,7 @@ class WooProductSearchController(http.Controller): '/woo/search/odoo_products', type='jsonrpc', auth='user', methods=['POST'], ) - def search_odoo_products(self, query='', instance_id=None, limit=20, **kw): + def search_odoo_products(self, query='', instance_id=None, limit=20, offset=0, **kw): """ Search Odoo products by name or internal reference (SKU). @@ -29,11 +29,13 @@ class WooProductSearchController(http.Controller): 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). Returns: - list of {id, name, default_code, list_price, qty_available} + dict with 'results' list and 'total' count """ limit = min(int(limit or 20), 100) + offset = int(offset or 0) domain = [] if query: @@ -43,24 +45,28 @@ class WooProductSearchController(http.Controller): ('default_code', 'ilike', query), ] - products = request.env['product.product'].search(domain, limit=limit) + total = request.env['product.product'].search_count(domain) + products = request.env['product.product'].search(domain, limit=limit, offset=offset) - return [ - { - 'id': p.id, - 'name': p.name, - 'default_code': p.default_code or '', - 'list_price': p.list_price, - 'qty_available': p.qty_available, - } - for p in products - ] + return { + 'results': [ + { + 'id': p.id, + 'name': p.name, + 'default_code': p.default_code or '', + 'list_price': p.list_price, + 'qty_available': p.qty_available, + } + for p in products + ], + '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, **kw): + def search_woo_products(self, query='', instance_id=None, limit=20, offset=0, **kw): """ Search unmapped WooCommerce products from the woo.product.map model. @@ -68,11 +74,13 @@ class WooProductSearchController(http.Controller): 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: - list of {id, woo_product_id, woo_product_name, woo_sku, woo_product_type} + 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: @@ -85,24 +93,28 @@ class WooProductSearchController(http.Controller): ('woo_sku', 'ilike', query), ] - maps = request.env['woo.product.map'].search(domain, limit=limit) + total = request.env['woo.product.map'].search_count(domain) + maps = request.env['woo.product.map'].search(domain, limit=limit, offset=offset) - return [ - { - 'id': m.id, - 'woo_product_id': m.woo_product_id, - 'woo_product_name': m.woo_product_name or '', - 'woo_sku': m.woo_sku or '', - 'woo_product_type': m.woo_product_type or '', - } - for m in maps - ] + return { + 'results': [ + { + 'id': m.id, + 'woo_product_id': m.woo_product_id, + 'woo_product_name': m.woo_product_name or '', + 'woo_sku': m.woo_sku or '', + 'woo_product_type': m.woo_product_type or '', + } + for m in maps + ], + 'total': total, + } @http.route( '/woo/search/mapped', type='jsonrpc', auth='user', methods=['POST'], ) - def search_mapped(self, query='', instance_id=None, limit=20, **kw): + def search_mapped(self, query='', instance_id=None, limit=20, offset=0, **kw): """ Search mapped WooCommerce ↔ Odoo product pairs. @@ -110,11 +122,13 @@ class WooProductSearchController(http.Controller): 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: - list of mapped product data dicts + 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: @@ -128,24 +142,28 @@ class WooProductSearchController(http.Controller): ('product_id.name', 'ilike', query), ] - maps = request.env['woo.product.map'].search(domain, limit=limit) + total = request.env['woo.product.map'].search_count(domain) + maps = request.env['woo.product.map'].search(domain, limit=limit, offset=offset) - return [ - { - '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 '', - '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, - 'woo_price': m.woo_price or 0.0, - 'sync_price': m.sync_price, - 'sync_inventory': m.sync_inventory, - 'instance_id': m.instance_id.id if m.instance_id else False, - 'instance_name': m.instance_id.name if m.instance_id else '', - } - for m in maps - ] + 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 '', + '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, + 'woo_price': m.woo_price or 0.0, + 'sync_price': m.sync_price, + 'sync_inventory': m.sync_inventory, + 'instance_id': m.instance_id.id if m.instance_id else False, + 'instance_name': m.instance_id.name if m.instance_id else '', + } + for m in maps + ], + 'total': total, + } diff --git a/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py b/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py index daeccd90..7cef40d5 100644 --- a/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py +++ b/fusion-woo-odoo/fusion_woocommerce/models/woo_instance.py @@ -727,6 +727,30 @@ class WooInstance(models.Model): 'price_unit': total, } + # ------------------------------------------------------------------ + # Bulk Price Sync (UI actions) + # ------------------------------------------------------------------ + + def action_bulk_price_odoo_to_wc(self): + """Push all Odoo prices to WooCommerce for mapped products.""" + self.ensure_one() + maps = self.env['woo.product.map'].search([ + ('instance_id', '=', self.id), + ('state', '=', 'mapped'), + ('product_id', '!=', False), + ]) + maps.action_push_price_to_wc() + + def action_bulk_price_wc_to_odoo(self): + """Pull all WC prices to Odoo for mapped products.""" + self.ensure_one() + maps = self.env['woo.product.map'].search([ + ('instance_id', '=', self.id), + ('state', '=', 'mapped'), + ('product_id', '!=', False), + ]) + maps.action_push_price_to_odoo() + # ------------------------------------------------------------------ # Product / Price Sync (Task 22) # ------------------------------------------------------------------ diff --git a/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py b/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py index 928881f1..54b45711 100644 --- a/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py +++ b/fusion-woo-odoo/fusion_woocommerce/models/woo_product_map.py @@ -43,6 +43,33 @@ class WooProductMap(models.Model): ('error', 'Error'), ], default='unmapped') + # ------------------------------------------------------------------ + # Individual Price Sync + # ------------------------------------------------------------------ + + def action_push_price_to_odoo(self): + """Update Odoo product price from WC price.""" + for rec in self: + if rec.product_id and rec.woo_price: + rec.product_id.list_price = rec.woo_price + rec.instance_id._log_sync( + 'product', 'woo_to_odoo', rec.product_id.name, 'success', + 'Price updated from WC: $%.2f' % rec.woo_price, + ) + + def action_push_price_to_wc(self): + """Update WC product price from Odoo price.""" + for rec in self: + if rec.product_id and rec.instance_id: + client = rec.instance_id._get_client() + new_price = str(rec.product_id.list_price) + client.update_product(rec.woo_product_id, {'regular_price': new_price}) + rec.woo_price = rec.product_id.list_price + rec.instance_id._log_sync( + 'product', 'odoo_to_woo', rec.product_id.name, 'success', + 'Price pushed to WC: $%.2f' % rec.product_id.list_price, + ) + # ------------------------------------------------------------------ # Image Sync (Task 22) # ------------------------------------------------------------------ diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css b/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css index 19bbcb93..aa27094b 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/css/woo_styles.css @@ -554,3 +554,40 @@ html[style*="color-scheme: dark"] { .woo-card strong { color: var(--woo-text-primary); } + +/* ---------------------------------------------------------- + Pagination + ---------------------------------------------------------- */ +.woo-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 12px 0; +} +.woo-pagination-info { + font-size: 0.85rem; + color: var(--woo-text-muted); +} + +/* ---------------------------------------------------------- + Icon buttons (price sync arrows) + ---------------------------------------------------------- */ +.woo-btn-icon { + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + color: var(--woo-text-muted); + font-size: 0.8rem; + border-radius: 4px; + transition: color 0.15s, background 0.15s; +} +.woo-btn-icon:hover { + color: var(--woo-accent); + background: var(--woo-bg-hover); +} +.woo-price-sync-col { + width: 60px; + white-space: nowrap; +} diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js b/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js index 951a932f..1f247630 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/js/product_mapping.js @@ -40,12 +40,21 @@ export class ProductMapping extends Component { // Mapped tab mappedProducts: [], selectedMapped: [], + mappedPage: 1, + mappedTotal: 0, // Unmatched tab odooProducts: [], wooProducts: [], selectedOdooId: false, selectedWooId: false, + unmatchedOdooPage: 1, + unmatchedOdooTotal: 0, + unmatchedWooPage: 1, + unmatchedWooTotal: 0, + + // Pagination + pageSize: 50, // Conflicts tab conflicts: [], @@ -95,12 +104,17 @@ export class ProductMapping extends Component { async _loadMapped(query = "") { try { - const params = { query, limit: 50 }; + const params = { + query, + limit: this.state.pageSize, + offset: (this.state.mappedPage - 1) * this.state.pageSize, + }; if (this.state.instanceId) { params.instance_id = this.state.instanceId; } const result = await rpc("/woo/search/mapped", params); - this.state.mappedProducts = result || []; + this.state.mappedProducts = (result && result.results) || []; + this.state.mappedTotal = (result && result.total) || 0; this.state.selectedMapped = []; } catch (err) { console.error("[ProductMapping] _loadMapped error:", err); @@ -109,12 +123,17 @@ export class ProductMapping extends Component { async _loadOdooProducts(query = "") { try { - const params = { query, limit: 50 }; + const params = { + query, + limit: this.state.pageSize, + offset: (this.state.unmatchedOdooPage - 1) * this.state.pageSize, + }; if (this.state.instanceId) { params.instance_id = this.state.instanceId; } const result = await rpc("/woo/search/odoo_products", params); - this.state.odooProducts = result || []; + this.state.odooProducts = (result && result.results) || []; + this.state.unmatchedOdooTotal = (result && result.total) || 0; } catch (err) { console.error("[ProductMapping] _loadOdooProducts error:", err); } @@ -122,12 +141,17 @@ export class ProductMapping extends Component { async _loadWooProducts(query = "") { try { - const params = { query, limit: 50 }; + const params = { + query, + limit: this.state.pageSize, + offset: (this.state.unmatchedWooPage - 1) * this.state.pageSize, + }; if (this.state.instanceId) { params.instance_id = this.state.instanceId; } const result = await rpc("/woo/search/woo_products", params); - this.state.wooProducts = result || []; + this.state.wooProducts = (result && result.results) || []; + this.state.unmatchedWooTotal = (result && result.total) || 0; } catch (err) { console.error("[ProductMapping] _loadWooProducts error:", err); } @@ -213,6 +237,9 @@ export class ProductMapping extends Component { async onInstanceChange(ev) { const val = ev.target.value; this.state.instanceId = val ? parseInt(val, 10) : false; + this.state.mappedPage = 1; + this.state.unmatchedOdooPage = 1; + this.state.unmatchedWooPage = 1; await this._refreshAll(); } @@ -221,7 +248,12 @@ export class ProductMapping extends Component { // ------------------------------------------------------------------------- onMappedResults(results) { - this.state.mappedProducts = results; + if (results && results.results) { + this.state.mappedProducts = results.results; + this.state.mappedTotal = results.total || 0; + } else { + this.state.mappedProducts = results || []; + } } toggleSelectMapped(id) { @@ -300,15 +332,25 @@ export class ProductMapping extends Component { // ------------------------------------------------------------------------- onOdooResults(results) { - this.state.odooProducts = results; - if (!results.find((r) => r.id === this.state.selectedOdooId)) { + let items = results; + if (results && results.results) { + items = results.results; + this.state.unmatchedOdooTotal = results.total || 0; + } + this.state.odooProducts = items || []; + if (!this.state.odooProducts.find((r) => r.id === this.state.selectedOdooId)) { this.state.selectedOdooId = false; } } onWooResults(results) { - this.state.wooProducts = results; - if (!results.find((r) => r.id === this.state.selectedWooId)) { + let items = results; + if (results && results.results) { + items = results.results; + this.state.unmatchedWooTotal = results.total || 0; + } + this.state.wooProducts = items || []; + if (!this.state.wooProducts.find((r) => r.id === this.state.selectedWooId)) { this.state.selectedWooId = false; } } @@ -410,6 +452,162 @@ export class ProductMapping extends Component { } } + // ------------------------------------------------------------------------- + // Pagination — Mapped + // ------------------------------------------------------------------------- + + _calcTotalPages(total) { + return Math.max(1, Math.ceil(total / this.state.pageSize)); + } + + get mappedTotalPages() { + return this._calcTotalPages(this.state.mappedTotal); + } + + async mappedNextPage() { + if (this.state.mappedPage < this.mappedTotalPages) { + this.state.mappedPage++; + await this._loadMapped(); + } + } + + async mappedPrevPage() { + if (this.state.mappedPage > 1) { + this.state.mappedPage--; + await this._loadMapped(); + } + } + + // ------------------------------------------------------------------------- + // Pagination — Unmatched Odoo + // ------------------------------------------------------------------------- + + get unmatchedOdooTotalPages() { + return this._calcTotalPages(this.state.unmatchedOdooTotal); + } + + async unmatchedOdooNextPage() { + if (this.state.unmatchedOdooPage < this.unmatchedOdooTotalPages) { + this.state.unmatchedOdooPage++; + await this._loadOdooProducts(""); + } + } + + async unmatchedOdooPrevPage() { + if (this.state.unmatchedOdooPage > 1) { + this.state.unmatchedOdooPage--; + await this._loadOdooProducts(""); + } + } + + // ------------------------------------------------------------------------- + // Pagination — Unmatched WC + // ------------------------------------------------------------------------- + + get unmatchedWooTotalPages() { + return this._calcTotalPages(this.state.unmatchedWooTotal); + } + + async unmatchedWooNextPage() { + if (this.state.unmatchedWooPage < this.unmatchedWooTotalPages) { + this.state.unmatchedWooPage++; + await this._loadWooProducts(""); + } + } + + async unmatchedWooPrevPage() { + if (this.state.unmatchedWooPage > 1) { + this.state.unmatchedWooPage--; + await this._loadWooProducts(""); + } + } + + // ------------------------------------------------------------------------- + // Individual price sync + // ------------------------------------------------------------------------- + + async pushPriceToOdoo(mapId) { + try { + await rpc("/web/dataset/call_kw", { + model: "woo.product.map", + method: "action_push_price_to_odoo", + args: [[mapId]], + kwargs: {}, + }); + this.notification.add("WC price pushed to Odoo.", { type: "success" }); + await this._loadMapped(); + } catch (err) { + console.error("[ProductMapping] pushPriceToOdoo error:", err); + this.notification.add("Failed to push price to Odoo.", { type: "danger" }); + } + } + + async pushPriceToWC(mapId) { + try { + await rpc("/web/dataset/call_kw", { + model: "woo.product.map", + method: "action_push_price_to_wc", + args: [[mapId]], + kwargs: {}, + }); + this.notification.add("Odoo price pushed to WC.", { type: "success" }); + await this._loadMapped(); + } catch (err) { + console.error("[ProductMapping] pushPriceToWC error:", err); + this.notification.add("Failed to push price to WC.", { type: "danger" }); + } + } + + // ------------------------------------------------------------------------- + // Bulk price sync + // ------------------------------------------------------------------------- + + async bulkPriceOdooToWC() { + if (!this.state.instanceId) { + this.notification.add("Select an instance.", { type: "warning" }); + return; + } + this.state.loading = true; + try { + await rpc("/web/dataset/call_kw", { + model: "woo.instance", + method: "action_bulk_price_odoo_to_wc", + args: [[this.state.instanceId]], + kwargs: {}, + }); + this.notification.add("All Odoo prices pushed to WooCommerce.", { type: "success" }); + await this._refreshAll(); + } catch (err) { + console.error("[ProductMapping] bulkPriceOdooToWC error:", err); + this.notification.add("Bulk price sync failed.", { type: "danger" }); + } finally { + this.state.loading = false; + } + } + + async bulkPriceWCToOdoo() { + if (!this.state.instanceId) { + this.notification.add("Select an instance.", { type: "warning" }); + return; + } + this.state.loading = true; + try { + await rpc("/web/dataset/call_kw", { + model: "woo.instance", + method: "action_bulk_price_wc_to_odoo", + args: [[this.state.instanceId]], + kwargs: {}, + }); + this.notification.add("All WC prices pulled to Odoo.", { type: "success" }); + await this._refreshAll(); + } catch (err) { + console.error("[ProductMapping] bulkPriceWCToOdoo error:", err); + this.notification.add("Bulk price sync failed.", { type: "danger" }); + } finally { + this.state.loading = false; + } + } + // ------------------------------------------------------------------------- // Top bar actions // ------------------------------------------------------------------------- diff --git a/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml b/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml index c90edf99..f8bec4a3 100644 --- a/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml +++ b/fusion-woo-odoo/fusion_woocommerce/static/src/xml/product_mapping.xml @@ -82,12 +82,12 @@ + + @@ -135,6 +141,7 @@ SKU Odoo Product WC Price + Odoo Price Instance Price Sync @@ -153,6 +160,16 @@ + + + + @@ -170,6 +187,20 @@ +
+ + + Page of + ( total) + + +
@@ -219,6 +250,20 @@
+
+ + + Page of + ( total) + + +
@@ -264,6 +309,20 @@ +
+ + + Page of + ( total) + + +