Split 49 modules/suites into independent git repos; untrack from monorepo
Each top-level module/suite folder is now its own private repo on GitHub (gsinghpal/<name>) and gitea (admin/<name>), with a fresh single initial commit. The monorepo no longer tracks them (added to .gitignore + git rm --cached); working-tree files are retained on disk and managed in their own repos. The monorepo keeps shared root files (CLAUDE.md, docs/, scripts/, tools/, AGENTS.md, WIP/obsolete dirs) and full history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
from . import woo_shipping_carrier
|
||||
from . import woo_instance
|
||||
from . import woo_category_map
|
||||
from . import woo_product_map
|
||||
from . import woo_order
|
||||
from . import woo_shipment
|
||||
from . import woo_customer
|
||||
from . import woo_sync_log
|
||||
from . import woo_conflict
|
||||
from . import woo_tax_map
|
||||
from . import woo_pricelist_map
|
||||
from . import woo_return
|
||||
from . import sale_order
|
||||
from . import stock_picking
|
||||
from . import account_move
|
||||
from . import res_partner
|
||||
@@ -1,28 +0,0 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
woo_order_id = fields.Many2one('woo.order', string='WooCommerce Order')
|
||||
is_woo_invoice = fields.Boolean(compute='_compute_is_woo_invoice', string='Is WC Invoice')
|
||||
|
||||
def _compute_is_woo_invoice(self):
|
||||
for move in self:
|
||||
move.is_woo_invoice = bool(move.woo_order_id)
|
||||
|
||||
def action_post(self):
|
||||
"""Override to auto-push invoice PDF to WooCommerce on posting."""
|
||||
res = super().action_post()
|
||||
for move in self:
|
||||
if move.woo_order_id and not move.woo_order_id.invoice_synced:
|
||||
try:
|
||||
move.woo_order_id.action_push_invoice_pdf()
|
||||
move.woo_order_id.invoice_synced = True
|
||||
except Exception as e:
|
||||
_logger.error("Failed to push invoice PDF to WC: %s", e)
|
||||
return res
|
||||
@@ -1,13 +0,0 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
woo_customer_ids = fields.One2many('woo.customer', 'partner_id', string='WooCommerce Links')
|
||||
is_woo_customer = fields.Boolean(compute='_compute_is_woo_customer', string='Is WC Customer', store=True)
|
||||
|
||||
@api.depends('woo_customer_ids')
|
||||
def _compute_is_woo_customer(self):
|
||||
for partner in self:
|
||||
partner.is_woo_customer = bool(partner.woo_customer_ids)
|
||||
@@ -1,12 +0,0 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
woo_bind_ids = fields.One2many('woo.order', 'sale_order_id', string='WooCommerce Orders')
|
||||
woo_order_count = fields.Integer(compute='_compute_woo_order_count', string='WC Orders')
|
||||
|
||||
def _compute_woo_order_count(self):
|
||||
for order in self:
|
||||
order.woo_order_count = len(order.woo_bind_ids)
|
||||
@@ -1,57 +0,0 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_inherit = 'stock.picking'
|
||||
|
||||
woo_tracking_number = fields.Char(string='WC Tracking Number')
|
||||
woo_carrier_id = fields.Many2one('woo.shipping.carrier', string='WC Shipping Carrier')
|
||||
woo_shipment_ids = fields.One2many('woo.shipment', 'picking_id', string='WC Shipments')
|
||||
is_woo_delivery = fields.Boolean(compute='_compute_is_woo_delivery', string='Is WC Delivery')
|
||||
|
||||
def _compute_is_woo_delivery(self):
|
||||
for picking in self:
|
||||
picking.is_woo_delivery = bool(picking.woo_shipment_ids) or bool(
|
||||
picking.sale_id and picking.sale_id.woo_bind_ids
|
||||
)
|
||||
|
||||
def button_validate(self):
|
||||
"""Override to auto-create shipment and push tracking to WC."""
|
||||
res = super().button_validate()
|
||||
for picking in self:
|
||||
if not picking.sale_id or not picking.sale_id.woo_bind_ids:
|
||||
continue
|
||||
woo_order = picking.sale_id.woo_bind_ids[0]
|
||||
# Create shipment record
|
||||
shipment_vals = {
|
||||
'order_id': woo_order.id,
|
||||
'picking_id': picking.id,
|
||||
'carrier_id': picking.woo_carrier_id.id if picking.woo_carrier_id else False,
|
||||
'tracking_number': picking.woo_tracking_number or '',
|
||||
'shipped_date': fields.Datetime.now(),
|
||||
'is_backorder': bool(picking.backorder_ids),
|
||||
'company_id': picking.company_id.id,
|
||||
}
|
||||
shipment = self.env['woo.shipment'].create(shipment_vals)
|
||||
|
||||
# Auto-push to WC if tracking number is set
|
||||
if picking.woo_tracking_number:
|
||||
try:
|
||||
woo_order.action_push_shipping(
|
||||
picking.woo_tracking_number,
|
||||
picking.woo_carrier_id.id if picking.woo_carrier_id else False,
|
||||
)
|
||||
shipment.synced_to_woo = True
|
||||
except Exception as e:
|
||||
_logger.error("Failed to push shipping to WC: %s", e)
|
||||
|
||||
# Push delivery PDF
|
||||
try:
|
||||
woo_order.action_push_delivery_pdf(picking)
|
||||
except Exception as e:
|
||||
_logger.warning("Failed to push delivery PDF to WC: %s", e)
|
||||
return res
|
||||
@@ -1,15 +0,0 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class WooCategoryMap(models.Model):
|
||||
_name = 'woo.category.map'
|
||||
_description = 'WooCommerce Category Mapping'
|
||||
_order = 'odoo_category_id'
|
||||
_rec_name = 'woo_category_name'
|
||||
|
||||
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
|
||||
odoo_category_id = fields.Many2one('product.category', string='Odoo Category')
|
||||
woo_category_id = fields.Integer(string='WC Category ID', required=True)
|
||||
woo_category_name = fields.Char(string='WC Category Name')
|
||||
woo_category_slug = fields.Char(string='WC Category Slug')
|
||||
company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company)
|
||||
@@ -1,100 +0,0 @@
|
||||
import logging
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooConflict(models.Model):
|
||||
_name = 'woo.conflict'
|
||||
_description = 'WooCommerce Sync Conflict'
|
||||
_rec_name = 'field_name'
|
||||
|
||||
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
|
||||
conflict_type = fields.Selection([
|
||||
('product', 'Product'),
|
||||
('customer', 'Customer'),
|
||||
('order', 'Order'),
|
||||
])
|
||||
map_id = fields.Many2one('woo.product.map')
|
||||
customer_id = fields.Many2one('woo.customer')
|
||||
order_id = fields.Many2one('woo.order')
|
||||
field_name = fields.Char()
|
||||
odoo_value = fields.Char()
|
||||
woo_value = fields.Char()
|
||||
resolution = fields.Selection([
|
||||
('pending', 'Pending'),
|
||||
('use_odoo', 'Use Odoo'),
|
||||
('use_woo', 'Use WooCommerce'),
|
||||
], default='pending')
|
||||
resolved_by = fields.Many2one('res.users')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Resolution methods (Task 23)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_use_odoo(self):
|
||||
"""Resolve conflict by pushing Odoo value to WooCommerce."""
|
||||
self.ensure_one()
|
||||
if self.resolution != 'pending':
|
||||
raise UserError("This conflict has already been resolved.")
|
||||
|
||||
client = self.instance_id._get_client()
|
||||
|
||||
if self.conflict_type == 'product' and self.map_id:
|
||||
if self.field_name == 'price':
|
||||
client.update_product(self.map_id.woo_product_id, {
|
||||
'regular_price': self.odoo_value,
|
||||
})
|
||||
self.map_id.state = 'mapped'
|
||||
self.map_id.last_synced = fields.Datetime.now()
|
||||
|
||||
self.resolution = 'use_odoo'
|
||||
self.resolved_by = self.env.user
|
||||
self.instance_id._log_sync(
|
||||
self.conflict_type or 'product', 'odoo_to_woo',
|
||||
self.map_id.product_id.display_name if self.map_id and self.map_id.product_id else 'N/A',
|
||||
'success', f'Conflict resolved: use Odoo value ({self.odoo_value})',
|
||||
)
|
||||
|
||||
def action_use_woo(self):
|
||||
"""Resolve conflict by pulling WooCommerce value into Odoo."""
|
||||
self.ensure_one()
|
||||
if self.resolution != 'pending':
|
||||
raise UserError("This conflict has already been resolved.")
|
||||
|
||||
if self.conflict_type == 'product' and self.map_id and self.map_id.product_id:
|
||||
if self.field_name == 'price':
|
||||
self.map_id.product_id.list_price = float(self.woo_value or 0)
|
||||
self.map_id.state = 'mapped'
|
||||
self.map_id.last_synced = fields.Datetime.now()
|
||||
|
||||
self.resolution = 'use_woo'
|
||||
self.resolved_by = self.env.user
|
||||
self.instance_id._log_sync(
|
||||
self.conflict_type or 'product', 'woo_to_odoo',
|
||||
self.map_id.product_id.display_name if self.map_id and self.map_id.product_id else 'N/A',
|
||||
'success', f'Conflict resolved: use WC value ({self.woo_value})',
|
||||
)
|
||||
|
||||
def action_bulk_resolve_odoo(self):
|
||||
"""Server action: resolve all selected conflicts with Odoo values."""
|
||||
for conflict in self:
|
||||
if conflict.resolution == 'pending':
|
||||
try:
|
||||
conflict.action_use_odoo()
|
||||
except Exception as e:
|
||||
_logger.error("Bulk resolve (Odoo) failed for conflict %s: %s", conflict.id, e)
|
||||
|
||||
def action_bulk_resolve_woo(self):
|
||||
"""Server action: resolve all selected conflicts with WC values."""
|
||||
for conflict in self:
|
||||
if conflict.resolution == 'pending':
|
||||
try:
|
||||
conflict.action_use_woo()
|
||||
except Exception as e:
|
||||
_logger.error("Bulk resolve (WC) failed for conflict %s: %s", conflict.id, e)
|
||||
@@ -1,92 +0,0 @@
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooCustomer(models.Model):
|
||||
_name = 'woo.customer'
|
||||
_description = 'WooCommerce Customer'
|
||||
_rec_name = 'woo_email'
|
||||
|
||||
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
|
||||
partner_id = fields.Many2one('res.partner', required=True)
|
||||
woo_customer_id = fields.Integer(index=True)
|
||||
woo_email = fields.Char()
|
||||
last_synced = fields.Datetime()
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True, default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers (Task 25)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _find_or_create(self, instance, email, wc_data=None):
|
||||
"""Find or create a woo.customer + res.partner for the given email.
|
||||
|
||||
Args:
|
||||
instance: woo.instance record
|
||||
email: customer email address
|
||||
wc_data: optional dict with full WC customer payload
|
||||
|
||||
Returns:
|
||||
woo.customer record
|
||||
"""
|
||||
email = (email or '').strip().lower()
|
||||
if not email:
|
||||
return self.browse()
|
||||
|
||||
wc_customer_id = (wc_data or {}).get('id', 0)
|
||||
|
||||
# Check existing link
|
||||
if wc_customer_id:
|
||||
existing = self.search([
|
||||
('instance_id', '=', instance.id),
|
||||
('woo_customer_id', '=', wc_customer_id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Check by email
|
||||
existing = self.search([
|
||||
('instance_id', '=', instance.id),
|
||||
('woo_email', '=ilike', email),
|
||||
], limit=1)
|
||||
if existing:
|
||||
if wc_customer_id and not existing.woo_customer_id:
|
||||
existing.woo_customer_id = wc_customer_id
|
||||
return existing
|
||||
|
||||
# Find or create partner
|
||||
partner = self.env['res.partner'].search([
|
||||
('email', '=ilike', email),
|
||||
'|', ('company_id', '=', instance.company_id.id), ('company_id', '=', False),
|
||||
], limit=1)
|
||||
|
||||
if not partner:
|
||||
billing = (wc_data or {}).get('billing', {})
|
||||
partner_vals = instance._prepare_partner_vals(billing) if billing else {
|
||||
'name': email.split('@')[0].title(),
|
||||
'email': email,
|
||||
'company_id': instance.company_id.id,
|
||||
}
|
||||
partner = self.env['res.partner'].create(partner_vals)
|
||||
|
||||
# Create woo.customer link
|
||||
woo_cust = self.create({
|
||||
'instance_id': instance.id,
|
||||
'partner_id': partner.id,
|
||||
'woo_customer_id': wc_customer_id,
|
||||
'woo_email': email,
|
||||
'last_synced': fields.Datetime.now(),
|
||||
'company_id': instance.company_id.id,
|
||||
})
|
||||
|
||||
instance._log_sync(
|
||||
'customer', 'woo_to_odoo', partner.display_name,
|
||||
'success', f'Customer created from WC (email: {email})',
|
||||
)
|
||||
return woo_cust
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,297 +0,0 @@
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooOrder(models.Model):
|
||||
_name = 'woo.order'
|
||||
_description = 'WooCommerce Order'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_rec_name = 'display_name'
|
||||
_order = 'id desc'
|
||||
|
||||
display_name = fields.Char(compute='_compute_display_name', store=True)
|
||||
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
|
||||
sale_order_id = fields.Many2one('sale.order')
|
||||
woo_order_id = fields.Integer(index=True)
|
||||
woo_order_number = fields.Char(string='WC Order #')
|
||||
woo_status = fields.Selection([
|
||||
('pending', 'Pending Payment'),
|
||||
('processing', 'Processing'),
|
||||
('on-hold', 'On Hold'),
|
||||
('completed', 'Completed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
('refunded', 'Refunded'),
|
||||
('failed', 'Failed'),
|
||||
('trash', 'Trashed'),
|
||||
], string='WC Status', tracking=True)
|
||||
invoice_id = fields.Many2one('account.move')
|
||||
invoice_synced = fields.Boolean()
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True, default=lambda self: self.env.company,
|
||||
)
|
||||
state = fields.Selection([
|
||||
('new', 'New'),
|
||||
('confirmed', 'Confirmed'),
|
||||
('shipped', 'Shipped'),
|
||||
('completed', 'Completed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], default='new', tracking=True)
|
||||
|
||||
WC_STATUS_TO_STATE = {
|
||||
'pending': 'new',
|
||||
'on-hold': 'new',
|
||||
'processing': 'confirmed',
|
||||
'completed': 'completed',
|
||||
'cancelled': 'cancelled',
|
||||
'refunded': 'cancelled',
|
||||
'failed': 'cancelled',
|
||||
'trash': 'cancelled',
|
||||
}
|
||||
|
||||
@api.onchange('woo_status')
|
||||
def _onchange_woo_status(self):
|
||||
if self.woo_status:
|
||||
self.state = self.WC_STATUS_TO_STATE.get(self.woo_status, self.state)
|
||||
|
||||
def _set_woo_status(self, wc_status):
|
||||
"""Set woo_status and auto-map to Odoo state."""
|
||||
vals = {}
|
||||
if wc_status:
|
||||
vals['woo_status'] = wc_status
|
||||
mapped_state = self.WC_STATUS_TO_STATE.get(wc_status)
|
||||
if mapped_state:
|
||||
vals['state'] = mapped_state
|
||||
if vals:
|
||||
self.write(vals)
|
||||
shipment_ids = fields.One2many('woo.shipment', 'order_id')
|
||||
|
||||
delivery_count = fields.Integer(compute='_compute_delivery_count')
|
||||
invoice_count = fields.Integer(compute='_compute_invoice_count')
|
||||
|
||||
@api.depends('woo_order_number', 'sale_order_id', 'sale_order_id.name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
parts = []
|
||||
if rec.woo_order_number:
|
||||
parts.append('WC#%s' % rec.woo_order_number)
|
||||
if rec.sale_order_id:
|
||||
parts.append(rec.sale_order_id.name)
|
||||
rec.display_name = ' — '.join(parts) if parts else 'WC Order #%s' % rec.id
|
||||
|
||||
@api.depends('sale_order_id')
|
||||
def _compute_delivery_count(self):
|
||||
for rec in self:
|
||||
if rec.sale_order_id:
|
||||
rec.delivery_count = self.env['stock.picking'].search_count([
|
||||
('origin', '=', rec.sale_order_id.name),
|
||||
])
|
||||
else:
|
||||
rec.delivery_count = 0
|
||||
|
||||
@api.depends('sale_order_id')
|
||||
def _compute_invoice_count(self):
|
||||
for rec in self:
|
||||
if rec.sale_order_id:
|
||||
rec.invoice_count = self.env['account.move'].search_count([
|
||||
('invoice_origin', '=', rec.sale_order_id.name),
|
||||
('move_type', 'in', ['out_invoice', 'out_refund']),
|
||||
])
|
||||
else:
|
||||
rec.invoice_count = 0
|
||||
|
||||
def action_view_sale_order(self):
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': self.sale_order_id.id,
|
||||
'views': [(False, 'form')],
|
||||
}
|
||||
|
||||
def action_view_deliveries(self):
|
||||
self.ensure_one()
|
||||
pickings = self.env['stock.picking'].search([
|
||||
('origin', '=', self.sale_order_id.name),
|
||||
])
|
||||
if len(pickings) == 1:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'stock.picking',
|
||||
'res_id': pickings.id,
|
||||
'views': [(False, 'form')],
|
||||
}
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Deliveries',
|
||||
'res_model': 'stock.picking',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('origin', '=', self.sale_order_id.name)],
|
||||
}
|
||||
|
||||
def action_view_invoices(self):
|
||||
self.ensure_one()
|
||||
invoices = self.env['account.move'].search([
|
||||
('invoice_origin', '=', self.sale_order_id.name),
|
||||
('move_type', 'in', ['out_invoice', 'out_refund']),
|
||||
])
|
||||
if len(invoices) == 1:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move',
|
||||
'res_id': invoices.id,
|
||||
'views': [(False, 'form')],
|
||||
}
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Invoices',
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('invoice_origin', '=', self.sale_order_id.name),
|
||||
('move_type', 'in', ['out_invoice', 'out_refund'])],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Push methods (Task 20)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_push_shipping(self, tracking_number, carrier_id=False):
|
||||
"""Push shipping/tracking info to WooCommerce and update status."""
|
||||
self.ensure_one()
|
||||
client = self.instance_id._get_client()
|
||||
|
||||
update_data = {
|
||||
'status': 'completed',
|
||||
}
|
||||
|
||||
# Build tracking meta
|
||||
meta = [
|
||||
{'key': '_tracking_number', 'value': tracking_number},
|
||||
]
|
||||
if carrier_id:
|
||||
carrier = self.env['woo.shipping.carrier'].browse(carrier_id)
|
||||
if carrier.exists():
|
||||
meta.append({'key': '_tracking_provider', 'value': carrier.name})
|
||||
if carrier.tracking_url:
|
||||
url = carrier.tracking_url.replace('{tracking}', tracking_number)
|
||||
meta.append({'key': '_tracking_url', 'value': url})
|
||||
|
||||
update_data['meta_data'] = meta
|
||||
|
||||
client.update_order(self.woo_order_id, update_data)
|
||||
self._set_woo_status('completed')
|
||||
self.state = 'shipped'
|
||||
|
||||
self.instance_id._log_sync(
|
||||
'order', 'odoo_to_woo', self.sale_order_id.name,
|
||||
'success', f'Shipping pushed with tracking: {tracking_number}',
|
||||
)
|
||||
|
||||
def action_push_completed(self):
|
||||
"""Mark WC order as completed."""
|
||||
self.ensure_one()
|
||||
client = self.instance_id._get_client()
|
||||
client.update_order(self.woo_order_id, {'status': 'completed'})
|
||||
self._set_woo_status('completed')
|
||||
|
||||
self.instance_id._log_sync(
|
||||
'order', 'odoo_to_woo', self.sale_order_id.name,
|
||||
'success', 'Order marked as completed in WC',
|
||||
)
|
||||
|
||||
def action_push_invoice_pdf(self):
|
||||
"""Render invoice PDF and push to WC via custom plugin endpoint."""
|
||||
self.ensure_one()
|
||||
if not self.invoice_id:
|
||||
raise UserError("No invoice linked to this WC order.")
|
||||
|
||||
# Generate PDF report
|
||||
report = self.env.ref('account.account_invoices')
|
||||
pdf_content, _content_type = report._render_qweb_pdf(
|
||||
report.id, [self.invoice_id.id]
|
||||
)
|
||||
pdf_b64 = base64.b64encode(pdf_content).decode('utf-8')
|
||||
|
||||
# Push to WC via order note or meta
|
||||
try:
|
||||
client = self.instance_id._get_client()
|
||||
client.update_order(self.woo_order_id, {
|
||||
'meta_data': [
|
||||
{'key': '_odoo_invoice_ref', 'value': self.invoice_id.name},
|
||||
{'key': '_odoo_invoice_pdf', 'value': pdf_b64},
|
||||
]
|
||||
})
|
||||
self.invoice_synced = True
|
||||
self.instance_id._log_sync(
|
||||
'invoice', 'odoo_to_woo', self.invoice_id.name,
|
||||
'success', 'Invoice PDF pushed to WC',
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("Failed to push invoice PDF to WC: %s", e)
|
||||
self.instance_id._log_sync(
|
||||
'invoice', 'odoo_to_woo', self.invoice_id.name,
|
||||
'failed', str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
def action_push_delivery_pdf(self, picking):
|
||||
"""Render delivery slip PDF and push to WC."""
|
||||
self.ensure_one()
|
||||
report = self.env.ref('stock.action_report_delivery')
|
||||
pdf_content, _content_type = report._render_qweb_pdf(
|
||||
report.id, [picking.id]
|
||||
)
|
||||
pdf_b64 = base64.b64encode(pdf_content).decode('utf-8')
|
||||
|
||||
try:
|
||||
client = self.instance_id._get_client()
|
||||
client.update_order(self.woo_order_id, {
|
||||
'meta_data': [
|
||||
{'key': '_odoo_delivery_ref', 'value': picking.name},
|
||||
{'key': '_odoo_delivery_pdf', 'value': pdf_b64},
|
||||
]
|
||||
})
|
||||
self.instance_id._log_sync(
|
||||
'order', 'odoo_to_woo', picking.name,
|
||||
'success', 'Delivery PDF pushed to WC',
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("Failed to push delivery PDF to WC: %s", e)
|
||||
|
||||
def _push_messages_to_wc(self):
|
||||
"""Extract customer-visible messages and push as WC order notes."""
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return
|
||||
|
||||
client = self.instance_id._get_client()
|
||||
|
||||
# Get messages from the sale order that are customer-visible
|
||||
messages = self.env['mail.message'].search([
|
||||
('res_id', '=', self.sale_order_id.id),
|
||||
('model', '=', 'sale.order'),
|
||||
('message_type', 'in', ['comment', 'email']),
|
||||
('subtype_id.internal', '=', False),
|
||||
], order='create_date asc')
|
||||
|
||||
for msg in messages:
|
||||
note_body = msg.body or msg.preview or ''
|
||||
if not note_body:
|
||||
continue
|
||||
try:
|
||||
# WC order notes endpoint
|
||||
client.post(f'orders/{self.woo_order_id}/notes', {
|
||||
'note': note_body,
|
||||
'customer_note': True,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Failed to push message to WC order %s: %s",
|
||||
self.woo_order_id, e,
|
||||
)
|
||||
@@ -1,36 +0,0 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class WooPricelistMap(models.Model):
|
||||
_name = 'woo.pricelist.map'
|
||||
_description = 'WooCommerce Pricelist Mapping'
|
||||
_rec_name = 'woo_role_name'
|
||||
|
||||
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
|
||||
pricelist_id = fields.Many2one('product.pricelist', required=True)
|
||||
woo_role = fields.Char(required=True)
|
||||
woo_role_name = fields.Char()
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True, default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lookup helper (Task 26)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def get_pricelist_for_role(self, instance, wc_role):
|
||||
"""Return the Odoo product.pricelist mapped to a WC customer role.
|
||||
|
||||
Args:
|
||||
instance: woo.instance record
|
||||
wc_role: WC customer role slug (e.g. 'wholesale', 'customer')
|
||||
|
||||
Returns:
|
||||
product.pricelist record or empty recordset
|
||||
"""
|
||||
mapping = self.search([
|
||||
('instance_id', '=', instance.id),
|
||||
('woo_role', '=', wc_role),
|
||||
], limit=1)
|
||||
return mapping.pricelist_id if mapping else self.env['product.pricelist']
|
||||
@@ -1,476 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooProductMap(models.Model):
|
||||
_name = 'woo.product.map'
|
||||
_description = 'WooCommerce Product Mapping'
|
||||
_rec_name = 'woo_product_name'
|
||||
|
||||
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
|
||||
product_id = fields.Many2one('product.product')
|
||||
woo_product_id = fields.Integer()
|
||||
woo_product_name = fields.Char()
|
||||
woo_sku = fields.Char()
|
||||
woo_product_type = fields.Selection([
|
||||
('simple', 'Simple'),
|
||||
('variable', 'Variable'),
|
||||
('grouped', 'Grouped'),
|
||||
('external', 'External'),
|
||||
])
|
||||
woo_regular_price = fields.Float(string='WC Standard Price', digits='Product Price')
|
||||
woo_sale_price = fields.Float(string='WC Sale Price', digits='Product Price')
|
||||
woo_permalink = fields.Char(string='WC Product URL')
|
||||
woo_category_id = fields.Integer(string='WC Category ID')
|
||||
woo_category_name = fields.Char(string='WC Category')
|
||||
woo_parent_id = fields.Integer()
|
||||
is_variation = fields.Boolean()
|
||||
sync_price = fields.Boolean(default=True)
|
||||
sync_inventory = fields.Boolean(default=True)
|
||||
sync_images = fields.Boolean(default=True)
|
||||
woo_image_ids = fields.Char() # JSON
|
||||
last_synced = fields.Datetime()
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True, default=lambda self: self.env.company,
|
||||
)
|
||||
state = fields.Selection([
|
||||
('unmapped', 'Unmapped'),
|
||||
('mapped', 'Mapped'),
|
||||
('conflict', 'Conflict'),
|
||||
('error', 'Error'),
|
||||
], default='unmapped')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Individual Price Sync
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_push_price_to_odoo(self):
|
||||
"""Update Odoo product price from WC sale price (or regular if no sale)."""
|
||||
for rec in self:
|
||||
if not rec.product_id:
|
||||
continue
|
||||
# Use sale price if available, otherwise regular price
|
||||
wc_price = rec.woo_sale_price if rec.woo_sale_price else rec.woo_regular_price
|
||||
if wc_price:
|
||||
rec.product_id.list_price = wc_price
|
||||
rec.instance_id._log_sync(
|
||||
'product', 'woo_to_odoo', rec.product_id.name, 'success',
|
||||
'Odoo price updated from WC: $%.2f' % wc_price,
|
||||
)
|
||||
|
||||
def action_push_price_to_wc(self):
|
||||
"""Push Odoo price to WC.
|
||||
|
||||
Logic:
|
||||
- If WC standard (regular) price is 0 or empty: set as regular_price
|
||||
- If WC standard price exists: set as sale_price
|
||||
- If WC standard price < Odoo price: error (standard can't be less than sale)
|
||||
"""
|
||||
errors = []
|
||||
for rec in self:
|
||||
if not rec.product_id or not rec.instance_id:
|
||||
continue
|
||||
odoo_price = rec.product_id.list_price
|
||||
wc_regular = rec.woo_regular_price or 0.0
|
||||
|
||||
client = rec.instance_id._get_client()
|
||||
update_data = {}
|
||||
|
||||
if wc_regular < 0.01:
|
||||
# No standard price set — push as regular_price
|
||||
update_data = {
|
||||
'regular_price': str(odoo_price),
|
||||
'sale_price': '',
|
||||
}
|
||||
rec.woo_regular_price = odoo_price
|
||||
rec.woo_sale_price = 0.0
|
||||
else:
|
||||
# Standard price exists — push as sale_price
|
||||
if wc_regular < odoo_price - 0.01:
|
||||
# Standard price is less than the price we want to set as sale
|
||||
errors.append(
|
||||
'%s: WC standard price ($%.2f) is less than Odoo price ($%.2f). '
|
||||
'Update the standard price first.' % (rec.woo_product_name, wc_regular, odoo_price)
|
||||
)
|
||||
continue
|
||||
update_data = {
|
||||
'sale_price': str(odoo_price),
|
||||
}
|
||||
rec.woo_sale_price = odoo_price
|
||||
|
||||
try:
|
||||
client.update_product(rec.woo_product_id, update_data)
|
||||
rec.instance_id._log_sync(
|
||||
'product', 'odoo_to_woo', rec.product_id.name, 'success',
|
||||
'Price pushed to WC: $%.2f' % odoo_price,
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append('%s: %s' % (rec.woo_product_name, str(e)))
|
||||
|
||||
if errors:
|
||||
raise UserError('\n'.join(errors))
|
||||
|
||||
def action_set_regular_price(self, price):
|
||||
"""Set the WC standard (regular) price directly."""
|
||||
self.ensure_one()
|
||||
if not self.instance_id:
|
||||
return
|
||||
client = self.instance_id._get_client()
|
||||
# If there's a sale price, regular must be >= sale
|
||||
if self.woo_sale_price and price < self.woo_sale_price - 0.01:
|
||||
raise UserError(
|
||||
'Standard price ($%.2f) cannot be less than the current sale price ($%.2f).'
|
||||
% (price, self.woo_sale_price)
|
||||
)
|
||||
client.update_product(self.woo_product_id, {'regular_price': str(price)})
|
||||
self.woo_regular_price = price
|
||||
self.instance_id._log_sync(
|
||||
'product', 'odoo_to_woo', self.woo_product_name, 'success',
|
||||
'Standard price set to $%.2f' % price,
|
||||
)
|
||||
|
||||
def action_set_sale_price(self, price):
|
||||
"""Set the WC sale price directly."""
|
||||
self.ensure_one()
|
||||
if not self.instance_id:
|
||||
return
|
||||
client = self.instance_id._get_client()
|
||||
# Sale price cannot exceed regular price
|
||||
if self.woo_regular_price and price > self.woo_regular_price + 0.01:
|
||||
raise UserError(
|
||||
'Sale price ($%.2f) cannot exceed the standard price ($%.2f).'
|
||||
% (price, self.woo_regular_price)
|
||||
)
|
||||
update_data = {'sale_price': str(price) if price > 0 else ''}
|
||||
client.update_product(self.woo_product_id, update_data)
|
||||
self.woo_sale_price = price
|
||||
self.instance_id._log_sync(
|
||||
'product', 'odoo_to_woo', self.woo_product_name, 'success',
|
||||
'Sale price set to $%.2f' % price,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Push Variants to WooCommerce
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_push_variants_to_wc(self):
|
||||
"""Convert a simple WC product to variable and create variations from Odoo variants."""
|
||||
self.ensure_one()
|
||||
if not self.product_id or not self.instance_id:
|
||||
raise UserError("Product or instance not set.")
|
||||
|
||||
tmpl = self.product_id.product_tmpl_id
|
||||
variants = tmpl.product_variant_ids
|
||||
if len(variants) <= 1:
|
||||
raise UserError("This product has no variants in Odoo.")
|
||||
|
||||
client = self.instance_id._get_client()
|
||||
inst = self.instance_id
|
||||
|
||||
# Step 1: Build WC attributes from Odoo attribute lines
|
||||
wc_attributes = []
|
||||
for attr_line in tmpl.attribute_line_ids:
|
||||
attr_name = attr_line.attribute_id.name
|
||||
attr_values = attr_line.value_ids.mapped('name')
|
||||
|
||||
# Find or create WC attribute
|
||||
wc_attr = self._find_or_create_wc_attribute(client, attr_name)
|
||||
|
||||
# Create terms for each value
|
||||
wc_terms = []
|
||||
for val_name in attr_values:
|
||||
term = self._find_or_create_wc_attribute_term(client, wc_attr['id'], val_name)
|
||||
wc_terms.append(term['name'])
|
||||
|
||||
wc_attributes.append({
|
||||
'id': wc_attr['id'],
|
||||
'name': attr_name,
|
||||
'position': 0,
|
||||
'visible': True,
|
||||
'variation': True,
|
||||
'options': wc_terms,
|
||||
})
|
||||
|
||||
# Step 2: Update the WC product from simple → variable with attributes
|
||||
try:
|
||||
client.update_product(self.woo_product_id, {
|
||||
'type': 'variable',
|
||||
'attributes': wc_attributes,
|
||||
})
|
||||
self.woo_product_type = 'variable'
|
||||
except Exception as e:
|
||||
raise UserError("Failed to convert WC product to variable: %s" % str(e))
|
||||
|
||||
# Build WC attribute ID lookup
|
||||
wc_attr_id_map = {a['name'].upper(): a['id'] for a in wc_attributes}
|
||||
|
||||
# Step 3: Create a WC variation for each Odoo variant
|
||||
created = 0
|
||||
for variant in variants:
|
||||
existing = self.search([
|
||||
('instance_id', '=', inst.id),
|
||||
('product_id', '=', variant.id),
|
||||
('is_variation', '=', True),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# Build variation attributes with WC IDs
|
||||
var_attributes = []
|
||||
for ptav in variant.product_template_attribute_value_ids:
|
||||
attr_name = ptav.attribute_id.name
|
||||
wc_aid = wc_attr_id_map.get(attr_name.upper(), 0)
|
||||
entry = {'option': ptav.name}
|
||||
if wc_aid:
|
||||
entry['id'] = wc_aid
|
||||
else:
|
||||
entry['name'] = attr_name
|
||||
var_attributes.append(entry)
|
||||
|
||||
var_data = {
|
||||
'regular_price': str(variant.list_price),
|
||||
'sku': variant.default_code or '',
|
||||
'attributes': var_attributes,
|
||||
'manage_stock': True,
|
||||
'stock_quantity': int(variant.qty_available),
|
||||
}
|
||||
|
||||
# Tax class
|
||||
wc_tax_class = self.env['woo.tax.map'].get_wc_tax_class(inst, variant.taxes_id[:1].id if variant.taxes_id else False)
|
||||
if wc_tax_class:
|
||||
var_data['tax_class'] = wc_tax_class
|
||||
|
||||
# Variant image — pass Odoo's public URL, WC downloads it directly
|
||||
if variant.image_variant_1920:
|
||||
odoo_base = inst.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
||||
filename = (variant.default_code or 'variant') + '.png'
|
||||
if odoo_base:
|
||||
img_url = f"{odoo_base}/web/image/product.product/{variant.id}/image_1920/{filename}"
|
||||
var_data['image'] = {
|
||||
'src': img_url,
|
||||
'name': filename,
|
||||
'alt': variant.display_name,
|
||||
}
|
||||
|
||||
try:
|
||||
wc_variation = client.create_product_variation(self.woo_product_id, var_data)
|
||||
|
||||
# Create variation product map
|
||||
self.create({
|
||||
'instance_id': inst.id,
|
||||
'product_id': variant.id,
|
||||
'woo_product_id': wc_variation['id'],
|
||||
'woo_product_name': variant.display_name,
|
||||
'woo_sku': variant.default_code or '',
|
||||
'woo_regular_price': variant.list_price,
|
||||
'woo_sale_price': 0,
|
||||
'woo_permalink': self.woo_permalink or '',
|
||||
'woo_product_type': 'simple',
|
||||
'woo_parent_id': self.woo_product_id,
|
||||
'is_variation': True,
|
||||
'state': 'mapped',
|
||||
'company_id': inst.company_id.id,
|
||||
})
|
||||
created += 1
|
||||
except Exception as e:
|
||||
_logger.error("Failed to create variation for %s: %s", variant.display_name, e)
|
||||
|
||||
inst._log_sync('product', 'odoo_to_woo', tmpl.name, 'success',
|
||||
'Pushed %d variants to WC product #%s' % (created, self.woo_product_id))
|
||||
return True
|
||||
|
||||
def _find_or_create_wc_attribute(self, client, attr_name):
|
||||
"""Find or create a WC product attribute by name."""
|
||||
try:
|
||||
attrs = client.get_product_attributes()
|
||||
for a in attrs:
|
||||
if a.get('name', '').lower() == attr_name.lower():
|
||||
return a
|
||||
except Exception:
|
||||
pass
|
||||
return client.create_product_attribute({
|
||||
'name': attr_name,
|
||||
'slug': attr_name.lower().replace(' ', '-'),
|
||||
'type': 'select',
|
||||
'order_by': 'menu_order',
|
||||
})
|
||||
|
||||
def _find_or_create_wc_attribute_term(self, client, attr_id, term_name):
|
||||
"""Find or create a WC attribute term."""
|
||||
try:
|
||||
terms = client.get_attribute_terms(attr_id)
|
||||
for t in terms:
|
||||
if t.get('name', '').lower() == term_name.lower():
|
||||
return t
|
||||
except Exception:
|
||||
pass
|
||||
return client.create_attribute_term(attr_id, {'name': term_name})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create in Odoo (from unmapped WC product)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_create_in_odoo(self):
|
||||
"""Create an Odoo product from WC mapping data, link the mapping, and
|
||||
return the new product ID so the JS can open the form."""
|
||||
self.ensure_one()
|
||||
if self.product_id:
|
||||
raise UserError("This mapping already has an Odoo product linked.")
|
||||
|
||||
wc_price = self.woo_sale_price or self.woo_regular_price or 0.0
|
||||
|
||||
# Resolve Odoo category from WC category mapping
|
||||
categ_id = False
|
||||
if self.woo_category_id and self.instance_id:
|
||||
cat_map = self.env['woo.category.map'].search([
|
||||
('instance_id', '=', self.instance_id.id),
|
||||
('woo_category_id', '=', self.woo_category_id),
|
||||
('odoo_category_id', '!=', False),
|
||||
], limit=1)
|
||||
if cat_map:
|
||||
categ_id = cat_map.odoo_category_id.id
|
||||
|
||||
product_vals = {
|
||||
'name': (self.woo_product_name or 'New Product').upper(),
|
||||
'default_code': self.woo_sku or '',
|
||||
'list_price': wc_price,
|
||||
'type': 'consu',
|
||||
}
|
||||
if categ_id:
|
||||
product_vals['categ_id'] = categ_id
|
||||
|
||||
product = self.env['product.product'].create(product_vals)
|
||||
|
||||
self.write({
|
||||
'product_id': product.id,
|
||||
'state': 'mapped',
|
||||
})
|
||||
|
||||
if self.instance_id:
|
||||
self.instance_id._log_sync(
|
||||
'product', 'woo_to_odoo', product.name, 'success',
|
||||
'Created Odoo product from WC #%s' % self.woo_product_id,
|
||||
)
|
||||
|
||||
return {'product_id': product.id}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SKU Sync
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_set_wc_sku(self, sku):
|
||||
"""Set WC product SKU."""
|
||||
self.ensure_one()
|
||||
if not self.instance_id:
|
||||
return
|
||||
client = self.instance_id._get_client()
|
||||
client.update_product(self.woo_product_id, {'sku': sku})
|
||||
self.woo_sku = sku
|
||||
self.instance_id._log_sync(
|
||||
'product', 'odoo_to_woo', self.woo_product_name, 'success',
|
||||
'WC SKU set to %s' % sku,
|
||||
)
|
||||
|
||||
def action_push_sku_to_odoo(self):
|
||||
"""Copy WC SKU to Odoo internal reference."""
|
||||
for rec in self:
|
||||
if rec.product_id and rec.woo_sku:
|
||||
rec.product_id.default_code = rec.woo_sku
|
||||
rec.instance_id._log_sync(
|
||||
'product', 'woo_to_odoo', rec.product_id.name, 'success',
|
||||
'Odoo SKU set from WC: %s' % rec.woo_sku,
|
||||
)
|
||||
|
||||
def action_push_sku_to_wc(self):
|
||||
"""Copy Odoo internal reference to WC SKU."""
|
||||
for rec in self:
|
||||
if rec.product_id and rec.instance_id:
|
||||
sku = rec.product_id.default_code or ''
|
||||
client = rec.instance_id._get_client()
|
||||
client.update_product(rec.woo_product_id, {'sku': sku})
|
||||
rec.woo_sku = sku
|
||||
rec.instance_id._log_sync(
|
||||
'product', 'odoo_to_woo', rec.product_id.name, 'success',
|
||||
'WC SKU set from Odoo: %s' % sku,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Image Sync (Task 22)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_sync_images(self):
|
||||
"""Sync product images between Odoo and WooCommerce."""
|
||||
for pm in self:
|
||||
if not pm.sync_images or not pm.product_id or pm.state != 'mapped':
|
||||
continue
|
||||
try:
|
||||
pm._sync_images_single()
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Image sync failed for %s (WC#%s): %s",
|
||||
pm.product_id.display_name, pm.woo_product_id, e,
|
||||
)
|
||||
|
||||
def _sync_images_single(self):
|
||||
"""Sync images for a single product mapping."""
|
||||
self.ensure_one()
|
||||
client = self.instance_id._get_client()
|
||||
wc_product = client.get_product(self.woo_product_id)
|
||||
wc_images = wc_product.get('images', [])
|
||||
|
||||
# Get Odoo product image hash
|
||||
odoo_image = self.product_id.image_1920
|
||||
odoo_hash = ''
|
||||
if odoo_image:
|
||||
odoo_hash = hashlib.md5(base64.b64decode(odoo_image)).hexdigest()
|
||||
|
||||
# Get WC image hash (download first image)
|
||||
wc_hash = ''
|
||||
wc_image_url = ''
|
||||
if wc_images:
|
||||
wc_image_url = wc_images[0].get('src', '')
|
||||
if wc_image_url:
|
||||
try:
|
||||
resp = requests.get(wc_image_url, timeout=15)
|
||||
if resp.status_code == 200:
|
||||
wc_hash = hashlib.md5(resp.content).hexdigest()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Compare
|
||||
if odoo_hash and wc_hash and odoo_hash == wc_hash:
|
||||
# Images match — nothing to do
|
||||
return
|
||||
|
||||
if odoo_image and not wc_images:
|
||||
# Push Odoo image to WC
|
||||
image_data = base64.b64decode(odoo_image)
|
||||
# Upload via WC media endpoint is complex; store as base64 meta
|
||||
client.update_product(self.woo_product_id, {
|
||||
'images': [{'src': '', 'name': self.product_id.name}],
|
||||
})
|
||||
_logger.info("Image push for %s — WC images updated", self.product_id.display_name)
|
||||
|
||||
elif wc_images and not odoo_image:
|
||||
# Pull WC image to Odoo
|
||||
if wc_image_url:
|
||||
try:
|
||||
resp = requests.get(wc_image_url, timeout=15)
|
||||
if resp.status_code == 200:
|
||||
self.product_id.image_1920 = base64.b64encode(resp.content)
|
||||
except Exception as e:
|
||||
_logger.warning("Failed to download WC image: %s", e)
|
||||
|
||||
# Store WC image IDs for reference
|
||||
image_ids = [{'id': img.get('id'), 'src': img.get('src', '')} for img in wc_images]
|
||||
self.woo_image_ids = json.dumps(image_ids)
|
||||
self.last_synced = fields.Datetime.now()
|
||||
@@ -1,210 +0,0 @@
|
||||
import logging
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooReturn(models.Model):
|
||||
_name = 'woo.return'
|
||||
_description = 'WooCommerce Return'
|
||||
_rec_name = 'order_id'
|
||||
|
||||
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
|
||||
order_id = fields.Many2one('woo.order', required=True)
|
||||
picking_id = fields.Many2one('stock.picking')
|
||||
reason = fields.Text()
|
||||
line_ids = fields.One2many('woo.return.line', 'return_id')
|
||||
state = fields.Selection([
|
||||
('requested', 'Requested'),
|
||||
('approved', 'Approved'),
|
||||
('received', 'Received'),
|
||||
('refunded', 'Refunded'),
|
||||
('rejected', 'Rejected'),
|
||||
], default='requested')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True, default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Workflow methods (Task 24)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_approve(self):
|
||||
"""Approve the return — create a reverse stock.picking."""
|
||||
self.ensure_one()
|
||||
if self.state != 'requested':
|
||||
raise UserError("Only requested returns can be approved.")
|
||||
|
||||
sale_order = self.order_id.sale_order_id
|
||||
if not sale_order:
|
||||
raise UserError("No sale order linked to this WC order.")
|
||||
|
||||
# Find the outbound delivery picking
|
||||
delivery_picking = self.env['stock.picking'].search([
|
||||
('origin', '=', sale_order.name),
|
||||
('picking_type_code', '=', 'outgoing'),
|
||||
('state', '=', 'done'),
|
||||
], limit=1)
|
||||
|
||||
if delivery_picking:
|
||||
# Create return picking via stock.return.picking wizard
|
||||
return_wizard = self.env['stock.return.picking'].with_context(
|
||||
active_id=delivery_picking.id,
|
||||
active_model='stock.picking',
|
||||
).create({})
|
||||
|
||||
# Update quantities based on return lines
|
||||
for wizard_line in return_wizard.product_return_moves:
|
||||
return_line = self.line_ids.filtered(
|
||||
lambda l: l.product_id == wizard_line.product_id
|
||||
)
|
||||
if return_line:
|
||||
wizard_line.quantity = return_line[0].quantity
|
||||
else:
|
||||
wizard_line.quantity = 0
|
||||
|
||||
# Remove lines with 0 quantity
|
||||
return_wizard.product_return_moves.filtered(
|
||||
lambda l: l.quantity == 0
|
||||
).unlink()
|
||||
|
||||
if return_wizard.product_return_moves:
|
||||
result = return_wizard.action_create_returns()
|
||||
if result and result.get('res_id'):
|
||||
self.picking_id = result['res_id']
|
||||
|
||||
self.state = 'approved'
|
||||
|
||||
# Push status to WC
|
||||
try:
|
||||
client = self.instance_id._get_client()
|
||||
client.update_order(self.order_id.woo_order_id, {
|
||||
'meta_data': [
|
||||
{'key': '_return_status', 'value': 'approved'},
|
||||
]
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning("Failed to push return approval to WC: %s", e)
|
||||
|
||||
self.instance_id._log_sync(
|
||||
'order', 'odoo_to_woo', self.order_id.sale_order_id.name,
|
||||
'success', 'Return approved',
|
||||
)
|
||||
|
||||
def action_reject(self):
|
||||
"""Reject the return request."""
|
||||
self.ensure_one()
|
||||
if self.state != 'requested':
|
||||
raise UserError("Only requested returns can be rejected.")
|
||||
|
||||
self.state = 'rejected'
|
||||
|
||||
try:
|
||||
client = self.instance_id._get_client()
|
||||
client.update_order(self.order_id.woo_order_id, {
|
||||
'meta_data': [
|
||||
{'key': '_return_status', 'value': 'rejected'},
|
||||
]
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning("Failed to push return rejection to WC: %s", e)
|
||||
|
||||
self.instance_id._log_sync(
|
||||
'order', 'odoo_to_woo', self.order_id.sale_order_id.name,
|
||||
'success', 'Return rejected',
|
||||
)
|
||||
|
||||
def action_receive(self):
|
||||
"""Mark return items as received."""
|
||||
self.ensure_one()
|
||||
if self.state != 'approved':
|
||||
raise UserError("Only approved returns can be marked as received.")
|
||||
|
||||
# Validate the return picking if it exists
|
||||
if self.picking_id and self.picking_id.state not in ('done', 'cancel'):
|
||||
self.picking_id.button_validate()
|
||||
|
||||
self.state = 'received'
|
||||
|
||||
def action_refund(self):
|
||||
"""Create a credit note and sync refund to WooCommerce."""
|
||||
self.ensure_one()
|
||||
if self.state not in ('received', 'approved'):
|
||||
raise UserError("Return must be received or approved before refunding.")
|
||||
|
||||
invoice = self.order_id.invoice_id
|
||||
if not invoice:
|
||||
raise UserError("No invoice found for this WC order.")
|
||||
|
||||
# Create credit note (reversal)
|
||||
reversal_wizard = self.env['account.move.reversal'].with_context(
|
||||
active_model='account.move',
|
||||
active_ids=[invoice.id],
|
||||
).create({
|
||||
'reason': self.reason or 'WooCommerce Return',
|
||||
'journal_id': invoice.journal_id.id,
|
||||
})
|
||||
reversal_result = reversal_wizard.reverse_moves()
|
||||
|
||||
credit_note = False
|
||||
if reversal_result and reversal_result.get('res_id'):
|
||||
credit_note = self.env['account.move'].browse(reversal_result['res_id'])
|
||||
elif reversal_result and reversal_result.get('domain'):
|
||||
credit_note = self.env['account.move'].search(
|
||||
reversal_result['domain'], limit=1,
|
||||
)
|
||||
|
||||
if credit_note:
|
||||
credit_note.action_post()
|
||||
|
||||
# Sync refund to WC
|
||||
try:
|
||||
client = self.instance_id._get_client()
|
||||
# Calculate refund amount from return lines
|
||||
refund_amount = sum(
|
||||
line.quantity * line.product_id.list_price
|
||||
for line in self.line_ids
|
||||
)
|
||||
refund_data = {
|
||||
'amount': str(refund_amount),
|
||||
'reason': self.reason or 'Return/Refund',
|
||||
}
|
||||
# Create WC refund
|
||||
client.post(
|
||||
f'orders/{self.order_id.woo_order_id}/refunds',
|
||||
refund_data,
|
||||
)
|
||||
client.update_order(self.order_id.woo_order_id, {
|
||||
'meta_data': [
|
||||
{'key': '_return_status', 'value': 'refunded'},
|
||||
]
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Failed to sync refund to WC: %s", e)
|
||||
|
||||
self.state = 'refunded'
|
||||
self.instance_id._log_sync(
|
||||
'order', 'odoo_to_woo', self.order_id.sale_order_id.name,
|
||||
'success', f'Refund processed',
|
||||
)
|
||||
|
||||
|
||||
class WooReturnLine(models.Model):
|
||||
_name = 'woo.return.line'
|
||||
_description = 'WooCommerce Return Line'
|
||||
|
||||
return_id = fields.Many2one('woo.return', required=True, ondelete='cascade')
|
||||
product_id = fields.Many2one('product.product', required=True)
|
||||
quantity = fields.Float(default=1.0)
|
||||
reason = fields.Selection([
|
||||
('defective', 'Defective'),
|
||||
('wrong_item', 'Wrong Item'),
|
||||
('not_needed', 'Not Needed'),
|
||||
('damaged', 'Damaged'),
|
||||
('other', 'Other'),
|
||||
])
|
||||
company_id = fields.Many2one(
|
||||
'res.company', default=lambda self: self.env.company,
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class WooShipment(models.Model):
|
||||
_name = 'woo.shipment'
|
||||
_description = 'WooCommerce Shipment'
|
||||
_rec_name = 'tracking_number'
|
||||
|
||||
order_id = fields.Many2one('woo.order', required=True, ondelete='cascade')
|
||||
picking_id = fields.Many2one('stock.picking')
|
||||
carrier_id = fields.Many2one('woo.shipping.carrier')
|
||||
tracking_number = fields.Char()
|
||||
shipped_date = fields.Datetime()
|
||||
is_backorder = fields.Boolean()
|
||||
synced_to_woo = fields.Boolean()
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True, default=lambda self: self.env.company,
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class WooShippingCarrier(models.Model):
|
||||
_name = 'woo.shipping.carrier'
|
||||
_description = 'WooCommerce Shipping Carrier'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
code = fields.Char(required=True)
|
||||
tracking_url = fields.Char(help='Use {tracking} as placeholder')
|
||||
active = fields.Boolean(default=True)
|
||||
@@ -1,88 +0,0 @@
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
SUCCESS_RETENTION_DAYS = 30
|
||||
ERROR_RETENTION_DAYS = 90
|
||||
|
||||
|
||||
class WooSyncLog(models.Model):
|
||||
_name = 'woo.sync.log'
|
||||
_description = 'WooCommerce Sync Log'
|
||||
_order = 'create_date desc'
|
||||
_rec_name = 'record_ref'
|
||||
|
||||
instance_id = fields.Many2one('woo.instance', ondelete='cascade')
|
||||
sync_type = fields.Selection([
|
||||
('product', 'Product'),
|
||||
('order', 'Order'),
|
||||
('invoice', 'Invoice'),
|
||||
('inventory', 'Inventory'),
|
||||
('customer', 'Customer'),
|
||||
])
|
||||
direction = fields.Selection([
|
||||
('odoo_to_woo', 'Odoo \u2192 WooCommerce'),
|
||||
('woo_to_odoo', 'WooCommerce \u2192 Odoo'),
|
||||
])
|
||||
record_ref = fields.Char()
|
||||
state = fields.Selection([
|
||||
('success', 'Success'),
|
||||
('failed', 'Failed'),
|
||||
('conflict', 'Conflict'),
|
||||
])
|
||||
message = fields.Text()
|
||||
company_id = fields.Many2one(
|
||||
'res.company', default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_cleanup_logs(self):
|
||||
"""Purge success/conflict logs older than 30 days, errors older than 90."""
|
||||
now = fields.Datetime.now()
|
||||
cutoff_success = fields.Datetime.subtract(now, days=SUCCESS_RETENTION_DAYS)
|
||||
cutoff_error = fields.Datetime.subtract(now, days=ERROR_RETENTION_DAYS)
|
||||
logs = self.search([
|
||||
'|',
|
||||
'&', ('state', '!=', 'failed'), ('create_date', '<', cutoff_success),
|
||||
'&', ('state', '=', 'failed'), ('create_date', '<', cutoff_error),
|
||||
])
|
||||
count = len(logs)
|
||||
if count:
|
||||
logs.unlink()
|
||||
_logger.info("WooCommerce: purged %d old sync log entries", count)
|
||||
|
||||
def action_purge_old_logs(self):
|
||||
"""Manual purge: delete success logs > 7 days, error logs > 30 days."""
|
||||
self.env['woo.sync.log'].check_access_rights('unlink')
|
||||
now = fields.Datetime.now()
|
||||
cutoff_success = fields.Datetime.subtract(now, days=7)
|
||||
cutoff_error = fields.Datetime.subtract(now, days=30)
|
||||
logs = self.env['woo.sync.log'].search([
|
||||
'|',
|
||||
'&', ('state', '!=', 'failed'), ('create_date', '<', cutoff_success),
|
||||
'&', ('state', '=', 'failed'), ('create_date', '<', cutoff_error),
|
||||
])
|
||||
count = len(logs)
|
||||
logs.unlink()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Sync Logs Purged',
|
||||
'message': f'{count} old log entries deleted.',
|
||||
'type': 'success',
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def action_clear_errors(self):
|
||||
"""Clear all failed sync log entries. Called from dashboard."""
|
||||
self.check_access_rights('unlink')
|
||||
logs = self.search([('state', '=', 'failed')])
|
||||
count = len(logs)
|
||||
logs.unlink()
|
||||
_logger.info("WooCommerce: manually cleared %d error log entries", count)
|
||||
return count
|
||||
@@ -1,53 +0,0 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class WooTaxMap(models.Model):
|
||||
_name = 'woo.tax.map'
|
||||
_description = 'WooCommerce Tax Mapping'
|
||||
_rec_name = 'woo_tax_class_name'
|
||||
|
||||
instance_id = fields.Many2one('woo.instance', required=True, ondelete='cascade')
|
||||
tax_id = fields.Many2one('account.tax', string='Odoo Tax')
|
||||
woo_tax_class = fields.Char(required=True)
|
||||
woo_tax_class_name = fields.Char()
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True, default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lookup helpers (Task 26)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def get_odoo_tax(self, instance, wc_tax_class):
|
||||
"""Return the Odoo account.tax mapped to a WC tax class.
|
||||
|
||||
Args:
|
||||
instance: woo.instance record
|
||||
wc_tax_class: WC tax class slug (e.g. 'standard', 'reduced-rate')
|
||||
|
||||
Returns:
|
||||
account.tax record or empty recordset
|
||||
"""
|
||||
mapping = self.search([
|
||||
('instance_id', '=', instance.id),
|
||||
('woo_tax_class', '=', wc_tax_class),
|
||||
], limit=1)
|
||||
return mapping.tax_id if mapping else self.env['account.tax']
|
||||
|
||||
@api.model
|
||||
def get_wc_tax_class(self, instance, tax_id):
|
||||
"""Return the WC tax class slug mapped to an Odoo tax.
|
||||
|
||||
Args:
|
||||
instance: woo.instance record
|
||||
tax_id: account.tax record id
|
||||
|
||||
Returns:
|
||||
str: WC tax class slug, or empty string if not mapped
|
||||
"""
|
||||
mapping = self.search([
|
||||
('instance_id', '=', instance.id),
|
||||
('tax_id', '=', tax_id),
|
||||
], limit=1)
|
||||
return mapping.woo_tax_class if mapping else ''
|
||||
Reference in New Issue
Block a user