feat: implement order sync lifecycle (WC→Odoo SO/invoice, shipping push, completion push)
- Replace _sync_orders placeholder with full WC order import pipeline - Add _sync_order_from_wc, _find_or_create_customer, _prepare_sale_order_vals, _prepare_order_line_vals, _prepare_shipping_line_vals helpers - Add push methods on woo.order: action_push_shipping, action_push_completed, action_push_invoice_pdf, action_push_delivery_pdf, _push_messages_to_wc - Override stock.picking button_validate to auto-create shipment records and push tracking/delivery PDFs to WooCommerce Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_inherit = 'stock.picking'
|
||||
@@ -14,3 +18,40 @@ class StockPicking(models.Model):
|
||||
picking.is_woo_delivery = bool(picking.woo_shipment_ids) or bool(
|
||||
picking.sale_id and picking.sale_id.woo_bind_ids
|
||||
)
|
||||
|
||||
def button_validate(self):
|
||||
"""Override to auto-create shipment and push tracking to WC."""
|
||||
res = super().button_validate()
|
||||
for picking in self:
|
||||
if not picking.sale_id or not picking.sale_id.woo_bind_ids:
|
||||
continue
|
||||
woo_order = picking.sale_id.woo_bind_ids[0]
|
||||
# Create shipment record
|
||||
shipment_vals = {
|
||||
'order_id': woo_order.id,
|
||||
'picking_id': picking.id,
|
||||
'carrier_id': picking.woo_carrier_id.id if picking.woo_carrier_id else False,
|
||||
'tracking_number': picking.woo_tracking_number or '',
|
||||
'shipped_date': fields.Datetime.now(),
|
||||
'is_backorder': bool(picking.backorder_ids),
|
||||
'company_id': picking.company_id.id,
|
||||
}
|
||||
shipment = self.env['woo.shipment'].create(shipment_vals)
|
||||
|
||||
# Auto-push to WC if tracking number is set
|
||||
if picking.woo_tracking_number:
|
||||
try:
|
||||
woo_order.action_push_shipping(
|
||||
picking.woo_tracking_number,
|
||||
picking.woo_carrier_id.id if picking.woo_carrier_id else False,
|
||||
)
|
||||
shipment.synced_to_woo = True
|
||||
except Exception as e:
|
||||
_logger.error("Failed to push shipping to WC: %s", e)
|
||||
|
||||
# Push delivery PDF
|
||||
try:
|
||||
woo_order.action_push_delivery_pdf(picking)
|
||||
except Exception as e:
|
||||
_logger.warning("Failed to push delivery PDF to WC: %s", e)
|
||||
return res
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
@@ -168,29 +171,594 @@ class WooInstance(models.Model):
|
||||
instance._log_sync('customer', 'odoo_to_woo', instance.name, 'failed', str(e))
|
||||
instance._notify_failure('customer', str(e))
|
||||
|
||||
@api.model
|
||||
def _cron_health_check(self):
|
||||
"""Ping all connected instances and flag unreachable ones."""
|
||||
instances = self.search([('state', '!=', 'draft')])
|
||||
for instance in instances:
|
||||
try:
|
||||
client = instance._get_client()
|
||||
success, _info = client.test_connection()
|
||||
if success:
|
||||
if instance.state == 'error':
|
||||
instance.state = 'connected'
|
||||
instance.message_post(body="Health check passed — connection restored.")
|
||||
else:
|
||||
instance.state = 'error'
|
||||
instance.message_post(body="Health check failed — store unreachable.")
|
||||
instance._notify_failure('health', 'Store unreachable during health check.')
|
||||
except Exception as e:
|
||||
instance.state = 'error'
|
||||
instance.message_post(body=f"Health check error: {e}")
|
||||
instance._notify_failure('health', str(e))
|
||||
_logger.error("Health check failed for %s: %s", instance.name, e)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sync method placeholders (filled in during later tasks)
|
||||
# Order Sync (Task 20)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _sync_orders(self):
|
||||
"""Fetch new WC orders and create Odoo SOs."""
|
||||
self.ensure_one()
|
||||
client = self._get_client()
|
||||
page = 1
|
||||
while True:
|
||||
orders = client.get_orders(page=page, status='processing,on-hold,pending')
|
||||
if not orders:
|
||||
break
|
||||
for wc_order in orders:
|
||||
try:
|
||||
self._sync_order_from_wc(wc_order)
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Failed to sync WC order %s for %s: %s",
|
||||
wc_order.get('id'), self.name, e,
|
||||
)
|
||||
self._log_sync(
|
||||
'order', 'woo_to_odoo',
|
||||
f"WC#{wc_order.get('id')}", 'failed', str(e),
|
||||
)
|
||||
page += 1
|
||||
if len(orders) < 100:
|
||||
break
|
||||
self.last_sync = fields.Datetime.now()
|
||||
|
||||
def _sync_order_from_wc(self, wc_order):
|
||||
"""Process a single WC order into an Odoo sale order."""
|
||||
self.ensure_one()
|
||||
woo_order_id = wc_order['id']
|
||||
|
||||
# Dedup check
|
||||
existing = self.env['woo.order'].search([
|
||||
('instance_id', '=', self.id),
|
||||
('woo_order_id', '=', woo_order_id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Find/create customer
|
||||
partner = self._find_or_create_customer(wc_order)
|
||||
|
||||
# Create sale order
|
||||
so_vals = self._prepare_sale_order_vals(wc_order, partner)
|
||||
sale_order = self.env['sale.order'].create(so_vals)
|
||||
|
||||
# Add order lines
|
||||
for line_item in wc_order.get('line_items', []):
|
||||
line_vals = self._prepare_order_line_vals(sale_order, line_item)
|
||||
if line_vals:
|
||||
self.env['sale.order.line'].create(line_vals)
|
||||
|
||||
# Add shipping lines
|
||||
for shipping in wc_order.get('shipping_lines', []):
|
||||
shipping_vals = self._prepare_shipping_line_vals(sale_order, shipping)
|
||||
if shipping_vals:
|
||||
self.env['sale.order.line'].create(shipping_vals)
|
||||
|
||||
# Add fee lines
|
||||
for fee in wc_order.get('fee_lines', []):
|
||||
fee_vals = {
|
||||
'order_id': sale_order.id,
|
||||
'name': fee.get('name', 'Fee'),
|
||||
'product_uom_qty': 1,
|
||||
'price_unit': float(fee.get('total', 0)),
|
||||
}
|
||||
self.env['sale.order.line'].create(fee_vals)
|
||||
|
||||
# Confirm SO
|
||||
sale_order.action_confirm()
|
||||
|
||||
# Create draft invoice
|
||||
invoice = False
|
||||
if self.sync_invoices:
|
||||
try:
|
||||
invoice = sale_order._create_invoices()
|
||||
except Exception as e:
|
||||
_logger.warning("Could not auto-create invoice for %s: %s", sale_order.name, e)
|
||||
|
||||
# Create woo.order tracking record
|
||||
woo_order = self.env['woo.order'].create({
|
||||
'instance_id': self.id,
|
||||
'sale_order_id': sale_order.id,
|
||||
'woo_order_id': woo_order_id,
|
||||
'woo_order_number': wc_order.get('number', str(woo_order_id)),
|
||||
'woo_status': wc_order.get('status', ''),
|
||||
'invoice_id': invoice.id if invoice else False,
|
||||
'state': 'confirmed',
|
||||
'company_id': self.company_id.id,
|
||||
})
|
||||
|
||||
# Link invoice to woo.order
|
||||
if invoice:
|
||||
invoice.woo_order_id = woo_order.id
|
||||
|
||||
# Push SO ref back to WC
|
||||
try:
|
||||
client = self._get_client()
|
||||
client.update_order(woo_order_id, {
|
||||
'meta_data': [
|
||||
{'key': '_odoo_order_id', 'value': str(sale_order.id)},
|
||||
{'key': '_odoo_order_ref', 'value': sale_order.name},
|
||||
]
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning("Failed to push SO ref to WC: %s", e)
|
||||
|
||||
self._log_sync(
|
||||
'order', 'woo_to_odoo', sale_order.name, 'success',
|
||||
f'Created from WC order #{woo_order.woo_order_number}',
|
||||
)
|
||||
return woo_order
|
||||
|
||||
def _find_or_create_customer(self, wc_order):
|
||||
"""Find or create an Odoo partner from a WC order's billing data."""
|
||||
self.ensure_one()
|
||||
billing = wc_order.get('billing', {})
|
||||
email = billing.get('email', '').strip().lower()
|
||||
wc_customer_id = wc_order.get('customer_id', 0)
|
||||
|
||||
if not email:
|
||||
# Guest checkout — create a one-off partner
|
||||
name = f"{billing.get('first_name', '')} {billing.get('last_name', '')}".strip()
|
||||
return self.env['res.partner'].create({
|
||||
'name': name or 'WooCommerce Guest',
|
||||
'email': '',
|
||||
'company_id': self.company_id.id,
|
||||
})
|
||||
|
||||
# Check existing woo.customer link first
|
||||
if wc_customer_id:
|
||||
woo_cust = self.env['woo.customer'].search([
|
||||
('instance_id', '=', self.id),
|
||||
('woo_customer_id', '=', wc_customer_id),
|
||||
], limit=1)
|
||||
if woo_cust:
|
||||
return woo_cust.partner_id
|
||||
|
||||
# Search by email
|
||||
partner = self.env['res.partner'].search([
|
||||
('email', '=ilike', email),
|
||||
'|', ('company_id', '=', self.company_id.id), ('company_id', '=', False),
|
||||
], limit=1)
|
||||
|
||||
if not partner:
|
||||
partner = self.env['res.partner'].create(
|
||||
self._prepare_partner_vals(billing)
|
||||
)
|
||||
|
||||
# Ensure woo.customer link exists
|
||||
if wc_customer_id and not self.env['woo.customer'].search([
|
||||
('instance_id', '=', self.id),
|
||||
('woo_customer_id', '=', wc_customer_id),
|
||||
], limit=1):
|
||||
self.env['woo.customer'].create({
|
||||
'instance_id': self.id,
|
||||
'partner_id': partner.id,
|
||||
'woo_customer_id': wc_customer_id,
|
||||
'woo_email': email,
|
||||
'last_synced': fields.Datetime.now(),
|
||||
'company_id': self.company_id.id,
|
||||
})
|
||||
|
||||
return partner
|
||||
|
||||
def _prepare_partner_vals(self, billing):
|
||||
"""Build res.partner vals from WC billing data."""
|
||||
self.ensure_one()
|
||||
name = f"{billing.get('first_name', '')} {billing.get('last_name', '')}".strip()
|
||||
country = self.env['res.country'].search([
|
||||
('code', '=', billing.get('country', '')),
|
||||
], limit=1)
|
||||
state = False
|
||||
if billing.get('state') and country:
|
||||
state = self.env['res.country.state'].search([
|
||||
('country_id', '=', country.id),
|
||||
('code', '=', billing.get('state')),
|
||||
], limit=1)
|
||||
return {
|
||||
'name': name or billing.get('email', 'WooCommerce Customer'),
|
||||
'email': billing.get('email', ''),
|
||||
'phone': billing.get('phone', ''),
|
||||
'street': billing.get('address_1', ''),
|
||||
'street2': billing.get('address_2', ''),
|
||||
'city': billing.get('city', ''),
|
||||
'zip': billing.get('postcode', ''),
|
||||
'country_id': country.id if country else False,
|
||||
'state_id': state.id if state else False,
|
||||
'company_id': self.company_id.id,
|
||||
}
|
||||
|
||||
def _prepare_sale_order_vals(self, wc_order, partner):
|
||||
"""Build sale.order vals dict from a WC order."""
|
||||
self.ensure_one()
|
||||
currency = self.env['res.currency'].search([
|
||||
('name', '=', wc_order.get('currency', 'CAD')),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'partner_id': partner.id,
|
||||
'company_id': self.company_id.id,
|
||||
'client_order_ref': f"WC#{wc_order.get('number', wc_order['id'])}",
|
||||
}
|
||||
if currency:
|
||||
# Set pricelist with matching currency if available
|
||||
pricelist = self.env['product.pricelist'].search([
|
||||
('currency_id', '=', currency.id),
|
||||
'|', ('company_id', '=', self.company_id.id), ('company_id', '=', False),
|
||||
], limit=1)
|
||||
if pricelist:
|
||||
vals['pricelist_id'] = pricelist.id
|
||||
|
||||
if self.default_warehouse_id:
|
||||
vals['warehouse_id'] = self.default_warehouse_id.id
|
||||
|
||||
# Shipping address
|
||||
shipping = wc_order.get('shipping', {})
|
||||
if shipping.get('first_name') or shipping.get('last_name'):
|
||||
ship_name = f"{shipping.get('first_name', '')} {shipping.get('last_name', '')}".strip()
|
||||
ship_country = self.env['res.country'].search([
|
||||
('code', '=', shipping.get('country', '')),
|
||||
], limit=1)
|
||||
ship_state = False
|
||||
if shipping.get('state') and ship_country:
|
||||
ship_state = self.env['res.country.state'].search([
|
||||
('country_id', '=', ship_country.id),
|
||||
('code', '=', shipping.get('state')),
|
||||
], limit=1)
|
||||
ship_partner = self.env['res.partner'].search([
|
||||
('parent_id', '=', partner.id),
|
||||
('type', '=', 'delivery'),
|
||||
('street', '=', shipping.get('address_1', '')),
|
||||
('city', '=', shipping.get('city', '')),
|
||||
], limit=1)
|
||||
if not ship_partner:
|
||||
ship_partner = self.env['res.partner'].create({
|
||||
'parent_id': partner.id,
|
||||
'type': 'delivery',
|
||||
'name': ship_name,
|
||||
'street': shipping.get('address_1', ''),
|
||||
'street2': shipping.get('address_2', ''),
|
||||
'city': shipping.get('city', ''),
|
||||
'zip': shipping.get('postcode', ''),
|
||||
'country_id': ship_country.id if ship_country else False,
|
||||
'state_id': ship_state.id if ship_state else False,
|
||||
'company_id': self.company_id.id,
|
||||
})
|
||||
vals['partner_shipping_id'] = ship_partner.id
|
||||
|
||||
return vals
|
||||
|
||||
def _prepare_order_line_vals(self, sale_order, line_item):
|
||||
"""Build sale.order.line vals from a WC line_item dict."""
|
||||
self.ensure_one()
|
||||
wc_product_id = line_item.get('product_id', 0)
|
||||
wc_variation_id = line_item.get('variation_id', 0)
|
||||
|
||||
# Look up mapped product
|
||||
lookup_id = wc_variation_id or wc_product_id
|
||||
product_map = self.env['woo.product.map'].search([
|
||||
('instance_id', '=', self.id),
|
||||
('woo_product_id', '=', lookup_id),
|
||||
('state', '=', 'mapped'),
|
||||
], limit=1)
|
||||
|
||||
product = product_map.product_id if product_map else False
|
||||
|
||||
# If not mapped, try SKU match
|
||||
if not product:
|
||||
sku = line_item.get('sku', '')
|
||||
if sku:
|
||||
product = self.env['product.product'].search([
|
||||
('default_code', '=', sku),
|
||||
'|', ('company_id', '=', self.company_id.id), ('company_id', '=', False),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'order_id': sale_order.id,
|
||||
'name': line_item.get('name', 'WooCommerce Product'),
|
||||
'product_uom_qty': line_item.get('quantity', 1),
|
||||
'price_unit': float(line_item.get('price', 0)),
|
||||
}
|
||||
|
||||
if product:
|
||||
vals['product_id'] = product.id
|
||||
|
||||
# Tax mapping
|
||||
taxes = []
|
||||
for tax_entry in line_item.get('taxes', []):
|
||||
# WC sends tax class on the line item
|
||||
pass
|
||||
wc_tax_class = line_item.get('tax_class', '')
|
||||
if wc_tax_class:
|
||||
odoo_tax = self.env['woo.tax.map'].get_odoo_tax(self, wc_tax_class)
|
||||
if odoo_tax:
|
||||
taxes.append(odoo_tax.id)
|
||||
elif line_item.get('total_tax') and float(line_item.get('total_tax', 0)) > 0:
|
||||
# Standard tax class
|
||||
odoo_tax = self.env['woo.tax.map'].get_odoo_tax(self, 'standard')
|
||||
if odoo_tax:
|
||||
taxes.append(odoo_tax.id)
|
||||
|
||||
if taxes:
|
||||
vals['tax_id'] = [(6, 0, taxes)]
|
||||
|
||||
return vals
|
||||
|
||||
def _prepare_shipping_line_vals(self, sale_order, shipping_line):
|
||||
"""Build sale.order.line vals from a WC shipping_lines entry."""
|
||||
self.ensure_one()
|
||||
total = float(shipping_line.get('total', 0))
|
||||
if not total:
|
||||
return False
|
||||
return {
|
||||
'order_id': sale_order.id,
|
||||
'name': shipping_line.get('method_title', 'Shipping'),
|
||||
'product_uom_qty': 1,
|
||||
'price_unit': total,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Product / Price Sync (Task 22)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _sync_products(self):
|
||||
"""Sync products — implemented in Task 22."""
|
||||
"""Sync product prices between Odoo and WooCommerce."""
|
||||
self.ensure_one()
|
||||
_logger.info("Product sync for %s — not yet implemented", self.name)
|
||||
client = self._get_client()
|
||||
maps = self.env['woo.product.map'].search([
|
||||
('instance_id', '=', self.id),
|
||||
('state', '=', 'mapped'),
|
||||
('sync_price', '=', True),
|
||||
('product_id', '!=', False),
|
||||
])
|
||||
|
||||
def _sync_orders(self):
|
||||
"""Sync orders — implemented in Task 20."""
|
||||
for pm in maps:
|
||||
try:
|
||||
wc_product = client.get_product(pm.woo_product_id)
|
||||
wc_price = float(wc_product.get('regular_price') or 0)
|
||||
odoo_price = pm.product_id.list_price
|
||||
|
||||
if abs(wc_price - odoo_price) < 0.01:
|
||||
# Prices match — nothing to do
|
||||
pm.last_synced = fields.Datetime.now()
|
||||
continue
|
||||
|
||||
# Check if Odoo price was changed since last sync
|
||||
odoo_changed = True
|
||||
woo_changed = True
|
||||
|
||||
if pm.last_synced:
|
||||
# If product write_date is newer than last sync, Odoo changed
|
||||
odoo_changed = pm.product_id.write_date > pm.last_synced
|
||||
# WC always returns current price, assume changed if different
|
||||
woo_changed = True
|
||||
|
||||
if odoo_changed and woo_changed:
|
||||
# Both changed — conflict
|
||||
self.env['woo.conflict'].create({
|
||||
'instance_id': self.id,
|
||||
'conflict_type': 'product',
|
||||
'map_id': pm.id,
|
||||
'field_name': 'price',
|
||||
'odoo_value': str(odoo_price),
|
||||
'woo_value': str(wc_price),
|
||||
'company_id': self.company_id.id,
|
||||
})
|
||||
pm.state = 'conflict'
|
||||
self._log_sync(
|
||||
'product', 'woo_to_odoo', pm.product_id.display_name,
|
||||
'conflict', f'Price conflict: Odoo=${odoo_price}, WC=${wc_price}',
|
||||
)
|
||||
elif odoo_changed:
|
||||
# Odoo is authoritative — push to WC
|
||||
client.update_product(pm.woo_product_id, {
|
||||
'regular_price': str(odoo_price),
|
||||
})
|
||||
self._log_sync(
|
||||
'product', 'odoo_to_woo', pm.product_id.display_name,
|
||||
'success', f'Price updated to ${odoo_price}',
|
||||
)
|
||||
else:
|
||||
# WC changed — pull into Odoo
|
||||
pm.product_id.list_price = wc_price
|
||||
self._log_sync(
|
||||
'product', 'woo_to_odoo', pm.product_id.display_name,
|
||||
'success', f'Price updated to ${wc_price}',
|
||||
)
|
||||
|
||||
pm.last_synced = fields.Datetime.now()
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Product price sync failed for %s (WC#%s): %s",
|
||||
pm.product_id.display_name, pm.woo_product_id, e,
|
||||
)
|
||||
pm.state = 'error'
|
||||
self._log_sync(
|
||||
'product', 'odoo_to_woo',
|
||||
pm.product_id.display_name, 'failed', str(e),
|
||||
)
|
||||
|
||||
def _sync_product_from_wc(self, wc_data):
|
||||
"""Handle an inbound WC product webhook — update price if mapped."""
|
||||
self.ensure_one()
|
||||
_logger.info("Order sync for %s — not yet implemented", self.name)
|
||||
wc_product_id = wc_data.get('id')
|
||||
if not wc_product_id:
|
||||
return
|
||||
|
||||
pm = self.env['woo.product.map'].search([
|
||||
('instance_id', '=', self.id),
|
||||
('woo_product_id', '=', wc_product_id),
|
||||
('state', '=', 'mapped'),
|
||||
], limit=1)
|
||||
if not pm or not pm.product_id:
|
||||
return
|
||||
|
||||
if pm.sync_price:
|
||||
wc_price = float(wc_data.get('regular_price') or 0)
|
||||
if wc_price and abs(wc_price - pm.product_id.list_price) > 0.01:
|
||||
pm.product_id.list_price = wc_price
|
||||
self._log_sync(
|
||||
'product', 'woo_to_odoo', pm.product_id.display_name,
|
||||
'success', f'Price updated via webhook to ${wc_price}',
|
||||
)
|
||||
|
||||
pm.last_synced = fields.Datetime.now()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inventory Sync (Task 22)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _sync_inventory(self):
|
||||
"""Sync inventory — implemented in Task 22."""
|
||||
"""Push Odoo stock levels to WooCommerce."""
|
||||
self.ensure_one()
|
||||
_logger.info("Inventory sync for %s — not yet implemented", self.name)
|
||||
client = self._get_client()
|
||||
maps = self.env['woo.product.map'].search([
|
||||
('instance_id', '=', self.id),
|
||||
('state', '=', 'mapped'),
|
||||
('sync_inventory', '=', True),
|
||||
('product_id', '!=', False),
|
||||
])
|
||||
|
||||
for pm in maps:
|
||||
try:
|
||||
product = pm.product_id
|
||||
if self.default_warehouse_id:
|
||||
# Get qty for specific warehouse
|
||||
quant = self.env['stock.quant'].search([
|
||||
('product_id', '=', product.id),
|
||||
('location_id', 'child_of',
|
||||
self.default_warehouse_id.lot_stock_id.id),
|
||||
])
|
||||
qty = sum(quant.mapped('quantity')) - sum(quant.mapped('reserved_quantity'))
|
||||
else:
|
||||
qty = product.qty_available
|
||||
|
||||
qty = max(int(qty), 0)
|
||||
|
||||
client.update_product(pm.woo_product_id, {
|
||||
'stock_quantity': qty,
|
||||
'manage_stock': True,
|
||||
})
|
||||
pm.last_synced = fields.Datetime.now()
|
||||
self._log_sync(
|
||||
'inventory', 'odoo_to_woo', product.display_name,
|
||||
'success', f'Stock set to {qty}',
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Inventory sync failed for %s (WC#%s): %s",
|
||||
pm.product_id.display_name, pm.woo_product_id, e,
|
||||
)
|
||||
self._log_sync(
|
||||
'inventory', 'odoo_to_woo',
|
||||
pm.product_id.display_name, 'failed', str(e),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Customer Sync (Task 25)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _sync_customers(self):
|
||||
"""Sync customers — implemented in Task 25."""
|
||||
"""Push updated Odoo partner addresses to WooCommerce."""
|
||||
self.ensure_one()
|
||||
_logger.info("Customer sync for %s — not yet implemented", self.name)
|
||||
client = self._get_client()
|
||||
customers = self.env['woo.customer'].search([
|
||||
('instance_id', '=', self.id),
|
||||
('woo_customer_id', '>', 0),
|
||||
])
|
||||
|
||||
for cust in customers:
|
||||
try:
|
||||
partner = cust.partner_id
|
||||
# Only sync if partner was modified since last sync
|
||||
if cust.last_synced and partner.write_date <= cust.last_synced:
|
||||
continue
|
||||
|
||||
billing_data = {
|
||||
'first_name': (partner.name or '').split(' ', 1)[0],
|
||||
'last_name': (partner.name or '').split(' ', 1)[1] if ' ' in (partner.name or '') else '',
|
||||
'email': partner.email or '',
|
||||
'phone': partner.phone or '',
|
||||
'address_1': partner.street or '',
|
||||
'address_2': partner.street2 or '',
|
||||
'city': partner.city or '',
|
||||
'postcode': partner.zip or '',
|
||||
'country': partner.country_id.code if partner.country_id else '',
|
||||
'state': partner.state_id.code if partner.state_id else '',
|
||||
}
|
||||
|
||||
client.update_customer(cust.woo_customer_id, {
|
||||
'billing': billing_data,
|
||||
'shipping': billing_data,
|
||||
})
|
||||
|
||||
cust.last_synced = fields.Datetime.now()
|
||||
self._log_sync(
|
||||
'customer', 'odoo_to_woo', partner.display_name,
|
||||
'success', 'Address updated in WooCommerce',
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Customer sync failed for %s (WC#%s): %s",
|
||||
cust.partner_id.display_name, cust.woo_customer_id, e,
|
||||
)
|
||||
self._log_sync(
|
||||
'customer', 'odoo_to_woo',
|
||||
cust.partner_id.display_name, 'failed', str(e),
|
||||
)
|
||||
|
||||
def _sync_customer_from_wc(self, wc_data):
|
||||
"""Handle an inbound WC customer webhook — update partner if linked."""
|
||||
self.ensure_one()
|
||||
wc_customer_id = wc_data.get('id')
|
||||
email = wc_data.get('email', '').strip().lower()
|
||||
if not wc_customer_id:
|
||||
return
|
||||
|
||||
cust = self.env['woo.customer'].search([
|
||||
('instance_id', '=', self.id),
|
||||
('woo_customer_id', '=', wc_customer_id),
|
||||
], limit=1)
|
||||
|
||||
billing = wc_data.get('billing', {})
|
||||
if cust:
|
||||
partner = cust.partner_id
|
||||
# Update partner address from WC
|
||||
vals = self._prepare_partner_vals(billing)
|
||||
vals.pop('company_id', None)
|
||||
partner.write(vals)
|
||||
cust.last_synced = fields.Datetime.now()
|
||||
self._log_sync(
|
||||
'customer', 'woo_to_odoo', partner.display_name,
|
||||
'success', 'Updated from WC webhook',
|
||||
)
|
||||
elif email:
|
||||
# New customer from WC
|
||||
self.env['woo.customer']._find_or_create(self, email, wc_data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Notification
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _notify_failure(self, sync_type, error_message):
|
||||
"""Send email notification on sync failure."""
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooOrder(models.Model):
|
||||
@@ -23,3 +29,143 @@ class WooOrder(models.Model):
|
||||
('cancelled', 'Cancelled'),
|
||||
], default='new')
|
||||
shipment_ids = fields.One2many('woo.shipment', 'order_id')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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.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.woo_status = 'completed'
|
||||
self.state = '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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user