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
|
from odoo import models, fields
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class StockPicking(models.Model):
|
class StockPicking(models.Model):
|
||||||
_inherit = 'stock.picking'
|
_inherit = 'stock.picking'
|
||||||
@@ -14,3 +18,40 @@ class StockPicking(models.Model):
|
|||||||
picking.is_woo_delivery = bool(picking.woo_shipment_ids) or bool(
|
picking.is_woo_delivery = bool(picking.woo_shipment_ids) or bool(
|
||||||
picking.sale_id and picking.sale_id.woo_bind_ids
|
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 logging
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
@@ -168,29 +171,594 @@ class WooInstance(models.Model):
|
|||||||
instance._log_sync('customer', 'odoo_to_woo', instance.name, 'failed', str(e))
|
instance._log_sync('customer', 'odoo_to_woo', instance.name, 'failed', str(e))
|
||||||
instance._notify_failure('customer', 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):
|
def _sync_products(self):
|
||||||
"""Sync products — implemented in Task 22."""
|
"""Sync product prices between Odoo and WooCommerce."""
|
||||||
self.ensure_one()
|
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):
|
for pm in maps:
|
||||||
"""Sync orders — implemented in Task 20."""
|
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()
|
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):
|
def _sync_inventory(self):
|
||||||
"""Sync inventory — implemented in Task 22."""
|
"""Push Odoo stock levels to WooCommerce."""
|
||||||
self.ensure_one()
|
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):
|
def _sync_customers(self):
|
||||||
"""Sync customers — implemented in Task 25."""
|
"""Push updated Odoo partner addresses to WooCommerce."""
|
||||||
self.ensure_one()
|
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):
|
def _notify_failure(self, sync_type, error_message):
|
||||||
"""Send email notification on sync failure."""
|
"""Send email notification on sync failure."""
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class WooOrder(models.Model):
|
class WooOrder(models.Model):
|
||||||
@@ -23,3 +29,143 @@ class WooOrder(models.Model):
|
|||||||
('cancelled', 'Cancelled'),
|
('cancelled', 'Cancelled'),
|
||||||
], default='new')
|
], default='new')
|
||||||
shipment_ids = fields.One2many('woo.shipment', 'order_id')
|
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