# -*- 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