423 lines
16 KiB
Python
423 lines
16 KiB
Python
# -*- 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
|