feat: implement returns/refunds, customer sync, tax/pricelist mapping
- Add woo.return workflow: action_approve (creates reverse picking), action_reject, action_receive, action_refund (creates credit note + WC refund) - Add woo.customer._find_or_create class method for customer lookup/creation - Replace _sync_customers placeholder to push address updates to WC - Add _sync_customer_from_wc webhook handler for inbound customer updates - Add woo.tax.map helpers: get_odoo_tax, get_wc_tax_class - Add woo.pricelist.map helper: get_pricelist_for_role Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
from odoo import fields, models
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooCustomer(models.Model):
|
||||
@@ -13,3 +17,75 @@ class WooCustomer(models.Model):
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class WooPricelistMap(models.Model):
|
||||
@@ -12,3 +12,24 @@ class WooPricelistMap(models.Model):
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True, default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lookup helper (Task 26)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def get_pricelist_for_role(self, instance, wc_role):
|
||||
"""Return the Odoo product.pricelist mapped to a WC customer role.
|
||||
|
||||
Args:
|
||||
instance: woo.instance record
|
||||
wc_role: WC customer role slug (e.g. 'wholesale', 'customer')
|
||||
|
||||
Returns:
|
||||
product.pricelist record or empty recordset
|
||||
"""
|
||||
mapping = self.search([
|
||||
('instance_id', '=', instance.id),
|
||||
('woo_role', '=', wc_role),
|
||||
], limit=1)
|
||||
return mapping.pricelist_id if mapping else self.env['product.pricelist']
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import logging
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooReturn(models.Model):
|
||||
@@ -21,6 +26,169 @@ class WooReturn(models.Model):
|
||||
'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'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class WooTaxMap(models.Model):
|
||||
@@ -12,3 +12,41 @@ class WooTaxMap(models.Model):
|
||||
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 ''
|
||||
|
||||
Reference in New Issue
Block a user