changes
This commit is contained in:
23
fusion_inventory/models/__init__.py
Normal file
23
fusion_inventory/models/__init__.py
Normal 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
|
||||
59
fusion_inventory/models/account_move.py
Normal file
59
fusion_inventory/models/account_move.py
Normal 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
|
||||
316
fusion_inventory/models/inter_company_transfer.py
Normal file
316
fusion_inventory/models/inter_company_transfer.py
Normal 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,
|
||||
})
|
||||
71
fusion_inventory/models/inventory_booking.py
Normal file
71
fusion_inventory/models/inventory_booking.py
Normal 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'))
|
||||
183
fusion_inventory/models/inventory_discrepancy.py
Normal file
183
fusion_inventory/models/inventory_discrepancy.py
Normal 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
|
||||
151
fusion_inventory/models/product_brand.py
Normal file
151
fusion_inventory/models/product_brand.py
Normal 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},
|
||||
}
|
||||
80
fusion_inventory/models/product_brand_pricing_rule.py
Normal file
80
fusion_inventory/models/product_brand_pricing_rule.py
Normal 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
|
||||
167
fusion_inventory/models/product_product.py
Normal file
167
fusion_inventory/models/product_product.py
Normal 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
|
||||
54
fusion_inventory/models/product_sync_mapping.py
Normal file
54
fusion_inventory/models/product_sync_mapping.py
Normal 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.'),
|
||||
]
|
||||
356
fusion_inventory/models/product_template.py
Normal file
356
fusion_inventory/models/product_template.py
Normal 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))
|
||||
68
fusion_inventory/models/product_template_attribute_value.py
Normal file
68
fusion_inventory/models/product_template_attribute_value.py
Normal 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)
|
||||
112
fusion_inventory/models/res_config_settings.py
Normal file
112
fusion_inventory/models/res_config_settings.py
Normal 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,
|
||||
},
|
||||
}
|
||||
60
fusion_inventory/models/res_partner.py
Normal file
60
fusion_inventory/models/res_partner.py
Normal 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',
|
||||
}
|
||||
44
fusion_inventory/models/stock_move.py
Normal file
44
fusion_inventory/models/stock_move.py
Normal 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)
|
||||
222
fusion_inventory/models/stock_picking.py
Normal file
222
fusion_inventory/models/stock_picking.py
Normal 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',
|
||||
}
|
||||
630
fusion_inventory/models/sync_config.py
Normal file
630
fusion_inventory/models/sync_config.py
Normal 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)
|
||||
32
fusion_inventory/models/sync_log.py
Normal file
32
fusion_inventory/models/sync_log.py
Normal 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')
|
||||
30
fusion_inventory/models/sync_stock.py
Normal file
30
fusion_inventory/models/sync_stock.py
Normal 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.'),
|
||||
]
|
||||
34
fusion_inventory/models/sync_warehouse.py
Normal file
34
fusion_inventory/models/sync_warehouse.py
Normal 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.'),
|
||||
]
|
||||
78
fusion_inventory/models/warehouse_ownership.py
Normal file
78
fusion_inventory/models/warehouse_ownership.py
Normal 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'
|
||||
Reference in New Issue
Block a user