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

View File

@@ -0,0 +1,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

View 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

View 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
)