This commit is contained in:
gsinghpal
2026-03-14 12:04:20 -04:00
parent fc3c966484
commit e9cf75ee48
75 changed files with 6991 additions and 873 deletions

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import sync_config
from . import sync_log
from . import sync_warehouse
from . import sync_stock
from . import product_sync_mapping
from . import product_brand_pricing_rule
from . import product_brand
from . import product_template
from . import product_template_attribute_value
from . import product_product
from . import res_partner
from . import stock_move
from . import res_config_settings
from . import stock_picking
from . import account_move
from . import inventory_booking
from . import warehouse_ownership
from . import inter_company_transfer
from . import inventory_discrepancy

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
def _post(self, soft=True):
posted = super()._post(soft=soft)
posted._fi_update_product_costs()
return posted
def _fi_update_product_costs(self):
"""Update product costs from vendor bill lines when bills are posted."""
auto_update = self.env['ir.config_parameter'].sudo().get_param(
'fusion_inventory.auto_update_cost', 'True'
)
if auto_update != 'True':
return
for move in self.filtered(lambda m: m.move_type == 'in_invoice'):
for line in move.invoice_line_ids:
if not line.product_id or line.price_unit <= 0:
continue
variant = line.product_id
old_cost = variant.standard_price
if abs(old_cost - line.price_unit) > 0.001:
variant.standard_price = line.price_unit
_logger.info(
'Cost updated for %s: %.2f -> %.2f (from bill %s)',
variant.display_name, old_cost, line.price_unit,
move.name)
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
x_fi_suggested_price = fields.Float(
string='Suggested Price',
compute='_compute_suggested_price',
help='Selling price based on cost and the product margin percentage')
@api.depends('price_unit', 'product_id')
def _compute_suggested_price(self):
for line in self:
margin = 0
if line.product_id and line.product_id.product_tmpl_id:
margin = line.product_id.product_tmpl_id.x_fi_margin_pct or 0
if 0 < margin < 100 and line.price_unit > 0:
line.x_fi_suggested_price = round(
line.price_unit / (1 - margin / 100), 2)
else:
line.x_fi_suggested_price = line.price_unit

View File

@@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields, api
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionInterCompanyTransfer(models.Model):
_name = 'fusion.inter.company.transfer'
_description = 'Inter-Company Inventory Transfer'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc'
_rec_name = 'display_name'
display_name = fields.Char(compute='_compute_display_name')
config_id = fields.Many2one(
'fusion.sync.config', string='Remote Instance',
required=True, ondelete='restrict',
domain=[('state', '=', 'connected')])
product_id = fields.Many2one(
'product.product', string='Product',
required=True, ondelete='restrict')
product_mapping_id = fields.Many2one(
'fusion.product.sync.mapping', string='Product Mapping',
compute='_compute_product_mapping', store=True)
quantity = fields.Float(string='Quantity', required=True, default=1.0)
state = fields.Selection([
('draft', 'Draft'),
('requested', 'Requested'),
('so_created', 'Remote SO Created'),
('po_created', 'Local PO Created'),
('invoiced', 'Invoiced'),
('transferred', 'Transferred'),
('done', 'Done'),
('error', 'Error'),
('cancelled', 'Cancelled'),
], string='Status', default='draft', required=True,
tracking=True, index=True)
remote_so_id = fields.Integer(string='Remote SO ID', readonly=True)
remote_so_name = fields.Char(string='Remote SO Reference', readonly=True)
local_po_id = fields.Many2one(
'purchase.order', string='Local PO', readonly=True)
remote_invoice_id = fields.Integer(string='Remote Invoice ID', readonly=True)
local_bill_id = fields.Many2one(
'account.move', string='Local Vendor Bill', readonly=True)
task_id = fields.Many2one(
'fusion.technician.task', string='Delivery Task', readonly=True)
warehouse_inventory_id = fields.Many2one(
'fusion.warehouse.inventory', string='Warehouse Record', readonly=True)
error_step = fields.Char(string='Failed Step', readonly=True)
requested_by = fields.Many2one('res.users', string='Requested By',
default=lambda self: self.env.uid)
notes = fields.Text(string='Notes')
@api.depends('product_id', 'config_id')
def _compute_display_name(self):
for rec in self:
product = rec.product_id.name or 'No Product'
remote = rec.config_id.name or 'No Remote'
rec.display_name = f'{product} <- {remote}'
@api.depends('product_id', 'config_id')
def _compute_product_mapping(self):
Mapping = self.env['fusion.product.sync.mapping']
for rec in self:
if rec.product_id and rec.config_id:
mapping = Mapping.search([
('config_id', '=', rec.config_id.id),
('local_product_id', '=', rec.product_id.product_tmpl_id.id),
], limit=1)
rec.product_mapping_id = mapping.id if mapping else False
else:
rec.product_mapping_id = False
# ── Manual Step-by-Step Actions (preserved) ──
def action_request(self):
for rec in self.filtered(lambda r: r.state == 'draft'):
if not rec.product_mapping_id:
raise UserError(
f'No sync mapping found for {rec.product_id.name} '
f'on {rec.config_id.name}. Sync products first.')
rec.state = 'requested'
def action_create_remote_so(self):
for rec in self.filtered(lambda r: r.state == 'requested'):
try:
partner_name = (
rec.config_id.local_company_name
or rec.env.company.name)
remote_so_id, so_name = rec.config_id._create_remote_sale_order(
rec.product_mapping_id, rec.quantity, partner_name)
rec.write({
'remote_so_id': remote_so_id,
'remote_so_name': so_name,
'state': 'so_created',
})
_logger.info('Remote SO %s created for transfer %s',
so_name, rec.display_name)
except Exception as e:
raise UserError(f'Failed to create remote SO: {e}')
def action_create_local_po(self):
for rec in self.filtered(lambda r: r.state == 'so_created'):
partner = rec.config_id.remote_partner_id
if not partner:
partner = self.env['res.partner'].search([
('name', 'ilike', rec.config_id.name),
], limit=1)
if not partner:
partner = self.env['res.partner'].create({
'name': rec.config_id.name,
'supplier_rank': 1,
})
po = self.env['purchase.order'].create({
'partner_id': partner.id,
'order_line': [(0, 0, {
'product_id': rec.product_id.id,
'product_qty': rec.quantity,
'price_unit': rec.product_id.standard_price,
})],
'origin': f'ICT from {rec.config_id.name} (SO {rec.remote_so_name or rec.remote_so_id})',
})
po.button_confirm()
rec.write({
'local_po_id': po.id,
'state': 'po_created',
})
_logger.info('Local PO %s created for transfer %s',
po.name, rec.display_name)
def action_create_invoice(self):
for rec in self.filtered(lambda r: r.state == 'po_created'):
try:
remote_inv_id = rec.config_id._create_remote_invoice(rec.remote_so_id)
rec.write({
'remote_invoice_id': remote_inv_id or 0,
'state': 'invoiced',
})
except Exception as e:
_logger.warning('Remote invoice creation failed: %s', e)
rec.state = 'invoiced'
def action_create_vendor_bill(self):
"""Create a local vendor bill from the PO (draft, for accounting review)."""
for rec in self.filtered(lambda r: r.state == 'invoiced' and r.local_po_id):
if rec.local_bill_id:
continue
action = rec.local_po_id.action_create_invoice()
if action and action.get('res_id'):
rec.local_bill_id = action['res_id']
else:
bills = self.env['account.move'].search([
('purchase_order_count', '>', 0),
('ref', 'ilike', rec.local_po_id.name),
], limit=1, order='id desc')
if bills:
rec.local_bill_id = bills.id
def action_create_delivery_task(self):
for rec in self.filtered(lambda r: r.state in ('po_created', 'invoiced')):
try:
task = self.env['fusion.technician.task'].create({
'name': f'Warehouse Transfer: {rec.product_id.name} x{rec.quantity}',
'task_type': 'delivery',
'description': (
f'Transfer {rec.quantity}x {rec.product_id.name} '
f'from {rec.config_id.name} shared warehouse.\n'
f'Remote SO: {rec.remote_so_name or rec.remote_so_id}\n'
f'Local PO: {rec.local_po_id.name if rec.local_po_id else "N/A"}'
),
})
rec.task_id = task.id
except Exception as e:
_logger.warning('Task creation failed (fusion_tasks may not be configured): %s', e)
def action_mark_transferred(self):
for rec in self.filtered(lambda r: r.state == 'invoiced'):
if rec.warehouse_inventory_id:
rec.warehouse_inventory_id.action_mark_transferred()
rec.state = 'transferred'
def action_complete(self):
for rec in self.filtered(lambda r: r.state == 'transferred'):
rec.state = 'done'
def action_cancel(self):
for rec in self.filtered(lambda r: r.state not in ('done', 'cancelled')):
if rec.warehouse_inventory_id:
rec.warehouse_inventory_id.action_release()
rec.state = 'cancelled'
# ── One-Click Automated Transfer ──
def action_execute_transfer(self):
"""Execute all transfer steps in one click:
1. Create SO on remote
2. Create Invoice on remote
3. Create PO locally
4. Confirm PO
5. Create Vendor Bill (draft)
6. Mark done
"""
for rec in self:
if rec.state not in ('draft', 'requested'):
continue
if not rec.product_mapping_id:
rec.write({
'state': 'error',
'error_step': 'validation',
'notes': (rec.notes or '') + '\nNo sync mapping found. Sync products first.',
})
continue
config = rec.config_id
partner_name = config.local_company_name or rec.env.company.name
# Step 1: Remote SO
try:
remote_so_id, so_name = config._create_remote_sale_order(
rec.product_mapping_id, rec.quantity, partner_name)
rec.write({
'remote_so_id': remote_so_id,
'remote_so_name': so_name,
'state': 'so_created',
})
rec.message_post(body=f'Remote SO {so_name} created.')
except Exception as e:
rec.write({
'state': 'error',
'error_step': 'remote_so',
'notes': (rec.notes or '') + f'\nFailed at Remote SO: {e}',
})
_logger.error('ICT %s: Remote SO failed: %s', rec.id, e)
continue
# Step 2: Remote Invoice
try:
remote_inv_id = config._create_remote_invoice(remote_so_id)
rec.write({
'remote_invoice_id': remote_inv_id or 0,
})
rec.message_post(body=f'Remote Invoice created (ID: {remote_inv_id}).')
except Exception as e:
_logger.warning('ICT %s: Remote Invoice failed (non-fatal): %s', rec.id, e)
rec.message_post(body=f'Remote Invoice skipped: {e}')
# Step 3: Local PO
try:
partner = config.remote_partner_id
if not partner:
partner = self.env['res.partner'].search([
('name', 'ilike', config.name),
], limit=1)
if not partner:
partner = self.env['res.partner'].create({
'name': config.name,
'supplier_rank': 1,
})
po = self.env['purchase.order'].create({
'partner_id': partner.id,
'order_line': [(0, 0, {
'product_id': rec.product_id.id,
'product_qty': rec.quantity,
'price_unit': rec.product_id.standard_price,
})],
'origin': f'ICT from {config.name} (SO {rec.remote_so_name or rec.remote_so_id})',
})
po.button_confirm()
rec.write({
'local_po_id': po.id,
'state': 'po_created',
})
rec.message_post(body=f'Local PO {po.name} created and confirmed.')
except Exception as e:
rec.write({
'state': 'error',
'error_step': 'local_po',
'notes': (rec.notes or '') + f'\nFailed at Local PO: {e}',
})
_logger.error('ICT %s: Local PO failed: %s', rec.id, e)
continue
# Step 4: Local Vendor Bill (draft)
try:
action = po.action_create_invoice()
if action and action.get('res_id'):
rec.local_bill_id = action['res_id']
rec.message_post(body='Local Vendor Bill created (draft).')
except Exception as e:
_logger.warning('ICT %s: Vendor bill creation failed (non-fatal): %s', rec.id, e)
# Step 5: Done
rec.write({
'state': 'done',
})
rec.message_post(body='Transfer completed automatically.')
_logger.info('ICT %s: One-click transfer completed', rec.id)
def action_retry(self):
"""Reset an errored transfer back to draft for retry."""
for rec in self.filtered(lambda r: r.state == 'error'):
rec.write({
'state': 'draft',
'error_step': False,
})

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from datetime import timedelta
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionInventoryBooking(models.Model):
_name = 'fusion.inventory.booking'
_description = 'Inventory Product Booking'
_order = 'create_date desc'
_rec_name = 'display_name'
product_id = fields.Many2one(
'product.product', string='Product',
required=True, ondelete='cascade', index=True)
product_tmpl_id = fields.Many2one(
related='product_id.product_tmpl_id', store=True)
user_id = fields.Many2one(
'res.users', string='Booked By',
required=True, default=lambda self: self.env.uid, index=True)
quantity = fields.Float(string='Quantity', default=1.0, required=True)
expiry_datetime = fields.Datetime(
string='Expires At', required=True,
default=lambda self: fields.Datetime.now() + timedelta(hours=24))
state = fields.Selection([
('active', 'Active'),
('expired', 'Expired'),
('released', 'Released'),
], string='Status', default='active', required=True, index=True)
notes = fields.Text(string='Notes')
display_name = fields.Char(compute='_compute_display_name')
@api.depends('product_id', 'user_id', 'state')
def _compute_display_name(self):
for rec in self:
rec.display_name = f'{rec.product_id.name} - {rec.user_id.name} ({rec.state})'
@api.model
def create(self, vals):
if 'expiry_datetime' not in vals:
hold_hours = int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_inventory.booking_hold_hours', '24') or 24)
vals['expiry_datetime'] = fields.Datetime.now() + timedelta(hours=hold_hours)
return super().create(vals)
def action_release(self):
self.write({'state': 'released'})
@api.model
def _cron_expire_bookings(self):
expired = self.search([
('state', '=', 'active'),
('expiry_datetime', '<', fields.Datetime.now()),
])
if expired:
expired.write({'state': 'expired'})
_logger.info('Expired %d inventory bookings', len(expired))
@api.model
def get_booked_qty(self, product_id):
bookings = self.search([
('product_id', '=', product_id),
('state', '=', 'active'),
])
return sum(bookings.mapped('quantity'))

View File

@@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import re
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionInventoryDiscrepancy(models.Model):
_name = 'fusion.inventory.discrepancy'
_description = 'Inventory Discrepancy'
_inherit = ['mail.thread']
_order = 'scan_date desc'
_rec_name = 'display_name'
display_name = fields.Char(compute='_compute_display_name')
product_id = fields.Many2one(
'product.product', string='Product',
required=True, ondelete='cascade', index=True)
expected_qty = fields.Float(string='Expected Quantity')
actual_qty = fields.Float(string='Actual Quantity')
difference = fields.Float(
string='Difference', compute='_compute_difference', store=True)
discrepancy_type = fields.Selection([
('qty_mismatch', 'Quantity Mismatch'),
('missing_serial', 'Missing Serial Number'),
('orphan_serial', 'Orphaned Serial (no quant)'),
('untracked_serial', 'Serial in Notes (not in system)'),
], string='Type', required=True, index=True)
missing_serials = fields.Text(
string='Serial Numbers',
help='Serial numbers that were found/missing')
source = fields.Char(
string='Source',
help='Where the discrepancy was detected')
state = fields.Selection([
('detected', 'Detected'),
('reviewed', 'Reviewed'),
('resolved', 'Resolved'),
('ignored', 'Ignored'),
], string='Status', default='detected', required=True,
tracking=True, index=True)
scan_date = fields.Datetime(
string='Scan Date', default=fields.Datetime.now, required=True)
reviewed_by = fields.Many2one('res.users', string='Reviewed By')
resolution_notes = fields.Text(string='Resolution Notes')
@api.depends('product_id', 'discrepancy_type')
def _compute_display_name(self):
for rec in self:
product = rec.product_id.name or 'Unknown'
dtype = dict(rec._fields['discrepancy_type'].selection).get(
rec.discrepancy_type, '')
rec.display_name = f'{product} - {dtype}'
@api.depends('expected_qty', 'actual_qty')
def _compute_difference(self):
for rec in self:
rec.difference = rec.actual_qty - rec.expected_qty
def action_mark_reviewed(self):
self.write({
'state': 'reviewed',
'reviewed_by': self.env.uid,
})
def action_mark_resolved(self):
self.write({'state': 'resolved'})
def action_ignore(self):
self.write({'state': 'ignored'})
@api.model
def _cron_scan_discrepancies(self):
"""Scheduled scan: detect inventory discrepancies and missing serials."""
_logger.info('Starting inventory discrepancy scan...')
count = 0
count += self._scan_serial_discrepancies()
count += self._scan_quantity_discrepancies()
_logger.info('Discrepancy scan complete: %d issues found', count)
def _scan_serial_discrepancies(self):
"""Check for serial numbers in SO/invoice notes that don't exist in stock.lot."""
count = 0
serial_pattern = re.compile(r'(?<!\w)([\w][\w-]{3,}[\w])(?!\w)')
recent_moves = self.env['account.move'].search([
('move_type', 'in', ('out_invoice', 'in_invoice')),
('state', '=', 'posted'),
('invoice_date', '>=', fields.Date.today()),
], limit=200)
for move in recent_moves:
for line in move.invoice_line_ids:
if not line.product_id:
continue
text_sources = []
if line.name:
text_sources.append(line.name)
for text in text_sources:
clean = re.sub(r'<[^>]+>', ' ', text)
candidates = serial_pattern.findall(clean)
for candidate in candidates:
if len(candidate) < 5:
continue
if candidate.lower() in ('total', 'price', 'quantity',
'subtotal', 'discount', 'amount',
'invoice', 'order', 'product'):
continue
existing = self.env['stock.lot'].search([
('name', '=ilike', candidate),
('product_id', '=', line.product_id.id),
], limit=1)
if not existing:
already_reported = self.search([
('product_id', '=', line.product_id.id),
('missing_serials', 'ilike', candidate),
('state', 'in', ('detected', 'reviewed')),
], limit=1)
if not already_reported:
self.create({
'product_id': line.product_id.id,
'discrepancy_type': 'untracked_serial',
'missing_serials': candidate,
'source': f'Invoice {move.name}, line: {line.name[:80]}',
'expected_qty': 0,
'actual_qty': 0,
})
count += 1
return count
def _scan_quantity_discrepancies(self):
"""Compare stock.quant quantities against expected levels."""
count = 0
tracked_products = self.env['product.product'].search([
('type', '=', 'product'),
('tracking', '!=', 'none'),
], limit=500)
for product in tracked_products:
lots = self.env['stock.lot'].search([
('product_id', '=', product.id),
])
quants = self.env['stock.quant'].search([
('product_id', '=', product.id),
('location_id.usage', '=', 'internal'),
])
lot_ids_with_quant = set(quants.mapped('lot_id').ids)
for lot in lots:
if lot.id not in lot_ids_with_quant:
already = self.search([
('product_id', '=', product.id),
('discrepancy_type', '=', 'orphan_serial'),
('missing_serials', '=', lot.name),
('state', 'in', ('detected', 'reviewed')),
], limit=1)
if not already:
self.create({
'product_id': product.id,
'discrepancy_type': 'orphan_serial',
'missing_serials': lot.name,
'source': 'Automated scan: lot exists without stock quant',
'expected_qty': 1,
'actual_qty': 0,
})
count += 1
return count

View File

@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class ProductBrand(models.Model):
_name = 'product.brand'
_description = 'Product Brand / Manufacturer'
_order = 'name'
_rec_name = 'name'
_parent_name = 'parent_id'
name = fields.Char(string='Brand Name', required=True, index=True)
active = fields.Boolean(default=True)
logo = fields.Image(string='Logo', max_width=1024, max_height=1024)
parent_id = fields.Many2one(
'product.brand', string='Parent Brand', index=True,
ondelete='cascade',
help='If set, this brand is a sub-brand of the parent.')
child_ids = fields.One2many(
'product.brand', 'parent_id', string='Sub-Brands')
child_count = fields.Integer(
string='Sub-Brands', compute='_compute_child_count')
partner_id = fields.Many2one(
'res.partner', string='Manufacturer / Vendor',
domain="[('supplier_rank', '>', 0)]",
help='The vendor or manufacturer company this brand belongs to.')
# Brand-level default pricing (backward-compatible fallback)
primary_discount_pct = fields.Float(
string='Primary Discount (%)',
help='First-tier discount off MSRP. E.g. 40 means 40%% off.')
secondary_discount_pct = fields.Float(
string='Secondary Discount (%)',
help='Second-tier discount applied after the primary. '
'E.g. 30 means 30%% off the primary-discounted price.')
net_discount_pct = fields.Float(
string='Effective Discount (%)',
compute='_compute_net_discount', store=True,
help='Combined effect of both discount tiers.')
pricing_rule_ids = fields.One2many(
'product.brand.pricing.rule', 'brand_id',
string='Pricing Rules')
notes = fields.Text(string='Pricing Notes')
product_ids = fields.Many2many(
'product.template', 'product_template_brand_rel',
'brand_id', 'product_tmpl_id',
string='Products')
product_count = fields.Integer(
string='Products', compute='_compute_product_count')
@api.depends('primary_discount_pct', 'secondary_discount_pct')
def _compute_net_discount(self):
for brand in self:
p = brand.primary_discount_pct or 0.0
s = brand.secondary_discount_pct or 0.0
remaining = (1 - p / 100) * (1 - s / 100)
brand.net_discount_pct = round((1 - remaining) * 100, 2)
def _compute_product_count(self):
for brand in self:
brand.product_count = len(brand.product_ids)
def _compute_child_count(self):
for brand in self:
brand.child_count = len(brand.child_ids)
@api.constrains('parent_id')
def _check_parent_recursion(self):
if not self._check_recursion():
raise models.ValidationError('A brand cannot be its own parent.')
def get_pricing_for_product(self, product_tmpl):
"""Walk the pricing cascade and return a matching rule, or None for brand default.
Resolution order (first match wins):
1. Rule scoped to this specific product
2. Rule scoped to the product's category
3. Rule scoped to "all products"
4. None -> caller uses brand's own primary/secondary defaults
5. If sub-brand with no pricing at all, delegate to parent
"""
self.ensure_one()
rules = self.pricing_rule_ids.filtered('active').sorted('sequence')
for rule in rules:
if rule.apply_on == 'product' and rule.product_tmpl_id == product_tmpl:
return rule
categ = product_tmpl.categ_id if product_tmpl else False
for rule in rules:
if rule.apply_on == 'category' and rule.categ_id == categ:
return rule
for rule in rules:
if rule.apply_on == 'all':
return rule
has_own_pricing = (self.primary_discount_pct or self.secondary_discount_pct)
if has_own_pricing:
return None
if self.parent_id:
return self.parent_id.get_pricing_for_product(product_tmpl)
return None
def calculate_cost_from_msrp(self, msrp, product_tmpl=None):
"""Return expected purchase cost for a given MSRP.
If product_tmpl is provided, uses the pricing cascade to find the
best-matching rule. Otherwise falls back to brand-level defaults.
"""
self.ensure_one()
if product_tmpl:
rule = self.get_pricing_for_product(product_tmpl)
if rule:
return rule.calculate_cost(msrp)
p = self.primary_discount_pct or 0.0
s = self.secondary_discount_pct or 0.0
return round(msrp * (1 - p / 100) * (1 - s / 100), 2)
def action_view_products(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': self.name,
'res_model': 'product.template',
'view_mode': 'list,form',
'domain': [('x_fi_brand_ids', 'in', self.id)],
'context': {'default_x_fi_brand_ids': [(4, self.id)]},
}
def action_view_sub_brands(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'{self.name} - Sub-Brands',
'res_model': 'product.brand',
'view_mode': 'list,form',
'domain': [('parent_id', '=', self.id)],
'context': {'default_parent_id': self.id},
}

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class ProductBrandPricingRule(models.Model):
_name = 'product.brand.pricing.rule'
_description = 'Brand Pricing Rule'
_order = 'sequence, id'
brand_id = fields.Many2one(
'product.brand', string='Brand', required=True,
ondelete='cascade', index=True)
name = fields.Char(
string='Description', required=True,
help='Short label for this rule, e.g. "Patient Lifts" or "Special Contract".')
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
apply_on = fields.Selection([
('all', 'All Products'),
('category', 'Product Category'),
('product', 'Specific Product'),
], string='Applies To', default='all', required=True)
categ_id = fields.Many2one(
'product.category', string='Product Category',
help='Only used when Applies To = Product Category.')
product_tmpl_id = fields.Many2one(
'product.template', string='Product',
help='Only used when Applies To = Specific Product.')
pricing_method = fields.Selection([
('tiered_pct', 'Primary + Secondary %'),
('flat_pct', 'Single Discount %'),
('fixed_rebate', 'Fixed $ Rebate off MSRP'),
('fixed_cost', 'Flat Cost Price'),
], string='Pricing Method', default='tiered_pct', required=True)
primary_discount_pct = fields.Float(string='Primary Discount (%)')
secondary_discount_pct = fields.Float(string='Secondary Discount (%)')
flat_discount_pct = fields.Float(string='Discount (%)')
fixed_rebate_amount = fields.Float(string='Rebate Amount ($)')
fixed_cost_price = fields.Float(string='Cost Price ($)')
net_discount_pct = fields.Float(
string='Effective Discount (%)',
compute='_compute_net_discount', store=True,
help='Combined effective discount shown as a single percentage.')
@api.depends('pricing_method', 'primary_discount_pct',
'secondary_discount_pct', 'flat_discount_pct')
def _compute_net_discount(self):
for rule in self:
if rule.pricing_method == 'tiered_pct':
p = rule.primary_discount_pct or 0.0
s = rule.secondary_discount_pct or 0.0
remaining = (1 - p / 100) * (1 - s / 100)
rule.net_discount_pct = round((1 - remaining) * 100, 2)
elif rule.pricing_method == 'flat_pct':
rule.net_discount_pct = rule.flat_discount_pct or 0.0
else:
rule.net_discount_pct = 0.0
def calculate_cost(self, msrp):
"""Return the expected purchase cost for a given MSRP using this rule's method."""
self.ensure_one()
if self.pricing_method == 'tiered_pct':
p = self.primary_discount_pct or 0.0
s = self.secondary_discount_pct or 0.0
return round(msrp * (1 - p / 100) * (1 - s / 100), 2)
if self.pricing_method == 'flat_pct':
d = self.flat_discount_pct or 0.0
return round(msrp * (1 - d / 100), 2)
if self.pricing_method == 'fixed_rebate':
return round(max(msrp - (self.fixed_rebate_amount or 0.0), 0), 2)
if self.pricing_method == 'fixed_cost':
return self.fixed_cost_price or 0.0
return msrp

View File

@@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class ProductProduct(models.Model):
_inherit = 'product.product'
x_fi_price_offset = fields.Float(
string='Price Offset',
default=0.0,
help='Margin-calculated price adjustment, kept separate from '
'manual attribute price extras.')
x_fi_variant_margin_pct = fields.Float(
string='Margin (%)',
compute='_compute_variant_margin',
inverse='_inverse_variant_margin',
store=True, readonly=False,
help='Profit margin based on (cost + shipping + extra cost). '
'Extra Price from attributes is added on top, outside margin.')
x_fi_margin_override = fields.Boolean(
string='Margin Override',
default=False,
help='When set, the template "Apply Margin" button '
'and automatic propagation skip this variant.')
x_fi_variant_profit = fields.Float(
string='Profit',
compute='_compute_variant_profit',
help='Total sale price minus total cost.')
x_fi_shipping_cost = fields.Float(
string='Shipping Cost',
default=0.0,
help='Per-unit shipping cost.')
x_fi_cost_extra = fields.Float(
string='Attribute Extra Cost',
compute='_compute_cost_extra', store=True,
help='Sum of Extra Cost from all attribute values for this variant.')
@api.depends('product_template_attribute_value_ids.x_fi_extra_cost')
def _compute_cost_extra(self):
for product in self:
product.x_fi_cost_extra = sum(
product.product_template_attribute_value_ids.mapped(
'x_fi_extra_cost'))
# ── Include price offset in pricelist / SO pricing ──
def _get_attributes_extra_price(self):
extra = super()._get_attributes_extra_price()
return extra + (self.x_fi_price_offset or 0.0)
# ── Override lst_price to include our price offset ──
@api.depends('list_price', 'price_extra', 'x_fi_price_offset')
@api.depends_context('uom')
def _compute_product_lst_price(self):
to_uom = None
if 'uom' in self._context:
to_uom = self.env['uom.uom'].browse(self._context['uom'])
for product in self:
if to_uom:
list_price = product.uom_id._compute_price(
product.list_price, to_uom)
else:
list_price = product.list_price
product.lst_price = (
list_price
+ product.price_extra
+ (product.x_fi_price_offset or 0.0))
def _set_product_lst_price(self):
for product in self:
if self._context.get('uom'):
value = (
float(product.lst_price) * product.uom_id.factor
/ self.env['uom.uom'].browse(
self._context['uom']).factor)
else:
value = product.lst_price
value -= product.price_extra
value -= (product.x_fi_price_offset or 0.0)
product.write({'list_price': value})
# ── Margin / Profit ──
# effective_cost = standard_price + shipping + extra_cost
# base_sale_price = list_price + x_fi_price_offset (margin applies here)
# final_price = base_sale_price + price_extra (surcharge on top)
# margin = (base_sale_price - effective_cost) / base_sale_price
@api.depends('list_price', 'price_extra', 'x_fi_price_offset',
'standard_price', 'x_fi_shipping_cost', 'x_fi_cost_extra')
def _compute_variant_margin(self):
for var in self:
base = var.list_price + (var.x_fi_price_offset or 0.0)
eff = (var.standard_price
+ (var.x_fi_shipping_cost or 0.0)
+ (var.x_fi_cost_extra or 0.0))
if base > 0 and eff > 0:
var.x_fi_variant_margin_pct = round(
((base - eff) / base) * 100, 2)
else:
var.x_fi_variant_margin_pct = 0.0
def _inverse_variant_margin(self):
for var in self:
margin = var.x_fi_variant_margin_pct or 0.0
eff = (var.standard_price
+ (var.x_fi_shipping_cost or 0.0)
+ (var.x_fi_cost_extra or 0.0))
if eff > 0 and 0 < margin < 100:
base = round(eff / (1 - margin / 100), 2)
var.x_fi_price_offset = round(base - var.list_price, 2)
@api.depends('list_price', 'price_extra', 'x_fi_price_offset',
'standard_price', 'x_fi_shipping_cost', 'x_fi_cost_extra')
def _compute_variant_profit(self):
for var in self:
lst = (var.list_price
+ var.price_extra
+ (var.x_fi_price_offset or 0.0))
eff = (var.standard_price
+ (var.x_fi_shipping_cost or 0.0)
+ (var.x_fi_cost_extra or 0.0))
var.x_fi_variant_profit = lst - eff
# ── Onchange handlers ──
@api.onchange('x_fi_variant_margin_pct')
def _onchange_variant_margin(self):
for var in self:
margin = var.x_fi_variant_margin_pct or 0.0
eff = (var.standard_price
+ (var.x_fi_shipping_cost or 0.0)
+ (var.x_fi_cost_extra or 0.0))
if eff > 0 and 0 < margin < 100:
base = round(eff / (1 - margin / 100), 2)
var.x_fi_price_offset = round(base - var.list_price, 2)
@api.onchange('standard_price', 'x_fi_shipping_cost')
def _onchange_variant_cost(self):
"""When cost or shipping changes, recalculate price to keep margin."""
for var in self:
margin = var.x_fi_variant_margin_pct or 0.0
eff = (var.standard_price
+ (var.x_fi_shipping_cost or 0.0)
+ (var.x_fi_cost_extra or 0.0))
if eff > 0 and 0 < margin < 100:
base = round(eff / (1 - margin / 100), 2)
var.x_fi_price_offset = round(base - var.list_price, 2)
def _apply_margin_to_variant(self, margin_pct):
"""Set this variant's price offset to achieve the given margin."""
self.ensure_one()
eff = (self.standard_price
+ (self.x_fi_shipping_cost or 0.0)
+ (self.x_fi_cost_extra or 0.0))
if eff <= 0 or margin_pct <= 0 or margin_pct >= 100:
return
base = round(eff / (1 - margin_pct / 100), 2)
new_offset = round(base - self.list_price, 2)
if abs((self.x_fi_price_offset or 0.0) - new_offset) > 0.001:
self.x_fi_price_offset = new_offset

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionProductSyncMapping(models.Model):
_name = 'fusion.product.sync.mapping'
_description = 'Product Sync Mapping'
_rec_name = 'remote_product_name'
_order = 'remote_product_name'
config_id = fields.Many2one('fusion.sync.config', string='Sync Config',
required=True, ondelete='cascade')
local_product_id = fields.Many2one('product.template', string='Local Product',
help='The matching product in this Odoo instance')
auto_matched = fields.Boolean(string='Auto-Matched',
help='True if the product was automatically matched by SKU or name')
remote_product_id = fields.Integer(string='Remote Product ID', index=True)
remote_product_name = fields.Char(string='Remote Product Name')
remote_default_code = fields.Char(string='Remote SKU/Reference')
remote_barcode = fields.Char(string='Remote Barcode')
remote_list_price = fields.Float(string='Remote Price')
remote_category = fields.Char(string='Remote Category')
remote_qty_available = fields.Float(
string='Remote On Hand',
compute='_compute_remote_totals', store=True, readonly=True)
remote_qty_forecast = fields.Float(
string='Remote Forecast',
compute='_compute_remote_totals', store=True, readonly=True)
last_stock_sync = fields.Datetime(string='Stock Last Updated', readonly=True)
stock_line_ids = fields.One2many(
'fusion.sync.stock', 'mapping_id', string='Stock by Warehouse')
owner_config_id = fields.Many2one(
'fusion.sync.config', string='Owner Instance',
help='Which instance owns this inventory in the shared warehouse')
@api.depends('stock_line_ids.qty_available', 'stock_line_ids.qty_forecast')
def _compute_remote_totals(self):
for mapping in self:
lines = mapping.stock_line_ids
mapping.remote_qty_available = sum(lines.mapped('qty_available'))
mapping.remote_qty_forecast = sum(lines.mapped('qty_forecast'))
_sql_constraints = [
('unique_remote_product',
'UNIQUE(config_id, remote_product_id)',
'Each remote product can only be mapped once per sync configuration.'),
]

View File

@@ -0,0 +1,356 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
CASE_CONVERSION_OPTIONS = [
('none', 'No Conversion'),
('upper', 'UPPERCASE'),
('sentence', 'Sentence case'),
('capitalized', 'Capitalized Case'),
('lower', 'lowercase'),
]
class ProductTemplate(models.Model):
_inherit = 'product.template'
# ── Inventory Sync (from fusion_inventory_sync) ──
sync_mapping_ids = fields.One2many(
'fusion.product.sync.mapping', 'local_product_id',
string='Remote Inventory Links')
remote_qty_available = fields.Float(
string='Remote On Hand',
compute='_compute_remote_stock',
help='Total on-hand quantity at remote locations')
remote_qty_forecast = fields.Float(
string='Remote Forecast',
compute='_compute_remote_stock',
help='Total forecasted quantity at remote locations')
has_remote_mapping = fields.Boolean(
string='Has Remote Link',
compute='_compute_remote_stock')
# ── Margin / Profit ──
x_fi_margin_pct = fields.Float(
string='Margin (%)',
compute='_compute_margin_pct',
inverse='_inverse_margin_pct',
store=True, readonly=False,
help='Profit margin as percentage of sale price. '
'Entering a margin auto-calculates the sale price; '
'entering a sale price auto-calculates the margin.')
x_fi_profit_amount = fields.Float(
string='Profit',
compute='_compute_profit',
help='Sale Price minus Cost (at the template level).')
x_fi_shipping_cost = fields.Float(
string='Shipping Cost',
default=0.0,
help='Per-unit shipping cost. Saved here and automatically '
'distributed to all variants on save.')
# ── Brand / Vendor ──
x_fi_brand_ids = fields.Many2many(
'product.brand', 'product_template_brand_rel',
'product_tmpl_id', 'brand_id',
string='Brand(s)')
x_fi_expected_cost = fields.Float(
string='Expected Cost',
compute='_compute_expected_cost',
help='Estimated purchase cost calculated from Sales Price '
'and the primary brand discount tiers.')
# ── Case Conversion ──
x_fi_case_conversion = fields.Selection(
CASE_CONVERSION_OPTIONS,
string='Name Case',
default='none',
help='Convert this product name to the selected case. '
'Global setting in Inventory Settings overrides this.')
# ── Purchase History (computed link to vendor bill lines) ──
x_fi_purchase_history_ids = fields.Many2many(
'account.move.line',
compute='_compute_purchase_history',
string='Purchase History')
x_fi_purchase_history_count = fields.Integer(
compute='_compute_purchase_history',
string='Bill Lines')
# ────────────────────── Computed Methods ──────────────────────
@api.depends('sync_mapping_ids', 'sync_mapping_ids.remote_qty_available',
'sync_mapping_ids.remote_qty_forecast')
def _compute_remote_stock(self):
for product in self:
mappings = product.sync_mapping_ids
product.remote_qty_available = sum(mappings.mapped('remote_qty_available'))
product.remote_qty_forecast = sum(mappings.mapped('remote_qty_forecast'))
product.has_remote_mapping = bool(mappings)
@api.depends('list_price', 'standard_price')
def _compute_profit(self):
for rec in self:
rec.x_fi_profit_amount = rec.list_price - rec.standard_price
@api.depends('list_price', 'categ_id',
'x_fi_brand_ids', 'x_fi_brand_ids.primary_discount_pct',
'x_fi_brand_ids.secondary_discount_pct',
'x_fi_brand_ids.pricing_rule_ids',
'x_fi_brand_ids.pricing_rule_ids.pricing_method',
'x_fi_brand_ids.pricing_rule_ids.primary_discount_pct',
'x_fi_brand_ids.pricing_rule_ids.secondary_discount_pct',
'x_fi_brand_ids.pricing_rule_ids.flat_discount_pct',
'x_fi_brand_ids.pricing_rule_ids.fixed_rebate_amount',
'x_fi_brand_ids.pricing_rule_ids.fixed_cost_price')
def _compute_expected_cost(self):
for rec in self:
brand = rec.x_fi_brand_ids[:1]
if brand and rec.list_price > 0:
rec.x_fi_expected_cost = brand.calculate_cost_from_msrp(
rec.list_price, product_tmpl=rec)
else:
rec.x_fi_expected_cost = 0.0
def _compute_purchase_history(self):
AML = self.env['account.move.line']
for rec in self:
variant_ids = rec.product_variant_ids.ids
if variant_ids:
lines = AML.search([
('product_id', 'in', variant_ids),
('move_id.move_type', '=', 'in_invoice'),
('move_id.state', '=', 'posted'),
('price_unit', '>', 0),
], order='date desc', limit=500)
rec.x_fi_purchase_history_ids = lines
rec.x_fi_purchase_history_count = len(lines)
else:
rec.x_fi_purchase_history_ids = AML
rec.x_fi_purchase_history_count = 0
# ────────────────────── Margin Compute / Inverse / Onchange ──────────────────────
@api.depends('list_price', 'standard_price')
def _compute_margin_pct(self):
for rec in self:
if rec.list_price > 0 and rec.standard_price > 0:
rec.x_fi_margin_pct = round(
((rec.list_price - rec.standard_price) / rec.list_price) * 100, 2)
else:
rec.x_fi_margin_pct = 0.0
def _inverse_margin_pct(self):
for rec in self:
if rec.standard_price > 0 and 0 < rec.x_fi_margin_pct < 100:
rec.list_price = round(
rec.standard_price / (1 - rec.x_fi_margin_pct / 100), 2)
@api.onchange('list_price', 'standard_price')
def _onchange_price_to_margin(self):
"""Real-time margin recalculation when user changes price or cost."""
for rec in self:
if rec.list_price > 0 and rec.standard_price > 0:
new_margin = round(
((rec.list_price - rec.standard_price) / rec.list_price) * 100, 2)
if abs(rec.x_fi_margin_pct - new_margin) > 0.01:
rec.x_fi_margin_pct = new_margin
elif rec.list_price <= 0:
rec.x_fi_margin_pct = 0.0
@api.onchange('x_fi_margin_pct')
def _onchange_margin_to_price(self):
"""Real-time sale price recalculation when user changes margin."""
for rec in self:
if rec.standard_price > 0 and 0 < rec.x_fi_margin_pct < 100:
new_price = round(
rec.standard_price / (1 - rec.x_fi_margin_pct / 100), 2)
if abs(rec.list_price - new_price) > 0.01:
rec.list_price = new_price
# ────────────────────── Variant Margin Propagation ──────────────────────
def _propagate_margin_to_variants(self):
"""Apply the template margin to variant price offsets (skip overrides)."""
for rec in self:
margin = rec.x_fi_margin_pct or 0.0
if margin <= 0 or margin >= 100:
continue
variants = rec.product_variant_ids.filtered(
lambda v: not v.x_fi_margin_override
and v.standard_price > 0)
for var in variants:
var._apply_margin_to_variant(margin)
def _propagate_shipping_to_variants(self, shipping_cost):
"""Copy the template's shipping cost to all variants."""
for rec in self:
for var in rec.product_variant_ids:
if abs((var.x_fi_shipping_cost or 0.0) - shipping_cost) > 0.001:
var.x_fi_shipping_cost = shipping_cost
def action_apply_margin_to_all_variants(self):
"""Button: apply template margin to every non-overridden variant."""
self.ensure_one()
margin = self.x_fi_margin_pct or 0.0
if margin <= 0 or margin >= 100:
return
variants = self.product_variant_ids.filtered(
lambda v: not v.x_fi_margin_override
and v.standard_price > 0)
for var in variants:
var._apply_margin_to_variant(margin)
skipped = len(self.product_variant_ids.filtered(
lambda v: v.x_fi_margin_override))
msg = f'{len(variants)} variant prices updated to {margin}% margin.'
if skipped:
msg += f' {skipped} overridden variant(s) skipped.'
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Margin Applied',
'message': msg,
'type': 'success',
'sticky': False,
},
}
# ────────────────────── Case Conversion ──────────────────────
@staticmethod
def _apply_case_conversion(name, mode):
if not name or not mode or mode == 'none':
return name
if mode == 'upper':
return name.upper()
if mode == 'lower':
return name.lower()
if mode == 'sentence':
return name[0].upper() + name[1:].lower() if len(name) > 1 else name.upper()
if mode == 'capitalized':
return name.title()
return name
def _get_effective_case_mode(self, vals=None):
global_mode = self.env['ir.config_parameter'].sudo().get_param(
'fusion_inventory.case_conversion', 'none')
if global_mode and global_mode != 'none':
return global_mode
if vals and vals.get('x_fi_case_conversion'):
return vals['x_fi_case_conversion']
if self and self.x_fi_case_conversion:
return self.x_fi_case_conversion
return 'none'
@api.onchange('x_fi_case_conversion')
def _onchange_case_conversion(self):
for rec in self:
mode = rec._get_effective_case_mode()
if mode != 'none' and rec.name:
rec.name = self._apply_case_conversion(rec.name, mode)
@api.model_create_multi
def create(self, vals_list):
global_mode = self.env['ir.config_parameter'].sudo().get_param(
'fusion_inventory.case_conversion', 'none')
for vals in vals_list:
name = vals.get('name', '')
if name:
mode = global_mode if (global_mode and global_mode != 'none') else vals.get('x_fi_case_conversion', 'none')
if mode and mode != 'none':
vals['name'] = self._apply_case_conversion(name, mode)
if global_mode and global_mode != 'none':
vals.setdefault('x_fi_case_conversion', global_mode)
return super().create(vals_list)
def write(self, vals):
res = super().write(vals)
if ('name' in vals or 'x_fi_case_conversion' in vals) and not self.env.context.get('_fi_converting_case'):
for rec in self:
mode = rec._get_effective_case_mode(vals)
if mode != 'none':
converted = self._apply_case_conversion(rec.name, mode)
if converted and converted != rec.name:
rec.with_context(_fi_converting_case=True).write({'name': converted})
if ('x_fi_margin_pct' in vals or 'list_price' in vals) and not self.env.context.get('_fi_propagating'):
self.with_context(_fi_propagating=True)._propagate_margin_to_variants()
if 'x_fi_shipping_cost' in vals:
self._propagate_shipping_to_variants(vals['x_fi_shipping_cost'])
return res
# ────────────────────── Purchase History / Cost Sync ──────────────────────
def action_view_purchase_history(self):
self.ensure_one()
variant_ids = self.product_variant_ids.ids
return {
'type': 'ir.actions.act_window',
'name': f'Purchase History: {self.name}',
'res_model': 'account.move.line',
'view_mode': 'list',
'domain': [
('product_id', 'in', variant_ids),
('move_id.move_type', '=', 'in_invoice'),
('move_id.state', '=', 'posted'),
('price_unit', '>', 0),
],
'context': {'create': False},
'limit': 50,
}
def action_refresh_cost_from_bills(self):
"""Pull the latest non-zero vendor bill price into product cost."""
self._sync_cost_from_latest_bill()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Cost Refreshed',
'message': 'Product cost updated from the latest vendor bill.',
'type': 'success',
'sticky': False,
},
}
def _sync_cost_from_latest_bill(self):
"""For each variant, find the most recent posted vendor bill line
with a non-zero price and set its standard_price."""
AML = self.env['account.move.line']
for rec in self:
for variant in rec.product_variant_ids:
latest = AML.search([
('product_id', '=', variant.id),
('move_id.move_type', '=', 'in_invoice'),
('move_id.state', '=', 'posted'),
('price_unit', '>', 0),
], order='date desc, id desc', limit=1)
if latest and abs(variant.standard_price - latest.price_unit) > 0.001:
old = variant.standard_price
variant.standard_price = latest.price_unit
_logger.info(
'Cost synced for %s: %.2f -> %.2f (bill %s)',
variant.display_name, old, latest.price_unit,
latest.move_id.name)
@api.model
def _cron_sync_all_costs_from_bills(self):
"""Batch job: update every product's cost from latest vendor bill."""
auto_update = self.env['ir.config_parameter'].sudo().get_param(
'fusion_inventory.auto_update_cost', 'True')
if auto_update != 'True':
return
products = self.search([])
products._sync_cost_from_latest_bill()
_logger.info('Batch cost sync complete for %d products.', len(products))

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class ProductTemplateAttributeValue(models.Model):
_inherit = 'product.template.attribute.value'
x_fi_extra_cost = fields.Float(
string='Extra Cost',
default=0.0,
digits='Product Price',
help='Additional cost incurred for this attribute value. '
'Included in the total cost base before margin is applied. '
'Unlike Extra Price, margin IS calculated on this amount.')
x_fi_extra_price_impact = fields.Float(
string='+ Price (from Cost)',
compute='_compute_extra_price_impact',
digits='Product Price',
help='The sale price increase resulting from this Extra Cost '
'after applying the product margin. '
'e.g. $40 extra cost at 50%% margin = +$80 in sale price.')
@api.depends('x_fi_extra_cost', 'product_tmpl_id.x_fi_margin_pct')
def _compute_extra_price_impact(self):
for ptav in self:
cost = ptav.x_fi_extra_cost or 0.0
margin = ptav.product_tmpl_id.x_fi_margin_pct or 0.0
if cost > 0 and 0 < margin < 100:
ptav.x_fi_extra_price_impact = round(
cost / (1 - margin / 100), 2)
else:
ptav.x_fi_extra_price_impact = cost
def write(self, vals):
res = super().write(vals)
if 'x_fi_extra_cost' in vals:
self._recalculate_variant_prices()
return res
def _recalculate_variant_prices(self):
"""When extra cost changes, adjust variant prices to maintain margin."""
ProductProduct = self.env['product.product']
variants = ProductProduct.search([
('product_template_attribute_value_ids', 'in', self.ids)
])
if not variants:
return
variants._compute_cost_extra()
for var in variants:
margin = var.x_fi_variant_margin_pct or 0.0
eff = (var.standard_price
+ (var.x_fi_shipping_cost or 0.0)
+ (var.x_fi_cost_extra or 0.0))
if eff > 0 and 0 < margin < 100:
base = round(eff / (1 - margin / 100), 2)
new_offset = round(base - var.list_price, 2)
if abs((var.x_fi_price_offset or 0.0) - new_offset) > 0.001:
var.x_fi_price_offset = new_offset
_logger.info(
'Extra cost changed -> updated offset for %s: %.2f',
var.display_name, new_offset)

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fi_case_conversion = fields.Selection([
('none', 'No Conversion'),
('upper', 'UPPERCASE'),
('sentence', 'Sentence case'),
('capitalized', 'Capitalized Case'),
('lower', 'lowercase'),
], string='Product Name Case',
config_parameter='fusion_inventory.case_conversion',
default='none',
help='Globally convert all product names to the selected case. '
'Overrides individual product settings and applies to new products.')
fi_auto_update_cost = fields.Boolean(
string='Auto-Update Cost from Vendor Bills',
config_parameter='fusion_inventory.auto_update_cost',
default=True,
help='Automatically update product cost when a vendor bill is confirmed. '
'Uses the latest bill line price (skips zero-price lines).')
fi_default_margin = fields.Float(
string='Default Margin (%)',
config_parameter='fusion_inventory.default_margin',
default=0,
help='Default margin percentage applied to new products.')
fi_booking_hold_hours = fields.Integer(
string='Booking Hold Duration (hours)',
config_parameter='fusion_inventory.booking_hold_hours',
default=24,
help='How many hours a product booking holds before auto-expiring.')
fi_openai_api_key = fields.Char(
string='OpenAI API Key',
config_parameter='fusion_inventory.openai_api_key',
help='API key for OpenAI features (discrepancy analysis, notes parsing). '
'Falls back to Fusion Digitize key if empty.')
@api.model
def get_fi_openai_key(self):
"""Return the effective OpenAI API key, falling back to Fusion Digitize."""
key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_inventory.openai_api_key', ''
)
if not key:
key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_digitize.openai_api_key', ''
)
return key or ''
def action_sync_all_costs_from_bills(self):
"""Pull every product's cost from its latest posted vendor bill line."""
products = self.env['product.template'].sudo().search([])
products._sync_cost_from_latest_bill()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Cost Sync Complete',
'message': f'Checked {len(products)} products against vendor bill history.',
'type': 'success',
'sticky': False,
},
}
def action_apply_case_conversion_all(self):
"""Apply the global case conversion to all existing products."""
mode = self.fi_case_conversion
if not mode or mode == 'none':
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No Conversion Selected',
'message': 'Select a case conversion option first.',
'type': 'warning',
'sticky': False,
},
}
products = self.env['product.template'].search([])
count = 0
for product in products:
converted = self.env['product.template']._apply_case_conversion(
product.name, mode
)
if converted and converted != product.name:
product.with_context(_fi_converting_case=True).write({
'name': converted,
'x_fi_case_conversion': mode,
})
count += 1
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Case Conversion Applied',
'message': f'{count} product names converted to {mode}.',
'type': 'success',
'sticky': False,
},
}

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class ResPartner(models.Model):
_inherit = 'res.partner'
brand_ids = fields.One2many(
'product.brand', 'partner_id', string='Brands')
brand_count = fields.Integer(
string='Brands', compute='_compute_brand_count')
def _compute_brand_count(self):
for partner in self:
partner.brand_count = len(partner.brand_ids)
def action_view_brands(self):
self.ensure_one()
brands = self.brand_ids
if len(brands) == 1:
return {
'type': 'ir.actions.act_window',
'res_model': 'product.brand',
'res_id': brands.id,
'view_mode': 'form',
}
return {
'type': 'ir.actions.act_window',
'name': f'Brands: {self.name}',
'res_model': 'product.brand',
'view_mode': 'list,form',
'domain': [('partner_id', '=', self.id)],
'context': {'default_partner_id': self.id},
}
def action_create_brand(self):
self.ensure_one()
existing = self.env['product.brand'].search(
[('partner_id', '=', self.id)], limit=1)
if existing:
return {
'type': 'ir.actions.act_window',
'res_model': 'product.brand',
'res_id': existing.id,
'view_mode': 'form',
}
brand = self.env['product.brand'].create({
'name': self.name,
'partner_id': self.id,
'logo': self.image_128 or False,
})
return {
'type': 'ir.actions.act_window',
'res_model': 'product.brand',
'res_id': brand.id,
'view_mode': 'form',
}

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class StockMove(models.Model):
_inherit = 'stock.move'
def _action_done(self, cancel_backorder=False):
res = super()._action_done(cancel_backorder=cancel_backorder)
try:
self._trigger_remote_stock_refresh()
except Exception as e:
_logger.warning('Remote stock refresh failed (non-blocking): %s', e)
return res
def _trigger_remote_stock_refresh(self):
"""After a stock move completes, pull fresh stock data from all
connected remote instances for the affected products."""
if not self:
return
product_tmpls = self.mapped('product_id.product_tmpl_id')
if not product_tmpls:
return
configs = self.env['fusion.sync.config'].search([
('active', '=', True),
('state', '=', 'connected'),
('sync_stock', '=', True),
])
for config in configs:
try:
config._sync_stock_for_products(product_tmpls.ids)
except Exception as e:
_logger.warning(
'Targeted stock refresh from %s failed: %s',
config.name, e)

View File

@@ -0,0 +1,222 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields, api
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class StockPicking(models.Model):
_inherit = 'stock.picking'
# ── Sale Order link ──
x_fi_sale_order_id = fields.Many2one(
'sale.order', string='Sale Order',
compute='_compute_fi_sale_invoice', store=True)
x_fi_sale_order_state = fields.Selection(
related='x_fi_sale_order_id.state', string='SO Status',
store=True, tracking=False)
# ── Invoice tracking (customer) ──
x_fi_invoice_ids = fields.Many2many(
'account.move', 'stock_picking_invoice_rel',
'picking_id', 'move_id',
string='Invoices',
compute='_compute_fi_sale_invoice', store=True)
x_fi_invoice_count = fields.Integer(
compute='_compute_fi_sale_invoice', store=True)
x_fi_invoice_status = fields.Selection([
('no', 'No Invoice'),
('invoiced', 'Invoiced'),
('paid', 'Paid'),
], string='Invoice Status', compute='_compute_fi_sale_invoice', store=True)
# ── Purchase Order link ──
x_fi_purchase_order_id = fields.Many2one(
'purchase.order', string='Purchase Order',
compute='_compute_fi_purchase_bill', store=True)
x_fi_purchase_order_state = fields.Selection(
related='x_fi_purchase_order_id.state', string='PO Status',
store=True, tracking=False)
# ── Bill tracking (vendor) ──
x_fi_bill_ids = fields.Many2many(
'account.move', 'stock_picking_bill_rel',
'picking_id', 'move_id',
string='Vendor Bills',
compute='_compute_fi_purchase_bill', store=True)
x_fi_bill_count = fields.Integer(
compute='_compute_fi_purchase_bill', store=True)
x_fi_bill_status = fields.Selection([
('no', 'No Bill'),
('billed', 'Billed'),
('paid', 'Paid'),
], string='Bill Status', compute='_compute_fi_purchase_bill', store=True)
@api.depends('sale_id', 'sale_id.invoice_ids', 'sale_id.invoice_ids.payment_state',
'origin')
def _compute_fi_sale_invoice(self):
for pick in self:
so = pick.sale_id
if not so and pick.origin:
so = self.env['sale.order'].search(
[('name', '=', pick.origin)], limit=1)
pick.x_fi_sale_order_id = so.id if so else False
if so:
invoices = so.invoice_ids.filtered(
lambda m: m.state == 'posted' and m.move_type == 'out_invoice')
pick.x_fi_invoice_ids = invoices
pick.x_fi_invoice_count = len(invoices)
if not invoices:
pick.x_fi_invoice_status = 'no'
elif all(inv.payment_state in ('paid', 'in_payment', 'reversed')
for inv in invoices):
pick.x_fi_invoice_status = 'paid'
else:
pick.x_fi_invoice_status = 'invoiced'
else:
pick.x_fi_invoice_ids = self.env['account.move']
pick.x_fi_invoice_count = 0
pick.x_fi_invoice_status = 'no'
@api.depends('purchase_id', 'purchase_id.invoice_ids',
'purchase_id.invoice_ids.payment_state', 'origin')
def _compute_fi_purchase_bill(self):
PO = self.env['purchase.order']
for pick in self:
po = pick.purchase_id
if not po and pick.origin:
po = PO.search([('name', '=', pick.origin)], limit=1)
pick.x_fi_purchase_order_id = po.id if po else False
if po:
bills = po.invoice_ids.filtered(
lambda m: m.state == 'posted' and m.move_type == 'in_invoice')
pick.x_fi_bill_ids = bills
pick.x_fi_bill_count = len(bills)
if not bills:
pick.x_fi_bill_status = 'no'
elif all(b.payment_state in ('paid', 'in_payment', 'reversed')
for b in bills):
pick.x_fi_bill_status = 'paid'
else:
pick.x_fi_bill_status = 'billed'
else:
pick.x_fi_bill_ids = self.env['account.move']
pick.x_fi_bill_count = 0
pick.x_fi_bill_status = 'no'
# ── Smart button actions ──
def action_view_sale_order(self):
self.ensure_one()
if not self.x_fi_sale_order_id:
return
return {
'type': 'ir.actions.act_window',
'name': 'Sale Order',
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': self.x_fi_sale_order_id.id,
}
def action_view_invoices(self):
self.ensure_one()
invoices = self.x_fi_invoice_ids
if not invoices:
return
if len(invoices) == 1:
return {
'type': 'ir.actions.act_window',
'name': 'Invoice',
'res_model': 'account.move',
'view_mode': 'form',
'res_id': invoices.id,
}
return {
'type': 'ir.actions.act_window',
'name': 'Invoices',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', invoices.ids)],
}
def action_view_purchase_order(self):
self.ensure_one()
if not self.x_fi_purchase_order_id:
return
return {
'type': 'ir.actions.act_window',
'name': 'Purchase Order',
'res_model': 'purchase.order',
'view_mode': 'form',
'res_id': self.x_fi_purchase_order_id.id,
}
def action_view_bills(self):
self.ensure_one()
bills = self.x_fi_bill_ids
if not bills:
return
if len(bills) == 1:
return {
'type': 'ir.actions.act_window',
'name': 'Vendor Bill',
'res_model': 'account.move',
'view_mode': 'form',
'res_id': bills.id,
}
return {
'type': 'ir.actions.act_window',
'name': 'Vendor Bills',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', bills.ids)],
}
# ── Booking warning on confirm ──
def button_validate(self):
Booking = self.env['fusion.inventory.booking']
for pick in self.filtered(lambda p: p.picking_type_code == 'outgoing'):
for move in pick.move_ids:
active_bookings = Booking.search([
('product_id', '=', move.product_id.id),
('state', '=', 'active'),
('user_id', '!=', self.env.uid),
])
if active_bookings:
bookers = ', '.join(active_bookings.mapped('user_id.name'))
_logger.warning(
'Product %s is booked by %s but being delivered in %s',
move.product_id.name, bookers, pick.name)
return super().button_validate()
# ── Serial Number Scan ──
def action_scan_serial_numbers(self):
self.ensure_one()
wizard = self.env['fusion.serial.scan.wizard'].create({
'picking_id': self.id,
})
wizard._scan()
return {
'type': 'ir.actions.act_window',
'name': 'Serial Number Scan Results',
'res_model': 'fusion.serial.scan.wizard',
'view_mode': 'form',
'res_id': wizard.id,
'target': 'new',
}

View File

@@ -0,0 +1,630 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import xmlrpc.client
from odoo import models, fields, api
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionSyncConfig(models.Model):
_name = 'fusion.sync.config'
_description = 'Inventory Sync Configuration'
_rec_name = 'name'
name = fields.Char(string='Connection Name', required=True)
url = fields.Char(string='Remote URL', required=True,
help='Full URL of the remote Odoo instance (e.g., https://erp.mobilityspecialties.com)')
db_name = fields.Char(string='Database Name', required=True,
help='Name of the remote database')
username = fields.Char(string='Username', required=True,
help='Login username for the remote instance')
api_key = fields.Char(string='API Key / Password', required=True,
help='API key or password for authentication')
active = fields.Boolean(default=True)
state = fields.Selection([
('draft', 'Not Connected'),
('connected', 'Connected'),
('error', 'Connection Error'),
], string='Status', default='draft', readonly=True)
last_sync = fields.Datetime(string='Last Sync', readonly=True)
last_sync_status = fields.Text(string='Last Sync Result', readonly=True)
sync_interval = fields.Integer(string='Sync Interval (minutes)', default=30,
help='How often to run the automatic sync')
remote_uid = fields.Integer(string='Remote User ID', readonly=True)
company_id = fields.Many2one('res.company', string='Company',
default=lambda self: self.env.company)
sync_products = fields.Boolean(string='Sync Products', default=True)
sync_stock = fields.Boolean(string='Sync Stock Levels', default=True)
remote_warehouse_name = fields.Char(
string='Remote Warehouse Name',
help='Name of the warehouse on the remote instance to read stock from. Leave empty for all.')
is_shared_warehouse = fields.Boolean(
string='Shared Warehouse',
help='Enable shared warehouse mode for cross-company inventory management')
warehouse_location_id = fields.Many2one(
'stock.location', string='Shared Warehouse Location',
help='The stock location representing the shared warehouse')
remote_partner_id = fields.Many2one(
'res.partner', string='Remote Company (Partner)',
help='The local partner record that represents the remote company. '
'Used as vendor when creating local POs for inter-company transfers.')
local_company_name = fields.Char(
string='This Company on Remote',
help='The name of this company as it appears on the remote instance. '
'Used to find the correct partner when creating SOs on the remote side.')
sync_warehouse_ids = fields.One2many(
'fusion.sync.warehouse', 'config_id', string='Remote Warehouses')
sync_warehouse_count = fields.Integer(
compute='_compute_sync_warehouse_count')
@api.depends('sync_warehouse_ids')
def _compute_sync_warehouse_count(self):
for rec in self:
rec.sync_warehouse_count = len(rec.sync_warehouse_ids)
# ── XML-RPC Connection ──
def _get_xmlrpc_connection(self):
self.ensure_one()
url = self.url.rstrip('/')
try:
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common', allow_none=True)
uid = common.authenticate(self.db_name, self.username, self.api_key, {})
if not uid:
raise UserError('Authentication failed. Check username/API key.')
models_proxy = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object', allow_none=True)
return uid, models_proxy
except xmlrpc.client.Fault as e:
raise UserError(f'XML-RPC error: {e.faultString}')
except Exception as e:
raise UserError(f'Connection error: {str(e)}')
# ── Actions ──
def action_test_connection(self):
self.ensure_one()
try:
uid, models_proxy = self._get_xmlrpc_connection()
version_info = xmlrpc.client.ServerProxy(
f'{self.url.rstrip("/")}/xmlrpc/2/common', allow_none=True
).version()
self.write({
'state': 'connected',
'remote_uid': uid,
'last_sync_status': f'Connection successful. Remote server: {version_info.get("server_serie", "unknown")}',
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Connection Successful',
'message': f'Connected to {self.url} as user ID {uid}',
'type': 'success',
'sticky': False,
},
}
except Exception as e:
self.write({
'state': 'error',
'last_sync_status': f'Connection failed: {str(e)}',
})
raise
def action_sync_now(self):
self.ensure_one()
self._run_sync()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Sync Complete',
'message': self.last_sync_status,
'type': 'success',
'sticky': False,
},
}
# ── Core Sync Logic ──
def _run_sync(self):
self.ensure_one()
try:
uid, models_proxy = self._get_xmlrpc_connection()
results = []
wh_count = self._sync_warehouses(uid, models_proxy)
results.append(f'{wh_count} warehouses discovered')
if self.sync_products:
count = self._sync_products(uid, models_proxy)
results.append(f'{count} products synced')
if self.sync_stock:
count = self._sync_stock_levels(uid, models_proxy)
results.append(f'{count} stock levels updated')
status = ' | '.join(results)
self.write({
'state': 'connected',
'last_sync': fields.Datetime.now(),
'last_sync_status': status,
})
self.env['fusion.sync.log'].create({
'config_id': self.id,
'direction': 'pull',
'sync_type': 'full',
'status': 'success',
'summary': status,
'product_count': sum(1 for r in results if 'product' in r.lower()),
})
_logger.info('Inventory sync complete for %s: %s', self.name, status)
except Exception as e:
error_msg = f'Sync failed: {str(e)}'
self.write({
'state': 'error',
'last_sync': fields.Datetime.now(),
'last_sync_status': error_msg,
})
self.env['fusion.sync.log'].create({
'config_id': self.id,
'direction': 'pull',
'sync_type': 'full',
'status': 'error',
'summary': error_msg,
})
_logger.error('Sync failed for %s: %s', self.name, error_msg)
def _sync_warehouses(self, uid, models_proxy):
"""Discover and store remote warehouse metadata.
Only syncs warehouses belonging to the remote user's main company."""
self.ensure_one()
SyncWH = self.env['fusion.sync.warehouse']
user_data = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'res.users', 'read', [[uid]],
{'fields': ['company_id']}
)
wh_domain = []
if user_data and user_data[0].get('company_id'):
remote_company_id = user_data[0]['company_id']
if isinstance(remote_company_id, (list, tuple)):
remote_company_id = remote_company_id[0]
wh_domain = [('company_id', '=', remote_company_id)]
remote_warehouses = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'stock.warehouse', 'search_read',
[wh_domain],
{'fields': ['id', 'name', 'code', 'company_id', 'lot_stock_id']}
)
seen_ids = set()
for rwh in remote_warehouses:
wh_id = rwh['id']
seen_ids.add(wh_id)
company = rwh.get('company_id')
company_name = company[1] if isinstance(company, (list, tuple)) else ''
lot_stock = rwh.get('lot_stock_id')
lot_stock_id = lot_stock[0] if isinstance(lot_stock, (list, tuple)) else (lot_stock or 0)
existing = SyncWH.search([
('config_id', '=', self.id),
('remote_warehouse_id', '=', wh_id),
], limit=1)
vals = {
'config_id': self.id,
'remote_warehouse_id': wh_id,
'remote_lot_stock_id': lot_stock_id,
'name': rwh.get('name', ''),
'code': rwh.get('code', ''),
'company_name': company_name,
}
if existing:
existing.write(vals)
else:
SyncWH.create(vals)
stale = SyncWH.search([
('config_id', '=', self.id),
('remote_warehouse_id', 'not in', list(seen_ids)),
])
if stale:
stale.write({'active': False})
return len(remote_warehouses)
def _sync_products(self, uid, models_proxy):
"""Pull remote product catalog and auto-match to local products."""
self.ensure_one()
Mapping = self.env['fusion.product.sync.mapping']
remote_products = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'product.template', 'search_read',
[[('type', 'in', ['consu', 'product'])]],
{'fields': ['id', 'name', 'default_code', 'barcode',
'list_price', 'type', 'categ_id'],
'limit': 10000}
)
synced = 0
for rp in remote_products:
mapping = Mapping.search([
('config_id', '=', self.id),
('remote_product_id', '=', rp['id']),
], limit=1)
vals = {
'config_id': self.id,
'remote_product_id': rp['id'],
'remote_product_name': rp.get('name', ''),
'remote_default_code': rp.get('default_code', '') or '',
'remote_barcode': rp.get('barcode', '') or '',
'remote_list_price': rp.get('list_price', 0),
'remote_category': (
rp['categ_id'][1]
if isinstance(rp.get('categ_id'), (list, tuple))
else ''),
}
if mapping:
mapping.write(vals)
else:
local_product = self._find_local_product(rp)
vals['local_product_id'] = local_product.id if local_product else False
vals['auto_matched'] = bool(local_product)
Mapping.create(vals)
synced += 1
return synced
def _find_local_product(self, remote_product):
"""Match a remote product to a local one by SKU, barcode, then name."""
Template = self.env['product.template']
code = remote_product.get('default_code')
if code:
match = Template.search([('default_code', '=', code)], limit=1)
if match:
return match
barcode = remote_product.get('barcode')
if barcode:
match = Template.search([('barcode', '=', barcode)], limit=1)
if match:
return match
name = remote_product.get('name')
if name:
match = Template.search([('name', '=ilike', name)], limit=1)
if match:
return match
return False
def _sync_stock_levels(self, uid, models_proxy):
"""Pull per-warehouse stock levels and store in fusion.sync.stock."""
self.ensure_one()
Mapping = self.env['fusion.product.sync.mapping']
SyncStock = self.env['fusion.sync.stock']
warehouses = self.sync_warehouse_ids.filtered('active')
if not warehouses:
_logger.info('No remote warehouses found for %s, skipping stock sync', self.name)
return 0
mappings = Mapping.search([
('config_id', '=', self.id),
('remote_product_id', '!=', 0),
])
if not mappings:
return 0
remote_tmpl_ids = [m.remote_product_id for m in mappings]
mapping_by_remote = {m.remote_product_id: m for m in mappings}
total_updated = 0
now = fields.Datetime.now()
for wh in warehouses:
if not wh.remote_lot_stock_id:
continue
try:
remote_quants = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'stock.quant', 'search_read',
[[
('location_id', 'child_of', wh.remote_lot_stock_id),
('product_id.product_tmpl_id', 'in', remote_tmpl_ids),
('quantity', '!=', 0),
]],
{'fields': ['product_id', 'quantity', 'reserved_quantity']}
)
except Exception as e:
_logger.warning(
'Stock sync failed for warehouse %s: %s', wh.name, e)
continue
stock_by_tmpl = {}
product_tmpl_cache = {}
product_ids_needed = set()
for q in remote_quants:
pid = q['product_id']
if isinstance(pid, (list, tuple)):
pid = pid[0]
product_ids_needed.add(pid)
if product_ids_needed:
remote_variants = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'product.product', 'search_read',
[[('id', 'in', list(product_ids_needed))]],
{'fields': ['id', 'product_tmpl_id']}
)
for rv in remote_variants:
tmpl = rv['product_tmpl_id']
tmpl_id = tmpl[0] if isinstance(tmpl, (list, tuple)) else tmpl
product_tmpl_cache[rv['id']] = tmpl_id
for q in remote_quants:
pid = q['product_id']
if isinstance(pid, (list, tuple)):
pid = pid[0]
tmpl_id = product_tmpl_cache.get(pid)
if not tmpl_id:
continue
if tmpl_id not in stock_by_tmpl:
stock_by_tmpl[tmpl_id] = {'qty': 0.0, 'reserved': 0.0}
stock_by_tmpl[tmpl_id]['qty'] += q.get('quantity', 0)
stock_by_tmpl[tmpl_id]['reserved'] += q.get('reserved_quantity', 0)
for tmpl_id, stock in stock_by_tmpl.items():
mapping = mapping_by_remote.get(tmpl_id)
if not mapping:
continue
qty_available = stock['qty'] - stock['reserved']
qty_forecast = stock['qty']
existing = SyncStock.search([
('mapping_id', '=', mapping.id),
('sync_warehouse_id', '=', wh.id),
], limit=1)
vals = {
'mapping_id': mapping.id,
'sync_warehouse_id': wh.id,
'qty_available': qty_available,
'qty_forecast': qty_forecast,
'last_sync': now,
}
if existing:
existing.write(vals)
else:
SyncStock.create(vals)
total_updated += 1
zero_mappings = set(mapping_by_remote.keys()) - set(stock_by_tmpl.keys())
if zero_mappings:
for tmpl_id in zero_mappings:
mapping = mapping_by_remote.get(tmpl_id)
if not mapping:
continue
existing = SyncStock.search([
('mapping_id', '=', mapping.id),
('sync_warehouse_id', '=', wh.id),
], limit=1)
if existing and existing.qty_available != 0:
existing.write({
'qty_available': 0,
'qty_forecast': 0,
'last_sync': now,
})
for mapping in mappings:
mapping.last_stock_sync = now
return total_updated
def _sync_stock_for_products(self, product_tmpl_ids):
"""Targeted stock re-sync for specific products (called after stock moves)."""
self.ensure_one()
if not product_tmpl_ids:
return
Mapping = self.env['fusion.product.sync.mapping']
mappings = Mapping.search([
('config_id', '=', self.id),
('local_product_id', 'in', product_tmpl_ids),
('remote_product_id', '!=', 0),
])
if not mappings:
return
try:
uid, models_proxy = self._get_xmlrpc_connection()
except Exception:
return
warehouses = self.sync_warehouse_ids.filtered('active')
SyncStock = self.env['fusion.sync.stock']
now = fields.Datetime.now()
remote_tmpl_ids = [m.remote_product_id for m in mappings]
mapping_by_remote = {m.remote_product_id: m for m in mappings}
for wh in warehouses:
if not wh.remote_lot_stock_id:
continue
try:
remote_quants = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'stock.quant', 'search_read',
[[
('location_id', 'child_of', wh.remote_lot_stock_id),
('product_id.product_tmpl_id', 'in', remote_tmpl_ids),
]],
{'fields': ['product_id', 'quantity', 'reserved_quantity']}
)
except Exception:
continue
stock_by_tmpl = {}
product_ids_needed = set()
for q in remote_quants:
pid = q['product_id']
if isinstance(pid, (list, tuple)):
pid = pid[0]
product_ids_needed.add(pid)
product_tmpl_cache = {}
if product_ids_needed:
remote_variants = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'product.product', 'search_read',
[[('id', 'in', list(product_ids_needed))]],
{'fields': ['id', 'product_tmpl_id']}
)
for rv in remote_variants:
tmpl = rv['product_tmpl_id']
product_tmpl_cache[rv['id']] = (
tmpl[0] if isinstance(tmpl, (list, tuple)) else tmpl)
for q in remote_quants:
pid = q['product_id']
if isinstance(pid, (list, tuple)):
pid = pid[0]
tmpl_id = product_tmpl_cache.get(pid)
if not tmpl_id:
continue
if tmpl_id not in stock_by_tmpl:
stock_by_tmpl[tmpl_id] = {'qty': 0.0, 'reserved': 0.0}
stock_by_tmpl[tmpl_id]['qty'] += q.get('quantity', 0)
stock_by_tmpl[tmpl_id]['reserved'] += q.get('reserved_quantity', 0)
for tmpl_id in remote_tmpl_ids:
mapping = mapping_by_remote.get(tmpl_id)
if not mapping:
continue
stock = stock_by_tmpl.get(tmpl_id, {'qty': 0, 'reserved': 0})
existing = SyncStock.search([
('mapping_id', '=', mapping.id),
('sync_warehouse_id', '=', wh.id),
], limit=1)
vals = {
'mapping_id': mapping.id,
'sync_warehouse_id': wh.id,
'qty_available': stock['qty'] - stock['reserved'],
'qty_forecast': stock['qty'],
'last_sync': now,
}
if existing:
existing.write(vals)
else:
SyncStock.create(vals)
# ── Inter-Company Transfer Helpers ──
def _create_remote_sale_order(self, product_mapping, qty, partner_name):
"""Create a sale order on the remote instance for inter-company transfers."""
self.ensure_one()
uid, models_proxy = self._get_xmlrpc_connection()
partners = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'res.partner', 'search_read',
[[('name', 'ilike', partner_name)]],
{'fields': ['id', 'name'], 'limit': 1}
)
if not partners:
raise UserError(f'Partner "{partner_name}" not found on remote instance.')
remote_product_ids = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'product.product', 'search',
[[('product_tmpl_id', '=', product_mapping.remote_product_id)]],
{'limit': 1}
)
if not remote_product_ids:
raise UserError('Remote product variant not found.')
so_vals = {
'partner_id': partners[0]['id'],
'order_line': [(0, 0, {
'product_id': remote_product_ids[0],
'product_uom_qty': qty,
})],
}
remote_so_id = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'sale.order', 'create', [so_vals]
)
models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'sale.order', 'action_confirm', [[remote_so_id]]
)
so_data = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'sale.order', 'read', [[remote_so_id]], {'fields': ['name']}
)
so_name = so_data[0]['name'] if so_data else ''
return remote_so_id, so_name
def _create_remote_invoice(self, remote_so_id):
"""Create and post an invoice for a remote sale order."""
self.ensure_one()
uid, models_proxy = self._get_xmlrpc_connection()
models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'sale.order', 'action_create_invoices', [[remote_so_id]]
)
so_data = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'sale.order', 'read', [[remote_so_id]],
{'fields': ['invoice_ids']}
)
invoice_ids = so_data[0].get('invoice_ids', []) if so_data else []
if invoice_ids:
inv_data = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'account.move', 'read',
[invoice_ids],
{'fields': ['id', 'name', 'amount_total']}
)
return inv_data[0]['id'] if inv_data else False
return False
# ── Cron ──
@api.model
def _cron_sync_inventory(self):
configs = self.search([('active', '=', True), ('state', '!=', 'draft')])
for config in configs:
try:
config._run_sync()
except Exception as e:
_logger.error('Cron sync failed for %s: %s', config.name, e)

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class FusionSyncLog(models.Model):
_name = 'fusion.sync.log'
_description = 'Inventory Sync Log'
_order = 'create_date desc'
_rec_name = 'summary'
config_id = fields.Many2one('fusion.sync.config', string='Sync Config',
required=True, ondelete='cascade')
direction = fields.Selection([
('pull', 'Pull (Remote -> Local)'),
('push', 'Push (Local -> Remote)'),
], string='Direction', required=True)
sync_type = fields.Selection([
('product', 'Product Catalog'),
('stock', 'Stock Levels'),
('full', 'Full Sync'),
], string='Type', required=True)
status = fields.Selection([
('success', 'Success'),
('partial', 'Partial'),
('error', 'Error'),
], string='Status', required=True)
summary = fields.Char(string='Summary')
details = fields.Text(string='Details')
product_count = fields.Integer(string='Products Affected')

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class FusionSyncStock(models.Model):
_name = 'fusion.sync.stock'
_description = 'Per-Warehouse Remote Stock Level'
_rec_name = 'sync_warehouse_id'
_order = 'sync_warehouse_id'
mapping_id = fields.Many2one(
'fusion.product.sync.mapping', string='Product Mapping',
required=True, ondelete='cascade', index=True)
sync_warehouse_id = fields.Many2one(
'fusion.sync.warehouse', string='Remote Warehouse',
required=True, ondelete='cascade', index=True)
config_id = fields.Many2one(
related='mapping_id.config_id', store=True, index=True)
qty_available = fields.Float(string='On Hand', default=0.0)
qty_forecast = fields.Float(string='Forecast', default=0.0)
last_sync = fields.Datetime(string='Last Updated')
_sql_constraints = [
('unique_mapping_warehouse',
'UNIQUE(mapping_id, sync_warehouse_id)',
'Only one stock record per product mapping per warehouse.'),
]

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class FusionSyncWarehouse(models.Model):
_name = 'fusion.sync.warehouse'
_description = 'Remote Warehouse (Discovered via Sync)'
_rec_name = 'name'
_order = 'name'
config_id = fields.Many2one(
'fusion.sync.config', string='Sync Config',
required=True, ondelete='cascade', index=True)
remote_warehouse_id = fields.Integer(
string='Remote Warehouse ID', required=True, index=True)
remote_lot_stock_id = fields.Integer(
string='Remote Stock Location ID',
help='The lot_stock_id on the remote warehouse, used to filter quants')
name = fields.Char(string='Warehouse Name', required=True)
code = fields.Char(string='Code')
company_name = fields.Char(string='Company')
active = fields.Boolean(default=True)
stock_line_ids = fields.One2many(
'fusion.sync.stock', 'sync_warehouse_id',
string='Stock Lines')
_sql_constraints = [
('unique_remote_warehouse',
'UNIQUE(config_id, remote_warehouse_id)',
'Each remote warehouse can only appear once per sync configuration.'),
]

View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionWarehouseInventory(models.Model):
_name = 'fusion.warehouse.inventory'
_description = 'Shared Warehouse Inventory Ownership'
_rec_name = 'display_name'
_order = 'product_id, owner_config_id'
product_id = fields.Many2one(
'product.product', string='Product',
required=True, ondelete='cascade', index=True)
product_tmpl_id = fields.Many2one(
related='product_id.product_tmpl_id', store=True)
lot_id = fields.Many2one(
'stock.lot', string='Serial/Lot',
help='Serial number or lot for tracked products')
owner_config_id = fields.Many2one(
'fusion.sync.config', string='Owner Instance',
required=True, ondelete='restrict',
help='The Odoo instance that owns this inventory')
quantity = fields.Float(string='Quantity', default=1.0)
location_bin = fields.Char(
string='Bin / Shelf',
help='Physical location within the shared warehouse')
warehouse_location_id = fields.Many2one(
'stock.location', string='Warehouse Location',
related='owner_config_id.warehouse_location_id', store=True)
state = fields.Selection([
('available', 'Available'),
('reserved', 'Reserved'),
('in_transit', 'In Transit'),
('transferred', 'Transferred'),
], string='Status', default='available', required=True, index=True)
notes = fields.Text(string='Notes')
display_name = fields.Char(compute='_compute_display_name')
@api.depends('product_id', 'owner_config_id', 'quantity')
def _compute_display_name(self):
for rec in self:
owner = rec.owner_config_id.name or 'Unknown'
rec.display_name = f'{rec.product_id.name} ({rec.quantity} - {owner})'
@api.model
def get_available_for_product(self, product_id, exclude_owner_id=None):
"""Get available warehouse inventory for a product, optionally excluding an owner."""
domain = [
('product_id', '=', product_id),
('state', '=', 'available'),
('quantity', '>', 0),
]
if exclude_owner_id:
domain.append(('owner_config_id', '!=', exclude_owner_id))
return self.search(domain)
def action_reserve(self):
for rec in self.filtered(lambda r: r.state == 'available'):
rec.state = 'reserved'
def action_mark_in_transit(self):
for rec in self.filtered(lambda r: r.state in ('available', 'reserved')):
rec.state = 'in_transit'
def action_mark_transferred(self):
for rec in self.filtered(lambda r: r.state == 'in_transit'):
rec.state = 'transferred'
def action_release(self):
for rec in self.filtered(lambda r: r.state == 'reserved'):
rec.state = 'available'