changes
This commit is contained in:
6
fusion_inventory/controllers/__init__.py
Normal file
6
fusion_inventory/controllers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import portal_inventory
|
||||
from . import product_configurator
|
||||
Binary file not shown.
422
fusion_inventory/controllers/portal_inventory.py
Normal file
422
fusion_inventory/controllers/portal_inventory.py
Normal file
@@ -0,0 +1,422 @@
|
||||
# -*- 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 http, fields
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
ITEMS_PER_PAGE = 50
|
||||
|
||||
|
||||
class PortalInventory(http.Controller):
|
||||
|
||||
# ── Core data builder ──
|
||||
|
||||
def _get_inventory_data(self, search='', category_ids=None, page=1,
|
||||
warehouse_filter=None):
|
||||
"""Build inventory data merging local stock with remote sync data.
|
||||
Fetches all matching products, filters/sorts globally, then paginates
|
||||
so local in-stock items always appear first across all pages."""
|
||||
Product = request.env['product.product'].sudo()
|
||||
Booking = request.env['fusion.inventory.booking'].sudo()
|
||||
POLine = request.env['purchase.order.line'].sudo()
|
||||
Mapping = request.env['fusion.product.sync.mapping'].sudo()
|
||||
|
||||
show_local = warehouse_filter in (None, '', 'all', 'local')
|
||||
show_remote = warehouse_filter in (None, '', 'all', 'remote')
|
||||
|
||||
domain = [
|
||||
('type', '!=', 'service'),
|
||||
('is_storable', '=', True),
|
||||
('active', '=', True),
|
||||
]
|
||||
if search:
|
||||
domain += ['|', '|',
|
||||
('name', 'ilike', search),
|
||||
('default_code', 'ilike', search),
|
||||
('barcode', 'ilike', search)]
|
||||
if category_ids:
|
||||
domain.append(('categ_id', 'in', category_ids))
|
||||
|
||||
all_products = Product.search(domain, order='name asc')
|
||||
|
||||
all_product_ids = all_products.ids
|
||||
all_tmpl_ids = all_products.mapped('product_tmpl_id').ids
|
||||
|
||||
bookings = Booking.search([
|
||||
('product_id', 'in', all_product_ids),
|
||||
('state', '=', 'active'),
|
||||
])
|
||||
booked_map = {}
|
||||
for b in bookings:
|
||||
booked_map.setdefault(b.product_id.id, 0)
|
||||
booked_map[b.product_id.id] += b.quantity
|
||||
|
||||
po_lines = POLine.search([
|
||||
('product_id', 'in', all_product_ids),
|
||||
('order_id.state', 'in', ('purchase', 'done')),
|
||||
])
|
||||
shadow_map = {}
|
||||
for pl in po_lines:
|
||||
remaining = pl.product_qty - pl.qty_received
|
||||
if remaining > 0:
|
||||
shadow_map.setdefault(pl.product_id.id, 0)
|
||||
shadow_map[pl.product_id.id] += remaining
|
||||
|
||||
remote_stock_map = {}
|
||||
remote_warehouses_map = {}
|
||||
if show_remote:
|
||||
mappings = Mapping.search([
|
||||
('local_product_id', 'in', all_tmpl_ids),
|
||||
('config_id.active', '=', True),
|
||||
])
|
||||
for m in mappings:
|
||||
tmpl_id = m.local_product_id.id
|
||||
total_remote = m.remote_qty_available or 0
|
||||
remote_stock_map[tmpl_id] = (
|
||||
remote_stock_map.get(tmpl_id, 0) + total_remote)
|
||||
|
||||
wh_details = []
|
||||
for sl in m.stock_line_ids:
|
||||
if sl.qty_available > 0:
|
||||
wh_details.append({
|
||||
'warehouse': sl.sync_warehouse_id.name,
|
||||
'code': sl.sync_warehouse_id.code,
|
||||
'company': sl.sync_warehouse_id.company_name,
|
||||
'qty': sl.qty_available,
|
||||
})
|
||||
if wh_details:
|
||||
remote_warehouses_map.setdefault(tmpl_id, []).extend(
|
||||
wh_details)
|
||||
|
||||
all_rows = []
|
||||
for p in all_products:
|
||||
booked = booked_map.get(p.id, 0)
|
||||
shadow = shadow_map.get(p.id, 0)
|
||||
cost = p.standard_price or 0
|
||||
sale = p.lst_price or 0
|
||||
margin_pct = (
|
||||
((sale - cost) / sale * 100) if sale > 0 and cost > 0 else 0)
|
||||
remote_qty = remote_stock_map.get(p.product_tmpl_id.id, 0)
|
||||
remote_whs = remote_warehouses_map.get(p.product_tmpl_id.id, [])
|
||||
local_qty = p.qty_available
|
||||
|
||||
total_stock = local_qty + remote_qty + shadow
|
||||
if total_stock <= 0:
|
||||
continue
|
||||
|
||||
if warehouse_filter == 'local' and local_qty <= 0:
|
||||
continue
|
||||
if warehouse_filter == 'remote' and remote_qty <= 0:
|
||||
continue
|
||||
|
||||
all_rows.append({
|
||||
'id': p.id,
|
||||
'tmpl_id': p.product_tmpl_id.id,
|
||||
'name': p.name or '',
|
||||
'default_code': p.default_code or '',
|
||||
'category': p.categ_id.display_name if p.categ_id else '',
|
||||
'category_id': p.categ_id.id if p.categ_id else 0,
|
||||
'qty_on_hand': local_qty,
|
||||
'available_qty': max(local_qty - booked, 0),
|
||||
'booked_qty': booked,
|
||||
'shadow_qty': shadow,
|
||||
'remote_qty': round(remote_qty, 1) if show_remote else 0,
|
||||
'remote_warehouses': remote_whs if show_remote else [],
|
||||
'total_qty': round(
|
||||
local_qty + (remote_qty if show_remote else 0), 1),
|
||||
'sale_price': round(sale, 2),
|
||||
'margin_pct': round(margin_pct, 1),
|
||||
})
|
||||
|
||||
all_rows.sort(key=lambda r: (-r['qty_on_hand'], r['name']))
|
||||
|
||||
total = len(all_rows)
|
||||
offset = (page - 1) * ITEMS_PER_PAGE
|
||||
page_rows = all_rows[offset:offset + ITEMS_PER_PAGE]
|
||||
|
||||
remote_only_rows = self._get_remote_only_products(
|
||||
search, category_ids, all_tmpl_ids, warehouse_filter)
|
||||
remote_total = len(remote_only_rows)
|
||||
|
||||
return page_rows, total, remote_only_rows, remote_total
|
||||
|
||||
def _get_remote_only_products(self, search='', category_ids=None,
|
||||
exclude_tmpl_ids=None,
|
||||
warehouse_filter=None):
|
||||
"""Return products that exist only on remote instances.
|
||||
Excluded when filter is 'local' or when a category filter is active
|
||||
(remote categories can't be matched to local category IDs)."""
|
||||
if warehouse_filter == 'local':
|
||||
return []
|
||||
|
||||
Mapping = request.env['fusion.product.sync.mapping'].sudo()
|
||||
|
||||
domain = [
|
||||
('local_product_id', '=', False),
|
||||
('config_id.active', '=', True),
|
||||
('remote_qty_available', '>', 0),
|
||||
]
|
||||
if search:
|
||||
domain.append(('remote_product_name', 'ilike', search))
|
||||
|
||||
if category_ids:
|
||||
cat_names = request.env['product.category'].sudo().browse(
|
||||
category_ids).mapped('name')
|
||||
if cat_names:
|
||||
cat_domain = ['|'] * (len(cat_names) - 1)
|
||||
for cn in cat_names:
|
||||
cat_domain.append(('remote_category', 'ilike', cn))
|
||||
domain += cat_domain
|
||||
else:
|
||||
return []
|
||||
|
||||
mappings = Mapping.search(domain, limit=50)
|
||||
|
||||
rows = []
|
||||
for m in mappings:
|
||||
wh_details = []
|
||||
for sl in m.stock_line_ids:
|
||||
if sl.qty_available > 0:
|
||||
wh_details.append({
|
||||
'warehouse': sl.sync_warehouse_id.name,
|
||||
'code': sl.sync_warehouse_id.code,
|
||||
'company': sl.sync_warehouse_id.company_name,
|
||||
'qty': sl.qty_available,
|
||||
})
|
||||
|
||||
rows.append({
|
||||
'id': 0,
|
||||
'tmpl_id': 0,
|
||||
'name': m.remote_product_name or '',
|
||||
'default_code': m.remote_default_code or '',
|
||||
'category': m.remote_category or '',
|
||||
'category_id': 0,
|
||||
'qty_on_hand': 0,
|
||||
'available_qty': 0,
|
||||
'booked_qty': 0,
|
||||
'shadow_qty': 0,
|
||||
'remote_qty': round(m.remote_qty_available, 1),
|
||||
'remote_warehouses': wh_details,
|
||||
'total_qty': round(m.remote_qty_available, 1),
|
||||
'sale_price': round(m.remote_list_price or 0, 2),
|
||||
'margin_pct': 0,
|
||||
'remote_only': True,
|
||||
'config_id': m.config_id.id,
|
||||
'config_name': m.config_id.name,
|
||||
})
|
||||
|
||||
return rows
|
||||
|
||||
# ── Main page ──
|
||||
|
||||
@http.route('/my/inventory', type='http', auth='user', website=True)
|
||||
def portal_inventory(self, search='', category=None, page=1,
|
||||
warehouse=None, **kw):
|
||||
page = int(page)
|
||||
category_ids = None
|
||||
if category:
|
||||
try:
|
||||
category_ids = [int(c) for c in category.split(',') if c]
|
||||
except (ValueError, AttributeError):
|
||||
category_ids = None
|
||||
|
||||
rows, total, remote_only, remote_total = self._get_inventory_data(
|
||||
search, category_ids, page, warehouse)
|
||||
|
||||
categories = request.env['product.category'].sudo().search(
|
||||
[], order='name asc')
|
||||
|
||||
total_pages = max(1, (total + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE)
|
||||
|
||||
has_sync = bool(request.env['fusion.sync.config'].sudo().search([
|
||||
('active', '=', True), ('state', '=', 'connected'),
|
||||
], limit=1))
|
||||
|
||||
all_warehouses = self._get_all_warehouses()
|
||||
|
||||
return request.render('fusion_inventory.portal_inventory_sheet', {
|
||||
'products': rows,
|
||||
'remote_only_products': remote_only,
|
||||
'categories': categories,
|
||||
'search': search,
|
||||
'selected_categories': category_ids or [],
|
||||
'page': page,
|
||||
'total_pages': total_pages,
|
||||
'total_products': total + remote_total,
|
||||
'page_name': 'fi_inventory',
|
||||
'has_sync': has_sync,
|
||||
'all_warehouses': all_warehouses,
|
||||
'warehouse_filter': warehouse or 'all',
|
||||
})
|
||||
|
||||
# ── JSON-RPC endpoints ──
|
||||
|
||||
@http.route('/my/inventory/data', type='json', auth='user')
|
||||
def portal_inventory_data(self, search='', category_ids=None, page=1,
|
||||
warehouse=None):
|
||||
rows, total, remote_only, _ = self._get_inventory_data(
|
||||
search, category_ids, int(page), warehouse)
|
||||
total_pages = max(1, (total + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE)
|
||||
return {
|
||||
'products': rows + remote_only,
|
||||
'total': total,
|
||||
'page': int(page),
|
||||
'total_pages': total_pages,
|
||||
}
|
||||
|
||||
@http.route('/my/inventory/book', type='json', auth='user')
|
||||
def portal_inventory_book(self, product_id, quantity=1):
|
||||
product_id = int(product_id)
|
||||
quantity = float(quantity)
|
||||
|
||||
product = request.env['product.product'].sudo().browse(product_id)
|
||||
if not product.exists():
|
||||
return {'error': 'Product not found'}
|
||||
|
||||
Booking = request.env['fusion.inventory.booking'].sudo()
|
||||
|
||||
existing = Booking.search([
|
||||
('product_id', '=', product_id),
|
||||
('user_id', '=', request.env.uid),
|
||||
('state', '=', 'active'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {'error': 'You already have an active booking for this product',
|
||||
'booking_id': existing.id}
|
||||
|
||||
hold_hours = int(request.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_inventory.booking_hold_hours', '24') or 24)
|
||||
|
||||
booking = Booking.create({
|
||||
'product_id': product_id,
|
||||
'user_id': request.env.uid,
|
||||
'quantity': quantity,
|
||||
'expiry_datetime': fields.Datetime.now() + timedelta(hours=hold_hours),
|
||||
'state': 'active',
|
||||
})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'booking_id': booking.id,
|
||||
'expires_at': fields.Datetime.to_string(booking.expiry_datetime),
|
||||
}
|
||||
|
||||
@http.route('/my/inventory/release', type='json', auth='user')
|
||||
def portal_inventory_release(self, booking_id):
|
||||
booking = request.env['fusion.inventory.booking'].sudo().browse(int(booking_id))
|
||||
if not booking.exists() or booking.user_id.id != request.env.uid:
|
||||
return {'error': 'Booking not found'}
|
||||
booking.action_release()
|
||||
return {'success': True}
|
||||
|
||||
@http.route('/my/inventory/categories', type='json', auth='user')
|
||||
def portal_inventory_categories(self, search=''):
|
||||
domain = []
|
||||
if search:
|
||||
domain.append(('name', 'ilike', search))
|
||||
categories = request.env['product.category'].sudo().search(
|
||||
domain, order='name asc', limit=100)
|
||||
return [{'id': c.id, 'name': c.display_name} for c in categories]
|
||||
|
||||
@http.route('/my/inventory/warehouses', type='json', auth='user')
|
||||
def portal_inventory_warehouses(self):
|
||||
return self._get_all_warehouses()
|
||||
|
||||
@http.route('/my/inventory/transfer', type='json', auth='user')
|
||||
def portal_inventory_transfer(self, product_id, quantity=1, config_id=None):
|
||||
"""Create and execute an inter-company transfer from the portal."""
|
||||
product_id = int(product_id)
|
||||
quantity = float(quantity)
|
||||
|
||||
product = request.env['product.product'].sudo().browse(product_id)
|
||||
if not product.exists():
|
||||
return {'error': 'Product not found'}
|
||||
|
||||
if config_id:
|
||||
config = request.env['fusion.sync.config'].sudo().browse(int(config_id))
|
||||
else:
|
||||
config = request.env['fusion.sync.config'].sudo().search([
|
||||
('active', '=', True),
|
||||
('state', '=', 'connected'),
|
||||
], limit=1)
|
||||
|
||||
if not config:
|
||||
return {'error': 'No sync connection configured'}
|
||||
|
||||
Mapping = request.env['fusion.product.sync.mapping'].sudo()
|
||||
mapping = Mapping.search([
|
||||
('config_id', '=', config.id),
|
||||
('local_product_id', '=', product.product_tmpl_id.id),
|
||||
], limit=1)
|
||||
|
||||
if not mapping:
|
||||
return {'error': f'Product not mapped on {config.name}. Run sync first.'}
|
||||
|
||||
if mapping.remote_qty_available < quantity:
|
||||
return {
|
||||
'error': f'Insufficient remote stock. Available: {mapping.remote_qty_available}',
|
||||
}
|
||||
|
||||
Transfer = request.env['fusion.inter.company.transfer'].sudo()
|
||||
transfer = Transfer.create({
|
||||
'config_id': config.id,
|
||||
'product_id': product_id,
|
||||
'quantity': quantity,
|
||||
'requested_by': request.env.uid,
|
||||
'notes': 'Created from portal inventory sheet.',
|
||||
})
|
||||
|
||||
try:
|
||||
transfer.action_execute_transfer()
|
||||
except Exception as e:
|
||||
_logger.error('Portal transfer failed: %s', e)
|
||||
return {
|
||||
'error': f'Transfer failed: {str(e)}',
|
||||
'transfer_id': transfer.id,
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'transfer_id': transfer.id,
|
||||
'state': transfer.state,
|
||||
'local_po': transfer.local_po_id.name if transfer.local_po_id else None,
|
||||
'remote_so': transfer.remote_so_name,
|
||||
}
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _get_all_warehouses(self):
|
||||
"""Return local (current company only) + remote warehouses for filter."""
|
||||
warehouses = []
|
||||
|
||||
user_company = request.env.company
|
||||
local_whs = request.env['stock.warehouse'].sudo().search([
|
||||
('company_id', '=', user_company.id),
|
||||
])
|
||||
for wh in local_whs:
|
||||
warehouses.append({
|
||||
'id': f'local_{wh.id}',
|
||||
'name': wh.name,
|
||||
'code': wh.code,
|
||||
'type': 'local',
|
||||
'company': wh.company_id.name,
|
||||
})
|
||||
|
||||
remote_whs = request.env['fusion.sync.warehouse'].sudo().search([
|
||||
('active', '=', True),
|
||||
])
|
||||
for wh in remote_whs:
|
||||
warehouses.append({
|
||||
'id': f'remote_{wh.id}',
|
||||
'name': wh.name,
|
||||
'code': wh.code,
|
||||
'type': 'remote',
|
||||
'company': wh.company_name,
|
||||
})
|
||||
|
||||
return warehouses
|
||||
27
fusion_inventory/controllers/product_configurator.py
Normal file
27
fusion_inventory/controllers/product_configurator.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo.http import request
|
||||
from odoo.addons.sale.controllers.product_configurator import (
|
||||
SaleProductConfiguratorController,
|
||||
)
|
||||
|
||||
_original_get_ptav_price_extra = (
|
||||
SaleProductConfiguratorController._get_ptav_price_extra
|
||||
)
|
||||
|
||||
|
||||
def _fi_get_ptav_price_extra(self, ptav, currency, date, product_or_template):
|
||||
extra = _original_get_ptav_price_extra(
|
||||
self, ptav, currency, date, product_or_template)
|
||||
impact = ptav.x_fi_extra_price_impact or 0.0
|
||||
if impact:
|
||||
extra += ptav.currency_id._convert(
|
||||
impact, currency, request.env.company, date.date())
|
||||
return extra
|
||||
|
||||
|
||||
SaleProductConfiguratorController._get_ptav_price_extra = (
|
||||
_fi_get_ptav_price_extra
|
||||
)
|
||||
Reference in New Issue
Block a user