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

@@ -722,7 +722,10 @@ class AssessmentPortal(CustomerPortal):
# Post message to chatter with photos # Post message to chatter with photos
sale_order.message_post( sale_order.message_post(
body=f"<p><strong>Assessment Photos</strong><br/>Photos from assessment {assessment.reference} by {request.env.user.name}</p>", body=Markup(
'<p><strong>Assessment Photos</strong><br/>'
'Photos from assessment %s by %s</p>'
) % (assessment.reference, request.env.user.name),
message_type='comment', message_type='comment',
subtype_xmlid='mail.mt_comment', subtype_xmlid='mail.mt_comment',
attachment_ids=attachment_ids, attachment_ids=attachment_ids,

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Chatter Enhance',
'version': '19.0.1.0.0',
'category': 'Productivity',
'summary': 'Resizable, collapsible chatter panel with per-user position preference.',
'description': """
Fusion Chatter Enhance
======================
Enhances the Odoo chatter panel with:
- Drag-to-resize: grab the panel edge to change width
- Quick toggle: hover button to show/hide the chatter
- Bottom position: move chatter below the form in user preferences
- Per-user setting: each user picks their own chatter layout
- Icon-only topbar buttons with tooltips for compact display
Copyright 2024-2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': [
'base',
'mail',
],
'data': [
'views/res_users_views.xml',
'views/templates.xml',
],
'assets': {
'web.assets_backend': [
'fusion_chatter_enhance/static/src/scss/chatter_enhance.scss',
'fusion_chatter_enhance/static/src/js/chatter_panel.js',
],
},
'installable': True,
'auto_install': False,
'application': False,
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import ir_http
from . import res_users

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1
from odoo import models
from odoo.http import request
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
def session_info(self):
result = super().session_info()
if request.session.uid:
user = self.env.user
result['chatter_position'] = user.chatter_position or 'side'
result['chatter_hidden'] = user.chatter_hidden or False
return result

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
class ResUsers(models.Model):
_inherit = 'res.users'
chatter_position = fields.Selection(
selection=[
('side', 'Side (right of form)'),
('bottom', 'Bottom (below form)'),
],
string='Chatter Position',
default='side',
)
chatter_hidden = fields.Boolean(
string='Chatter Hidden',
default=False,
)
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + ['chatter_position', 'chatter_hidden']
@property
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_WRITEABLE_FIELDS + ['chatter_position', 'chatter_hidden']

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1,424 @@
// Fusion Chatter Enhance
// Copyright 2024-2026 Nexa Systems Inc.
// License OPL-1
//
// Resizable & collapsible chatter panel with per-user position preference.
//
// ARCHITECTURE:
// 1. On script load: read localStorage, inject <style> for anti-flash.
// 2. On DOMContentLoaded: read odoo.__session_info__ (server-injected),
// sync to localStorage, then apply the definitive layout.
// 3. MutationObserver re-applies layout on SPA navigations.
// 4. No RPC calls needed -- preferences come from session_info.
(function () {
'use strict';
var STORAGE_KEY_WIDTH = 'fce_chatter_width';
var STORAGE_KEY_HIDDEN = 'fce_chatter_hidden';
var STORAGE_KEY_POSITION = 'fce_chatter_position';
var MIN_CHATTER_PCT = 15;
var MAX_CHATTER_PCT = 50;
var DEFAULT_CHATTER_PCT = 20;
var STYLE_TAG_ID = 'fce-width-override';
var TOOLTIPS = {
'.o-mail-Chatter-sendMessage': 'Send Message',
'.o-mail-Chatter-logNote': 'Log Note',
'button[data-hotkey="shift+w"]': 'WhatsApp',
'.o-mail-Chatter-activity': 'Schedule Activity',
};
var _enhanceTimer = null;
var _sessionSynced = false;
// =========================================================================
// EARLY <style> INJECTION (runs before DOM is ready)
// =========================================================================
var SEL_RENDERER = '.o_action_manager .o_form_view .o_form_renderer';
var SEL_SHEET = SEL_RENDERER + ':not(.fce-chatter-bottom) > .o_form_sheet_bg';
var SEL_CHATTER = SEL_RENDERER + ':not(.fce-chatter-bottom) > .o-mail-ChatterContainer, ' +
SEL_RENDERER + ':not(.fce-chatter-bottom) > .o-mail-Form-chatter, ' +
SEL_RENDERER + ':not(.fce-chatter-bottom) > .o-aside';
function buildWidthCss(chatterPct) {
var formPct = 100 - chatterPct;
return '@media (min-width:992px){' +
SEL_SHEET + '{flex:0 0 ' + formPct + '% !important;width:' + formPct + '% !important;max-width:' + formPct + '% !important;}' +
SEL_CHATTER + '{flex:0 0 ' + chatterPct + '% !important;width:' + chatterPct + '% !important;}' +
'}';
}
function injectOrUpdateStyleTag(css) {
var tag = document.getElementById(STYLE_TAG_ID);
if (!tag) {
tag = document.createElement('style');
tag.id = STYLE_TAG_ID;
tag.setAttribute('type', 'text/css');
(document.head || document.documentElement).appendChild(tag);
}
tag.textContent = css;
}
// =========================================================================
// SESSION INFO READER
// =========================================================================
function getSessionInfo() {
try {
var o = window.odoo;
if (!o) return null;
return o.__session_info__ || o.session_info || null;
} catch (_) {}
return null;
}
function syncFromSessionInfo() {
if (_sessionSynced) return;
var si = getSessionInfo();
if (!si || !si.uid) return;
_sessionSynced = true;
if (si.chatter_position) {
localStorage.setItem(STORAGE_KEY_POSITION, si.chatter_position);
}
if (typeof si.chatter_hidden === 'boolean') {
localStorage.setItem(STORAGE_KEY_HIDDEN, si.chatter_hidden ? '1' : '0');
}
}
function applyEarlyStyles() {
syncFromSessionInfo();
var pos = localStorage.getItem(STORAGE_KEY_POSITION) || 'side';
if (pos === 'bottom') {
injectOrUpdateStyleTag('');
return;
}
if (localStorage.getItem(STORAGE_KEY_HIDDEN) === '1') {
injectOrUpdateStyleTag(buildWidthCss(0));
return;
}
var raw = localStorage.getItem(STORAGE_KEY_WIDTH);
var pct = raw ? parseFloat(raw) : NaN;
if (isNaN(pct) || pct < MIN_CHATTER_PCT || pct > MAX_CHATTER_PCT) pct = DEFAULT_CHATTER_PCT;
injectOrUpdateStyleTag(buildWidthCss(pct));
}
applyEarlyStyles();
// =========================================================================
// WRITE HELPERS (save to backend)
// =========================================================================
function getSessionUid() {
var si = getSessionInfo();
return si && si.uid ? si.uid : null;
}
function rpcCall(model, method, args, kwargs, callback) {
try {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/web/dataset/call_kw/' + model + '/' + method, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
if (xhr.status !== 200) { if (callback) callback(null); return; }
try {
var resp = JSON.parse(xhr.responseText);
if (resp && resp.error) { if (callback) callback(null); return; }
if (callback) callback(resp && resp.result);
} catch (_) {
if (callback) callback(null);
}
};
xhr.send(JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: { model: model, method: method, args: args, kwargs: kwargs || {} },
}));
} catch (_) {
if (callback) callback(null);
}
}
function saveHiddenPref(hidden) {
localStorage.setItem(STORAGE_KEY_HIDDEN, hidden ? '1' : '0');
var uid = getSessionUid();
if (!uid) return;
rpcCall('res.users', 'write', [[uid], { chatter_hidden: hidden }], {}, null);
}
// =========================================================================
// STORAGE HELPERS
// =========================================================================
function getStoredWidth() {
var v = localStorage.getItem(STORAGE_KEY_WIDTH);
var n = v ? parseFloat(v) : NaN;
if (isNaN(n) || n < MIN_CHATTER_PCT || n > MAX_CHATTER_PCT) return DEFAULT_CHATTER_PCT;
return n;
}
function getStoredPosition() {
return localStorage.getItem(STORAGE_KEY_POSITION) || 'side';
}
function isHidden() {
return localStorage.getItem(STORAGE_KEY_HIDDEN) === '1';
}
// =========================================================================
// DOM FINDERS
// =========================================================================
function findFormRenderer() {
return document.querySelector('.o_action_manager .o_form_view .o_form_renderer');
}
function findChatterEl(renderer) {
if (!renderer) return null;
return renderer.querySelector('.o-mail-ChatterContainer') ||
renderer.querySelector('.o-mail-Form-chatter') ||
renderer.querySelector('.o-aside');
}
function findSheetBg(renderer) {
if (!renderer) return null;
return renderer.querySelector('.o_form_sheet_bg');
}
// =========================================================================
// WIDTH APPLICATION
// =========================================================================
function applyWidths(renderer, chatterPct) {
injectOrUpdateStyleTag(buildWidthCss(chatterPct));
var sheet = findSheetBg(renderer);
var chatter = findChatterEl(renderer);
if (!sheet || !chatter) return;
var formPct = 100 - chatterPct;
sheet.style.flex = '0 0 ' + formPct + '%';
sheet.style.width = formPct + '%';
sheet.style.maxWidth = formPct + '%';
sheet.style.minWidth = '0';
chatter.style.flex = '0 0 ' + chatterPct + '%';
chatter.style.width = chatterPct + '%';
}
function clearInlineWidths(renderer) {
var sheet = findSheetBg(renderer);
var chatter = findChatterEl(renderer);
if (sheet) sheet.removeAttribute('style');
if (chatter) chatter.removeAttribute('style');
}
// =========================================================================
// RESIZE HANDLE
// =========================================================================
function injectResizeHandle(renderer) {
if (renderer.querySelector('.fce-resize-handle')) return;
var chatter = findChatterEl(renderer);
if (!chatter) return;
var handle = document.createElement('div');
handle.className = 'fce-resize-handle';
chatter.parentNode.insertBefore(handle, chatter);
var startX = 0;
var startWidth = 0;
function onMouseDown(e) {
e.preventDefault();
startX = e.clientX;
startWidth = chatter.getBoundingClientRect().width;
handle.classList.add('fce-dragging');
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
function onMouseMove(e) {
var delta = startX - e.clientX;
var rendererWidth = renderer.getBoundingClientRect().width;
var newWidth = startWidth + delta;
var pct = (newWidth / rendererWidth) * 100;
pct = Math.max(MIN_CHATTER_PCT, Math.min(MAX_CHATTER_PCT, pct));
applyWidths(renderer, pct);
}
function onMouseUp() {
handle.classList.remove('fce-dragging');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
var rendererWidth = renderer.getBoundingClientRect().width;
var chatterWidth = chatter.getBoundingClientRect().width;
var pct = Math.round((chatterWidth / rendererWidth) * 100);
localStorage.setItem(STORAGE_KEY_WIDTH, String(pct));
injectOrUpdateStyleTag(buildWidthCss(pct));
}
handle.addEventListener('mousedown', onMouseDown);
}
// =========================================================================
// TOGGLE / EXPAND BUTTONS
// =========================================================================
function injectToggleBtn(renderer) {
if (renderer.querySelector('.fce-toggle-btn') || renderer.querySelector('.fce-expand-btn')) return;
var chatter = findChatterEl(renderer);
if (!chatter) return;
var btn = document.createElement('button');
btn.className = 'fce-toggle-btn';
btn.type = 'button';
btn.innerHTML = '<i class="fa fa-chevron-right"></i>';
btn.title = 'Hide chatter';
chatter.style.position = 'relative';
chatter.appendChild(btn);
btn.addEventListener('click', function () {
saveHiddenPref(true);
renderer.classList.add('fce-chatter-hidden');
injectOrUpdateStyleTag(buildWidthCss(0));
btn.remove();
injectExpandBtn(renderer);
});
}
function injectExpandBtn(renderer) {
if (renderer.querySelector('.fce-expand-btn')) return;
var btn = document.createElement('button');
btn.className = 'fce-expand-btn';
btn.type = 'button';
btn.innerHTML = '<i class="fa fa-chevron-left"></i>';
btn.title = 'Show chatter';
renderer.appendChild(btn);
btn.addEventListener('click', function () {
saveHiddenPref(false);
renderer.classList.remove('fce-chatter-hidden');
btn.remove();
var savedPct = getStoredWidth();
applyWidths(renderer, savedPct);
injectResizeHandle(renderer);
injectToggleBtn(renderer);
});
}
// =========================================================================
// TOOLTIPS
// =========================================================================
function applyTooltips() {
var keys = Object.keys(TOOLTIPS);
for (var i = 0; i < keys.length; i++) {
var selector = keys[i];
var title = TOOLTIPS[selector];
var els = document.querySelectorAll(selector);
for (var j = 0; j < els.length; j++) {
if (!els[j].getAttribute('title')) els[j].setAttribute('title', title);
}
}
}
// =========================================================================
// LAYOUT APPLICATION
// =========================================================================
function applyLayout(renderer) {
var pos = getStoredPosition();
var hidden = isHidden();
if (pos === 'bottom') {
renderer.classList.add('fce-chatter-bottom');
renderer.classList.remove('fce-chatter-hidden');
injectOrUpdateStyleTag('');
removeEl(renderer, '.fce-resize-handle');
removeEl(renderer, '.fce-toggle-btn');
removeEl(renderer, '.fce-expand-btn');
renderer.style.display = 'block';
var sheet = findSheetBg(renderer);
var chatter = findChatterEl(renderer);
if (sheet) {
sheet.style.flex = 'none';
sheet.style.width = '100%';
sheet.style.maxWidth = '100%';
sheet.style.minWidth = '100%';
}
if (chatter) {
chatter.style.flex = 'none';
chatter.style.width = '100%';
chatter.style.maxWidth = '100%';
chatter.style.minWidth = '100%';
}
return;
}
renderer.classList.remove('fce-chatter-bottom');
renderer.style.display = '';
if (hidden) {
renderer.classList.add('fce-chatter-hidden');
injectOrUpdateStyleTag(buildWidthCss(0));
removeEl(renderer, '.fce-resize-handle');
removeEl(renderer, '.fce-toggle-btn');
injectExpandBtn(renderer);
return;
}
renderer.classList.remove('fce-chatter-hidden');
removeEl(renderer, '.fce-expand-btn');
var savedPct = getStoredWidth();
applyWidths(renderer, savedPct);
injectResizeHandle(renderer);
injectToggleBtn(renderer);
applyTooltips();
}
function removeEl(renderer, selector) {
var el = renderer.querySelector(selector);
if (el) el.remove();
}
// =========================================================================
// MAIN ORCHESTRATOR
// =========================================================================
function enhance() {
var renderer = findFormRenderer();
if (!renderer) return;
var chatter = findChatterEl(renderer);
if (!chatter) return;
if (window.innerWidth < 992) return;
syncFromSessionInfo();
applyLayout(renderer);
}
function debouncedEnhance() {
if (_enhanceTimer) clearTimeout(_enhanceTimer);
_enhanceTimer = setTimeout(enhance, 150);
}
var observer = new MutationObserver(debouncedEnhance);
function boot() {
syncFromSessionInfo();
applyEarlyStyles();
observer.observe(document.body, { childList: true, subtree: true });
enhance();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();

View File

@@ -0,0 +1,341 @@
// Fusion Chatter Enhance - Styles
// Copyright 2024-2026 Nexa Systems Inc.
// License OPL-1
//
// NOTE: The chatter/form flex percentages are NOT set here.
// They are injected by chatter_panel.js as a <style> tag in <head>
// from localStorage, so the correct width is applied BEFORE first paint.
// =============================================================================
// FORM LAYOUT FOUNDATION (flex row, no-wrap) -- percentages come from JS
// =============================================================================
@media (min-width: 992px) {
.o_action_manager > .o_action > .o_form_view .o_form_renderer:not(.fce-chatter-bottom) {
display: flex !important;
flex-wrap: nowrap !important;
> .o_form_sheet_bg {
min-width: 0 !important;
}
> .o-mail-ChatterContainer,
> .o-mail-Form-chatter,
> .o-aside {
min-width: 200px !important;
max-width: 50% !important;
position: relative;
}
}
.o_action_manager .o_form_sheet_bg {
max-width: none !important;
}
.o_action_manager .o_form_sheet {
max-width: none !important;
width: 100% !important;
}
}
// =============================================================================
// CHATTER BOTTOM LAYOUT (per-user preference)
// High specificity to override Odoo's flex layout.
// =============================================================================
.o_action_manager .o_form_view .o_form_renderer.fce-chatter-bottom {
display: block !important;
flex-direction: column !important;
flex-wrap: wrap !important;
> .o_form_sheet_bg {
flex: none !important;
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
}
> .o-mail-ChatterContainer,
> .o-mail-Form-chatter,
> .o-aside {
flex: none !important;
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
}
> .fce-resize-handle {
display: none !important;
}
}
// =============================================================================
// CHATTER HIDDEN STATE
// =============================================================================
.fce-chatter-hidden {
> .o-mail-ChatterContainer,
> .o-mail-Form-chatter,
> .o-aside {
display: none !important;
}
> .o_form_sheet_bg {
flex: 1 1 100% !important;
width: 100% !important;
max-width: 100% !important;
}
}
// =============================================================================
// RESIZE HANDLE
// Idle: subtle dotted line so users know where to grab.
// Hover: dots merge into a solid line with a glow pulse.
// Dragging: bold solid accent bar.
// =============================================================================
.fce-resize-handle {
width: 7px;
cursor: col-resize;
background: transparent;
position: relative;
z-index: 10;
flex-shrink: 0;
align-self: stretch;
// --- idle dotted line (always visible) ---
&::before {
content: '';
position: absolute;
top: 10%;
bottom: 10%;
left: 50%;
transform: translateX(-50%);
width: 2px;
border: none;
background-image: repeating-linear-gradient(
to bottom,
var(--o-border-color, #ced4da) 0px,
var(--o-border-color, #ced4da) 3px,
transparent 3px,
transparent 8px
);
opacity: 0.45;
border-radius: 1px;
transition: opacity 0.3s ease, background-image 0.3s ease, width 0.25s ease,
box-shadow 0.3s ease, top 0.3s ease, bottom 0.3s ease;
}
// --- hover grip dots (centered cluster) ---
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 5px;
height: 28px;
opacity: 0;
border-radius: 3px;
background-image: radial-gradient(
circle,
var(--o-border-color, #adb5bd) 1.2px,
transparent 1.2px
);
background-size: 5px 7px;
background-repeat: repeat-y;
background-position: center;
transition: opacity 0.25s ease, height 0.3s ease, transform 0.25s ease;
}
// --- HOVER STATE ---
&:hover {
&::before {
opacity: 0.85;
width: 2px;
top: 4%;
bottom: 4%;
background-image: repeating-linear-gradient(
to bottom,
var(--o-action, #0077b6) 0px,
var(--o-action, #0077b6) 3px,
transparent 3px,
transparent 8px
);
box-shadow: 0 0 6px rgba(0, 119, 182, 0.2);
}
&::after {
opacity: 1;
height: 36px;
}
}
// --- DRAGGING STATE ---
&.fce-dragging {
&::before {
opacity: 1;
width: 3px;
top: 0;
bottom: 0;
background-image: none;
background-color: var(--o-action, #0077b6);
box-shadow: 0 0 10px rgba(0, 119, 182, 0.35);
border-radius: 2px;
}
&::after {
opacity: 0;
}
}
}
// =============================================================================
// TOGGLE BUTTON
// =============================================================================
.fce-toggle-btn {
position: absolute;
top: 8px;
left: -14px;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--o-border-color, #dee2e6);
background: var(--o-bg-card, #fff);
color: var(--o-text-muted, #6c757d);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 20;
opacity: 0;
transition: opacity 0.2s ease, background-color 0.15s ease, color 0.15s ease;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
font-size: 12px;
padding: 0;
&:hover {
background: var(--o-action, #0077b6);
color: #fff;
border-color: var(--o-action, #0077b6);
}
}
.o-mail-ChatterContainer:hover .fce-toggle-btn,
.o-mail-Form-chatter:hover .fce-toggle-btn,
.o-aside:hover .fce-toggle-btn {
opacity: 1;
}
.fce-expand-btn {
position: fixed;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 56px;
border-radius: 6px 0 0 6px;
border: 1px solid var(--o-border-color, #dee2e6);
border-right: none;
background: var(--o-bg-card, #fff);
color: var(--o-text-muted, #6c757d);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 20;
box-shadow: -2px 0 6px rgba(0, 0, 0, 0.08);
font-size: 14px;
padding: 0;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--o-action, #0077b6);
color: #fff;
}
}
// =============================================================================
// COMPACT CHATTER CONTENT
// =============================================================================
.o-mail-Thread .o-mail-Message {
padding: 6px 10px !important;
font-size: 0.9em;
}
.o-mail-Activity {
padding: 4px 8px !important;
}
// =============================================================================
// ICON-ONLY TOPBAR BUTTONS
// =============================================================================
.o-mail-Chatter-topbar {
gap: 4px;
.o-mail-Chatter-sendMessage {
font-size: 0 !important;
padding: 8px 12px !important;
min-width: auto;
line-height: 1;
&::before {
font-family: "Font Awesome 5 Free", FontAwesome;
font-weight: 900;
font-size: 15px;
content: "\f0e0";
}
}
.o-mail-Chatter-logNote {
font-size: 0 !important;
padding: 8px 12px !important;
min-width: auto;
line-height: 1;
&::before {
font-family: "Font Awesome 5 Free", FontAwesome;
font-weight: 900;
font-size: 15px;
content: "\f044";
}
}
button[data-hotkey="shift+w"] {
> span { display: none !important; }
padding: 8px 12px !important;
min-width: auto;
line-height: 1;
&::before {
content: "";
display: inline-block;
width: 17px;
height: 17px;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z'/%3E%3C/svg%3E");
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
background-color: currentColor;
}
}
.o-mail-Chatter-activity {
> span { display: none !important; }
padding: 8px 12px !important;
min-width: auto;
line-height: 1;
&::before {
font-family: "Font Awesome 5 Free", FontAwesome;
font-weight: 900;
font-size: 15px;
content: "\f073";
}
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add chatter preferences to the "other_preferences" group in user preferences form -->
<record id="view_users_form_chatter_prefs" model="ir.ui.view">
<field name="name">res.users.form.chatter.prefs</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='other_preferences']" position="inside">
<label for="chatter_position"/>
<div>
<field name="chatter_position" widget="radio"/>
</div>
<label for="chatter_hidden"/>
<div>
<field name="chatter_hidden"/>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Inject chatter preferences into localStorage BEFORE the asset bundle loads.
This runs as an inline script right after the Odoo bootstrap script,
guaranteeing the values are available before our IIFE in the bundle executes.
-->
<template id="chatter_prefs_inject" inherit_id="web.layout" name="Chatter Preferences Early Inject">
<xpath expr="//script[@id='web.layout.odooscript']" position="after">
<t t-if="request.session.uid">
<script type="text/javascript">
(function(){
try {
localStorage.setItem('fce_chatter_position', '<t t-esc="request.env.user.chatter_position or 'side'"/>');
localStorage.setItem('fce_chatter_hidden', '<t t-esc="'1' if request.env.user.chatter_hidden else '0'"/>');
} catch(e){}
})();
</script>
</t>
</xpath>
</template>
</odoo>

View File

@@ -1,15 +1,54 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc. # Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family. # Part of the Fusion Claim Assistant product family.
import logging
from . import models from . import models
from . import wizard from . import wizard
_logger = logging.getLogger(__name__)
def _load_adp_device_codes(env): def _load_adp_device_codes(env):
""" """Post-init hook: load device codes then link products to them.
Post-init hook to load ADP Mobility Manual device codes.
Called on module install AND upgrade. Called on module install AND upgrade. Each step is idempotent.
""" """
env['fusion.adp.device.code']._load_packaged_device_codes() env['fusion.adp.device.code']._load_packaged_device_codes()
_link_products_to_device_codes(env)
def _link_products_to_device_codes(env):
"""Populate x_fc_adp_device_code_id and x_fc_is_adp_product for
existing products that already have a device code string set.
Uses raw SQL for speed since this may touch hundreds of rows.
"""
cr = env.cr
cr.execute("""
UPDATE product_template pt
SET x_fc_adp_device_code_id = adc.id,
x_fc_adp_price = adc.adp_price,
x_fc_is_adp_product = TRUE
FROM fusion_adp_device_code adc
WHERE pt.x_fc_adp_device_code IS NOT NULL
AND pt.x_fc_adp_device_code != ''
AND adc.device_code = pt.x_fc_adp_device_code
AND adc.active = TRUE
AND pt.x_fc_adp_device_code_id IS NULL
""")
linked = cr.rowcount
_logger.info("ADP migration: linked %d products to device code records", linked)
cr.execute("""
UPDATE product_template
SET x_fc_is_adp_product = TRUE
WHERE x_fc_adp_device_code IS NOT NULL
AND x_fc_adp_device_code != ''
AND (x_fc_is_adp_product IS NULL OR x_fc_is_adp_product = FALSE)
""")
toggled = cr.rowcount
_logger.info("ADP migration: toggled x_fc_is_adp_product on %d products", toggled)

View File

@@ -136,6 +136,7 @@
'wizard/xml_import_wizard_views.xml', 'wizard/xml_import_wizard_views.xml',
'views/adp_claims_views.xml', 'views/adp_claims_views.xml',
'views/submission_history_views.xml', 'views/submission_history_views.xml',
'views/product_template_adp_views.xml',
'views/fusion_loaner_views.xml', 'views/fusion_loaner_views.xml',
'views/page11_sign_request_views.xml', 'views/page11_sign_request_views.xml',
'views/technician_task_views.xml', 'views/technician_task_views.xml',
@@ -160,7 +161,6 @@
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend': [
'fusion_claims/static/src/scss/fusion_claims.scss', 'fusion_claims/static/src/scss/fusion_claims.scss',
'fusion_claims/static/src/js/chatter_resize.js',
'fusion_claims/static/src/js/document_preview.js', 'fusion_claims/static/src/js/document_preview.js',
'fusion_claims/static/src/js/preview_button_widget.js', 'fusion_claims/static/src/js/preview_button_widget.js',
'fusion_claims/static/src/js/status_selection_filter.js', 'fusion_claims/static/src/js/status_selection_filter.js',

View File

@@ -84,6 +84,15 @@ class FusionADPDeviceCode(models.Model):
default=fields.Datetime.now, default=fields.Datetime.now,
) )
# ==========================================================================
# REVERSE LINK TO PRODUCTS
# ==========================================================================
product_template_ids = fields.One2many(
'product.template',
'x_fc_adp_device_code_id',
string='Linked Products',
)
# ========================================================================== # ==========================================================================
# SQL CONSTRAINTS # SQL CONSTRAINTS
# ========================================================================== # ==========================================================================
@@ -92,6 +101,28 @@ class FusionADPDeviceCode(models.Model):
'Device code must be unique!'), 'Device code must be unique!'),
] ]
# ==========================================================================
# WRITE OVERRIDE - push changes to linked products
# ==========================================================================
def write(self, vals):
res = super().write(vals)
sync_fields = {'adp_price', 'device_code'}
if sync_fields & set(vals):
products = self.env['product.template'].sudo().search([
('x_fc_adp_device_code_id', 'in', self.ids)
])
if products:
for product in products:
update = {}
device = product.x_fc_adp_device_code_id
if 'adp_price' in vals:
update['x_fc_adp_price'] = device.adp_price
if 'device_code' in vals:
update['x_fc_adp_device_code'] = device.device_code
if update:
product.sudo().write(update)
return res
# ========================================================================== # ==========================================================================
# COMPUTED FIELDS # COMPUTED FIELDS
# ========================================================================== # ==========================================================================

View File

@@ -10,70 +10,68 @@ class ProductProduct(models.Model):
_inherit = 'product.product' _inherit = 'product.product'
def get_adp_device_code(self): def get_adp_device_code(self):
""" """Get ADP device code, preferring the linked device code record.
Get ADP device code from the field mapped in fusion settings.
Checks in order:
The field name is configured in Settings → Sales → Fusion Central → 1. Linked Many2one device code record on template
Field Mappings → Product ADP Code Field. 2. x_fc_adp_device_code char field on template
3. Mapped field from fusion settings (legacy)
Checks the mapped field on the product variant first, then on template. 4. default_code
Returns the value from the mapped field, or empty string if not found.
""" """
self.ensure_one() self.ensure_one()
tmpl = self.product_tmpl_id
# Get the mapped field name from fusion settings
if tmpl and tmpl.x_fc_adp_device_code_id:
return tmpl.x_fc_adp_device_code_id.device_code or ''
if tmpl and tmpl.x_fc_adp_device_code:
return tmpl.x_fc_adp_device_code
ICP = self.env['ir.config_parameter'].sudo() ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_product_code', 'x_fc_adp_device_code') field_name = ICP.get_param('fusion_claims.field_product_code', 'x_fc_adp_device_code')
if field_name and field_name != 'x_fc_adp_device_code':
if not field_name: if field_name in self._fields:
return '' value = getattr(self, field_name, '') or ''
if value:
# Check if the mapped field exists on the product variant (product.product) return value
if field_name in self._fields: if tmpl and field_name in tmpl._fields:
value = getattr(self, field_name, '') or '' value = getattr(tmpl, field_name, '') or ''
if value: if value:
return value return value
# Check if the mapped field exists on the product template return self.default_code or ''
if self.product_tmpl_id and field_name in self.product_tmpl_id._fields:
value = getattr(self.product_tmpl_id, field_name, '') or ''
if value:
return value
return ''
def get_adp_price(self): def get_adp_price(self):
""" """Get ADP price, preferring the linked device code record.
Get ADP price from the field mapped in fusion settings.
Checks in order:
The field name is configured in Settings → Sales → Fusion Central → 1. Linked Many2one device code record price on template
Field Mappings → Product ADP Price Field. 2. x_fc_adp_price field on template
3. Mapped field from fusion settings (legacy)
Checks the mapped field on the product variant first, then on template. 4. list_price
Returns the value from the mapped field, or 0.0 if not found.
""" """
self.ensure_one() self.ensure_one()
tmpl = self.product_tmpl_id
# Get the mapped field name from fusion settings
if tmpl and tmpl.x_fc_adp_device_code_id and tmpl.x_fc_adp_device_code_id.adp_price:
return tmpl.x_fc_adp_device_code_id.adp_price
if tmpl and tmpl.x_fc_adp_price:
return tmpl.x_fc_adp_price
ICP = self.env['ir.config_parameter'].sudo() ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_product_adp_price', 'x_fc_adp_price') field_name = ICP.get_param('fusion_claims.field_product_adp_price', 'x_fc_adp_price')
if field_name and field_name != 'x_fc_adp_price':
if not field_name: if field_name in self._fields:
return 0.0 value = getattr(self, field_name, 0.0) or 0.0
if value:
# Check if the mapped field exists on the product variant (product.product) return value
if field_name in self._fields: if tmpl and field_name in tmpl._fields:
value = getattr(self, field_name, 0.0) or 0.0 value = getattr(tmpl, field_name, 0.0) or 0.0
if value: if value:
return value return value
# Check if the mapped field exists on the product template return tmpl.list_price if tmpl else 0.0
if self.product_tmpl_id and field_name in self.product_tmpl_id._fields:
value = getattr(self.product_tmpl_id, field_name, 0.0) or 0.0
if value:
return value
return 0.0
def is_non_adp_funded(self): def is_non_adp_funded(self):
""" """
@@ -114,65 +112,71 @@ class ProductProduct(models.Model):
return False return False
def action_sync_adp_price_from_database(self): def action_sync_adp_price_from_database(self):
""" """Sync product ADP data from the device codes database.
Update product's ADP price from the device codes database.
Looks up the product's device code in fusion.adp.device.code and
Looks up the product's ADP device code in the fusion.adp.device.code table populates the Many2one link, price, and device code string.
and updates the product's x_fc_adp_price field with the database value.
Returns a notification with the result.
""" """
ADPDevice = self.env['fusion.adp.device.code'].sudo() ADPDevice = self.env['fusion.adp.device.code'].sudo()
updated = [] updated = []
not_found = [] not_found = []
no_code = [] no_code = []
for product in self: for product in self:
device_code = product.get_adp_device_code() product_tmpl = product.product_tmpl_id
if product_tmpl.x_fc_adp_device_code_id:
adp_device = product_tmpl.x_fc_adp_device_code_id
device_code = adp_device.device_code
else:
device_code = product.get_adp_device_code()
if not device_code: if not device_code:
no_code.append(product.name) no_code.append(product.name)
continue continue
adp_device = ADPDevice.search([ adp_device = ADPDevice.search([
('device_code', '=', device_code), ('device_code', '=', device_code),
('active', '=', True) ('active', '=', True)
], limit=1) ], limit=1)
if adp_device and adp_device.adp_price: if adp_device:
# Update product template old_price = product_tmpl.x_fc_adp_price or 0
product_tmpl = product.product_tmpl_id write_vals = {
old_price = 0 'x_fc_adp_device_code_id': adp_device.id,
'x_fc_adp_device_code': adp_device.device_code,
if hasattr(product_tmpl, 'x_fc_adp_price'): 'x_fc_adp_price': adp_device.adp_price,
old_price = getattr(product_tmpl, 'x_fc_adp_price', 0) or 0 'x_fc_is_adp_product': True,
product_tmpl.sudo().write({'x_fc_adp_price': adp_device.adp_price}) }
updated.append({ product_tmpl.sudo().write(write_vals)
'name': product.name, updated.append({
'code': device_code, 'name': product.name,
'old_price': old_price, 'code': device_code,
'new_price': adp_device.adp_price, 'old_price': old_price,
}) 'new_price': adp_device.adp_price,
})
else: else:
not_found.append(f"{product.name} ({device_code})") not_found.append(f"{product.name} ({device_code})")
# Build result message
message_parts = [] message_parts = []
if updated: if updated:
msg = f"<strong>Updated {len(updated)} product(s):</strong><ul>" msg = f"<strong>Synced {len(updated)} product(s):</strong><ul>"
for u in updated: for u in updated:
msg += f"<li>{u['name']}: ${u['old_price']:.2f} ${u['new_price']:.2f}</li>" msg += f"<li>{u['name']}: ${u['old_price']:.2f} -> ${u['new_price']:.2f}</li>"
msg += "</ul>" msg += "</ul>"
message_parts.append(msg) message_parts.append(msg)
if not_found: if not_found:
message_parts.append(f"<strong>Not found in database:</strong> {', '.join(not_found)}") message_parts.append(
f"<strong>Not found in database:</strong> {', '.join(not_found)}"
)
if no_code: if no_code:
message_parts.append(f"<strong>No ADP code:</strong> {', '.join(no_code)}") message_parts.append(
f"<strong>No ADP code:</strong> {', '.join(no_code)}"
)
if not message_parts: if not message_parts:
message_parts.append("No products to process.") message_parts.append("No products to process.")
return { return {
'type': 'ir.actions.client', 'type': 'ir.actions.client',
'tag': 'display_notification', 'tag': 'display_notification',

View File

@@ -3,7 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family. # Part of the Fusion Claim Assistant product family.
from odoo import api, fields, models from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class ProductTemplate(models.Model): class ProductTemplate(models.Model):
@@ -11,12 +12,26 @@ class ProductTemplate(models.Model):
# ========================================================================== # ==========================================================================
# ADP PRODUCT FIELDS # ADP PRODUCT FIELDS
# These are the module's own fields - independent of Odoo Studio
# ========================================================================== # ==========================================================================
x_fc_adp_device_code = fields.Char( x_fc_is_adp_product = fields.Boolean(
string='ADP Product',
default=False,
tracking=True,
help='Toggle to mark this as an ADP product. Shows ADP fields when enabled.',
)
x_fc_adp_device_code_id = fields.Many2one(
'fusion.adp.device.code',
string='ADP Device Code', string='ADP Device Code',
help='Device code used for ADP claims export', ondelete='set null',
tracking=True,
help='Link to the ADP Mobility Manual device code record',
)
x_fc_adp_device_code = fields.Char(
string='Device Code',
help='Device code string used for ADP claims export',
copy=True, copy=True,
tracking=True, tracking=True,
) )
@@ -24,16 +39,30 @@ class ProductTemplate(models.Model):
x_fc_adp_price = fields.Float( x_fc_adp_price = fields.Float(
string='ADP Price', string='ADP Price',
digits='Product Price', digits='Product Price',
help='ADP retail price for this product. Used in ADP reports and claims.', help='ADP retail price from the device codes database.',
copy=True, copy=True,
tracking=True, tracking=True,
) )
x_fc_is_adp_product = fields.Boolean( x_fc_adp_device_type = fields.Char(
string='Is ADP Product', related='x_fc_adp_device_code_id.device_type',
compute='_compute_is_adp_product', string='Device Type',
store=True, store=True,
help='Indicates if this product has ADP pricing set up', readonly=True,
)
x_fc_adp_build_type = fields.Selection(
related='x_fc_adp_device_code_id.build_type',
string='Build Type',
store=True,
readonly=True,
)
x_fc_adp_max_quantity = fields.Integer(
related='x_fc_adp_device_code_id.max_quantity',
string='Max Quantity',
store=True,
readonly=True,
) )
# ========================================================================== # ==========================================================================
@@ -117,49 +146,65 @@ class ProductTemplate(models.Model):
x_fc_package_info = fields.Text(string='Package Information') x_fc_package_info = fields.Text(string='Package Information')
# ========================================================================== # ==========================================================================
# COMPUTED FIELDS # ONCHANGE / CONSTRAINTS
# ========================================================================== # ==========================================================================
@api.depends('x_fc_adp_device_code', 'x_fc_adp_price') @api.onchange('x_fc_adp_device_code_id')
def _compute_is_adp_product(self): def _onchange_adp_device_code_id(self):
"""Determine if this is an ADP product based on having device code or price.""" """Populate device code string and price from the selected device record."""
if self.x_fc_adp_device_code_id:
self.x_fc_adp_device_code = self.x_fc_adp_device_code_id.device_code
self.x_fc_adp_price = self.x_fc_adp_device_code_id.adp_price
else:
self.x_fc_adp_device_code = False
self.x_fc_adp_price = 0.0
@api.constrains('x_fc_is_adp_product', 'x_fc_adp_device_code_id')
def _check_adp_product_device_code(self):
for product in self: for product in self:
product.x_fc_is_adp_product = bool( if product.x_fc_is_adp_product and not product.x_fc_adp_device_code_id:
product.x_fc_adp_device_code or product.x_fc_adp_price raise ValidationError(
) _("'%s' is marked as an ADP Product but has no ADP Device Code selected.") % product.name
)
# ========================================================================== # ==========================================================================
# HELPER METHODS # HELPER METHODS
# ========================================================================== # ==========================================================================
def get_adp_price(self): def get_adp_price(self):
""" """Get ADP price, preferring the linked device code record.
Get ADP price with fallback to Studio field.
Checks in order: Checks in order:
1. x_fc_adp_price (module field) 1. Linked device code record price
2. list_price (default product price) 2. x_fc_adp_price (stored field)
3. list_price (default product price)
""" """
self.ensure_one() self.ensure_one()
if self.x_fc_adp_device_code_id and self.x_fc_adp_device_code_id.adp_price:
return self.x_fc_adp_device_code_id.adp_price
if self.x_fc_adp_price: if self.x_fc_adp_price:
return self.x_fc_adp_price return self.x_fc_adp_price
return self.list_price or 0.0 return self.list_price or 0.0
def get_adp_device_code(self): def get_adp_device_code(self):
""" """Get ADP device code, preferring the linked device code record.
Get ADP device code.
Checks in order: Checks in order:
1. x_fc_adp_device_code (module field) 1. Linked device code record
2. default_code (internal reference) 2. x_fc_adp_device_code (stored char)
3. default_code (internal reference)
""" """
self.ensure_one() self.ensure_one()
if self.x_fc_adp_device_code_id:
return self.x_fc_adp_device_code_id.device_code or ''
if self.x_fc_adp_device_code: if self.x_fc_adp_device_code:
return self.x_fc_adp_device_code return self.x_fc_adp_device_code
return self.default_code or '' return self.default_code or ''
# ========================================================================== # ==========================================================================

View File

@@ -4626,11 +4626,13 @@ class SaleOrder(models.Model):
f'Product price ${pm["product_price"]:.2f} vs Database ${pm["db_price"]:.2f}</li>' f'Product price ${pm["product_price"]:.2f} vs Database ${pm["db_price"]:.2f}</li>'
) )
mismatch_msg += '</ul><p>Database prices were used. Consider updating product prices.</p>' mismatch_msg += '</ul><p>Database prices were used. Consider updating product prices.</p>'
self.message_post(body=mismatch_msg, message_type='notification', subtype_xmlid='mail.mt_note') self.message_post(body=Markup(mismatch_msg), message_type='notification', subtype_xmlid='mail.mt_note')
# Auto-update product prices from database # Auto-update product prices from database (skip if managed via Many2one link)
for pm in price_mismatches: for pm in price_mismatches:
product_tmpl = pm['product'].product_tmpl_id product_tmpl = pm['product'].product_tmpl_id
if product_tmpl.x_fc_adp_device_code_id:
continue
if hasattr(product_tmpl, 'x_fc_adp_price'): if hasattr(product_tmpl, 'x_fc_adp_price'):
product_tmpl.sudo().write({'x_fc_adp_price': pm['db_price']}) product_tmpl.sudo().write({'x_fc_adp_price': pm['db_price']})
@@ -6905,9 +6907,11 @@ class SaleOrder(models.Model):
# Post to chatter # Post to chatter
days_since_billed = (today - order.x_fc_billing_date).days days_since_billed = (today - order.x_fc_billing_date).days
order.message_post( order.message_post(
body=f'<p><strong><i class="fa fa-check-circle text-success"/> Case Automatically Closed</strong></p>' body=Markup(
f'<p>This case has been automatically closed after {days_since_billed} days since billing.</p>' '<p><strong><i class="fa fa-check-circle text-success"/> Case Automatically Closed</strong></p>'
f'<p>Billing Date: {order.x_fc_billing_date}</p>', '<p>This case has been automatically closed after %s days since billing.</p>'
'<p>Billing Date: %s</p>'
) % (days_since_billed, order.x_fc_billing_date),
message_type='notification', message_type='notification',
subtype_xmlid='mail.mt_note', subtype_xmlid='mail.mt_note',
) )

View File

@@ -1,40 +0,0 @@
/** @odoo-module **/
// Fusion Claims - Chatter Topbar Tooltips
// Copyright 2024-2026 Nexa Systems Inc.
// License OPL-1
//
// Adds title (tooltip) attributes to chatter topbar buttons that have
// their text hidden via CSS (icon-only mode).
const TOOLTIPS = {
'.o-mail-Chatter-sendMessage': 'Send Message',
'.o-mail-Chatter-logNote': 'Log Note',
'button[data-hotkey="shift+w"]': 'WhatsApp',
'.o-mail-Chatter-activity': 'Schedule Activity',
'.fusion-notes-mic-btn': 'Record Voice Note',
'.o-mail-Chatter-messageAuthorizer': 'Message Authorizer',
};
function applyTooltips() {
for (const [selector, title] of Object.entries(TOOLTIPS)) {
for (const btn of document.querySelectorAll(selector)) {
if (!btn.getAttribute('title')) {
btn.setAttribute('title', title);
}
}
}
}
// Run on DOM changes (OWL re-renders)
const observer = new MutationObserver(() => applyTooltips());
// Start observing once DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
observer.observe(document.body, { childList: true, subtree: true });
applyTooltips();
});
} else {
observer.observe(document.body, { childList: true, subtree: true });
applyTooltips();
}

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
ADP Product toggle and linked device code fields on the product form.
-->
<odoo>
<!-- ADP Product checkbox in the top options row -->
<record id="view_product_template_adp_toggle" model="ir.ui.view">
<field name="name">product.template.form.fusion.claims.adp.toggle</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_form_view"/>
<field name="priority">165</field>
<field name="arch" type="xml">
<xpath expr="//div[@name='options']" position="inside">
<span class="d-inline-flex">
<field name="x_fc_is_adp_product"/>
<label for="x_fc_is_adp_product" string="ADP Product"/>
</span>
</xpath>
</field>
</record>
<!-- ADP Information section (visible only when ADP Product is toggled) -->
<record id="view_product_template_adp_section" model="ir.ui.view">
<field name="name">product.template.form.fusion.claims.adp.section</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view"/>
<field name="priority">170</field>
<field name="arch" type="xml">
<xpath expr="//group[@name='group_standard_price']" position="inside">
<separator string="ADP Information"
invisible="not x_fc_is_adp_product"/>
<field name="x_fc_adp_device_code_id"
invisible="not x_fc_is_adp_product"
required="x_fc_is_adp_product"/>
<field name="x_fc_adp_device_code"
invisible="not x_fc_is_adp_product"
readonly="1"/>
<field name="x_fc_adp_price"
invisible="not x_fc_is_adp_product"
readonly="1"/>
<field name="x_fc_adp_device_type"
invisible="not x_fc_is_adp_product"
readonly="1"/>
<field name="x_fc_adp_build_type"
invisible="not x_fc_is_adp_product"
readonly="1"/>
<field name="x_fc_adp_max_quantity"
invisible="not x_fc_is_adp_product"
readonly="1"/>
</xpath>
</field>
</record>
<!-- ADP filters and grouping in product search view -->
<record id="view_product_template_search_adp" model="ir.ui.view">
<field name="name">product.template.search.fusion.claims.adp</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_search_view"/>
<field name="priority">170</field>
<field name="arch" type="xml">
<xpath expr="//filter[@name='filter_to_sell']" position="after">
<filter string="ADP Product" name="filter_adp_product"
domain="[('x_fc_is_adp_product', '=', True)]"/>
</xpath>
<xpath expr="//filter[@name='group_by_categ_id']" position="after">
<filter string="ADP Device Type" name="group_by_adp_device_type"
context="{'group_by': 'x_fc_adp_device_type'}"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from . import models
from . import wizard
from . import controllers
_logger = logging.getLogger(__name__)
def _post_init_assign_groups(env):
"""Give all internal users the Fusion Inventory / User group,
and all admins the Manager group on install.
Also force-recompute margin and auto-detect brands."""
fi_user = env.ref('fusion_inventory.group_fusion_inventory_user', raise_if_not_found=False)
fi_manager = env.ref('fusion_inventory.group_fusion_inventory_manager', raise_if_not_found=False)
if fi_user:
internal_users = env['res.users'].sudo().search([
('active', '=', True),
('share', '=', False),
])
fi_user.sudo().write({'user_ids': [(4, u.id) for u in internal_users]})
if fi_manager:
group_admin = env.ref('base.group_system', raise_if_not_found=False)
if group_admin:
admin_users = group_admin.sudo().user_ids.filtered('active')
fi_manager.sudo().write({'user_ids': [(4, u.id) for u in admin_users]})
_recompute_margins(env)
_auto_detect_brands(env)
_sync_costs_from_bills(env)
def _recompute_margins(env):
"""Force-recompute x_fi_margin_pct for all products with a sale price and cost."""
try:
products = env['product.template'].sudo().search([
('list_price', '>', 0),
('standard_price', '>', 0),
])
if products:
products._compute_margin_pct()
_logger.info('Recomputed margin for %d products', len(products))
except Exception:
_logger.warning('Failed to recompute margins', exc_info=True)
def _sync_costs_from_bills(env):
"""Update every product's cost from its latest posted vendor bill line."""
try:
products = env['product.template'].sudo().search([])
products._sync_cost_from_latest_bill()
_logger.info('Post-init cost sync complete for %d products', len(products))
except Exception:
_logger.warning('Failed to sync costs from vendor bills', exc_info=True)
def _auto_detect_brands(env):
"""Create product.brand records from existing PO vendors
and link products to their respective brands."""
try:
Brand = env['product.brand'].sudo()
PO = env['purchase.order'].sudo()
orders = PO.search([('state', 'in', ('purchase', 'done'))])
if not orders:
_logger.info('No confirmed POs found, skipping brand auto-detection')
return
vendor_ids = list(set(orders.mapped('partner_id').ids))
partners = env['res.partner'].sudo().browse(vendor_ids)
created = 0
linked = 0
for vendor in partners:
if not vendor.supplier_rank:
continue
brand = Brand.search([('partner_id', '=', vendor.id)], limit=1)
if not brand:
brand = Brand.create({
'name': vendor.name,
'partner_id': vendor.id,
'logo': vendor.image_128 or False,
})
created += 1
vendor_orders = orders.filtered(lambda o: o.partner_id.id == vendor.id)
product_tmpls = vendor_orders.mapped('order_line.product_id.product_tmpl_id')
for tmpl in product_tmpls:
if brand.id not in tmpl.x_fi_brand_ids.ids:
tmpl.write({'x_fi_brand_ids': [(4, brand.id)]})
linked += 1
_logger.info(
'Brand auto-detection: %d brands created, %d product-brand links added',
created, linked)
except Exception:
_logger.warning('Failed to auto-detect brands from POs', exc_info=True)

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Inventory',
'version': '19.0.1.0.0',
'category': 'Inventory',
'summary': 'Advanced inventory management with margin tracking, sync, portal sheet, and inter-company transfers',
'description': """
Fusion Inventory - Comprehensive Inventory Management
=====================================================
* Product margin and profit tracking with real-time calculation
* Automatic cost updates from vendor bills
* Purchase history tracking per product
* Sale order and invoice status tracking on delivery orders
* Serial number scanning and validation
* Product name case conversion (global and per-product)
* Mobile-friendly portal inventory sheet for sales reps
* Product booking system (24h hold)
* Shadow inventory (incoming POs)
* Cross-instance inventory sync via XML-RPC
* Shared warehouse management with ownership tracking
* Automated inter-company transfers with PO/SO/invoice creation
* Inventory discrepancy scanning
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': [
'base',
'stock',
'product',
'sale_management',
'sale_stock',
'purchase',
'account',
'portal',
'website',
'website_sale',
'mail',
'fusion_tasks',
],
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'data/ir_cron_data.xml',
'wizard/serial_scan_wizard_views.xml',
'views/res_config_settings_views.xml',
'views/product_brand_views.xml',
'views/product_template_views.xml',
'views/product_attribute_views.xml',
'views/product_views.xml',
'views/res_partner_views.xml',
'views/stock_picking_views.xml',
'views/sync_config_views.xml',
'views/portal_inventory_templates.xml',
'views/menus.xml',
],
'assets': {},
'images': ['static/description/icon.png'],
'installable': True,
'auto_install': False,
'application': True,
'post_init_hook': '_post_init_assign_groups',
}

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
)

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="icp_case_conversion" model="ir.config_parameter">
<field name="key">fusion_inventory.case_conversion</field>
<field name="value">none</field>
</record>
<record id="icp_auto_update_cost" model="ir.config_parameter">
<field name="key">fusion_inventory.auto_update_cost</field>
<field name="value">True</field>
</record>
<record id="icp_default_margin" model="ir.config_parameter">
<field name="key">fusion_inventory.default_margin</field>
<field name="value">0</field>
</record>
<record id="icp_booking_hold_hours" model="ir.config_parameter">
<field name="key">fusion_inventory.booking_hold_hours</field>
<field name="value">24</field>
</record>
<record id="icp_openai_api_key" model="ir.config_parameter">
<field name="key">fusion_inventory.openai_api_key</field>
<field name="value"></field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="cron_sync_remote_inventory" model="ir.cron">
<field name="name">Fusion Inventory: Sync Remote Inventory</field>
<field name="model_id" ref="fusion_inventory.model_fusion_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_sync_inventory()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<record id="cron_expire_bookings" model="ir.cron">
<field name="name">Fusion Inventory: Expire Stale Bookings</field>
<field name="model_id" ref="fusion_inventory.model_fusion_inventory_booking"/>
<field name="state">code</field>
<field name="code">model._cron_expire_bookings()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<record id="cron_scan_discrepancies" model="ir.cron">
<field name="name">Fusion Inventory: Scan Discrepancies</field>
<field name="model_id" ref="fusion_inventory.model_fusion_inventory_discrepancy"/>
<field name="state">code</field>
<field name="code">model._cron_scan_discrepancies()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View 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

View 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

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

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

View 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

View 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},
}

View 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

View 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

View File

@@ -1,9 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from odoo import models, fields # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionProductSyncMapping(models.Model): class FusionProductSyncMapping(models.Model):
"""Maps local products to remote products for inventory sync."""
_name = 'fusion.product.sync.mapping' _name = 'fusion.product.sync.mapping'
_description = 'Product Sync Mapping' _description = 'Product Sync Mapping'
_rec_name = 'remote_product_name' _rec_name = 'remote_product_name'
@@ -11,26 +13,40 @@ class FusionProductSyncMapping(models.Model):
config_id = fields.Many2one('fusion.sync.config', string='Sync Config', config_id = fields.Many2one('fusion.sync.config', string='Sync Config',
required=True, ondelete='cascade') required=True, ondelete='cascade')
# Local product link
local_product_id = fields.Many2one('product.template', string='Local Product', local_product_id = fields.Many2one('product.template', string='Local Product',
help='The matching product in this Odoo instance') help='The matching product in this Odoo instance')
auto_matched = fields.Boolean(string='Auto-Matched', auto_matched = fields.Boolean(string='Auto-Matched',
help='True if the product was automatically matched by SKU or name') help='True if the product was automatically matched by SKU or name')
# Remote product info
remote_product_id = fields.Integer(string='Remote Product ID', index=True) remote_product_id = fields.Integer(string='Remote Product ID', index=True)
remote_product_name = fields.Char(string='Remote Product Name') remote_product_name = fields.Char(string='Remote Product Name')
remote_default_code = fields.Char(string='Remote SKU/Reference') 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_list_price = fields.Float(string='Remote Price')
remote_category = fields.Char(string='Remote Category') remote_category = fields.Char(string='Remote Category')
# Remote stock levels (updated by sync) remote_qty_available = fields.Float(
remote_qty_available = fields.Float(string='Remote On Hand', readonly=True, string='Remote On Hand',
help='Quantity currently on hand at the remote location') compute='_compute_remote_totals', store=True, readonly=True)
remote_qty_forecast = fields.Float(string='Remote Forecast', readonly=True, remote_qty_forecast = fields.Float(
help='Forecasted quantity (on hand - outgoing + incoming)') string='Remote Forecast',
compute='_compute_remote_totals', store=True, readonly=True)
last_stock_sync = fields.Datetime(string='Stock Last Updated', 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 = [ _sql_constraints = [
('unique_remote_product', ('unique_remote_product',
'UNIQUE(config_id, remote_product_id)', 'UNIQUE(config_id, remote_product_id)',

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

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

View 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,
},
}

View 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',
}

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

View 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',
}

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

View File

@@ -1,9 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields from odoo import models, fields
class FusionSyncLog(models.Model): class FusionSyncLog(models.Model):
"""Log sync operations for auditing and debugging."""
_name = 'fusion.sync.log' _name = 'fusion.sync.log'
_description = 'Inventory Sync Log' _description = 'Inventory Sync Log'
_order = 'create_date desc' _order = 'create_date desc'

View 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.'),
]

View 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.'),
]

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

View File

@@ -0,0 +1,33 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_sync_config_manager,fusion.sync.config.manager,model_fusion_sync_config,group_fusion_inventory_manager,1,1,1,1
access_sync_config_user,fusion.sync.config.user,model_fusion_sync_config,group_fusion_inventory_user,1,0,0,0
access_sync_mapping_manager,fusion.product.sync.mapping.manager,model_fusion_product_sync_mapping,group_fusion_inventory_manager,1,1,1,1
access_sync_mapping_user,fusion.product.sync.mapping.user,model_fusion_product_sync_mapping,group_fusion_inventory_user,1,0,0,0
access_sync_mapping_internal,fusion.product.sync.mapping.internal,model_fusion_product_sync_mapping,base.group_user,1,0,0,0
access_sync_log_manager,fusion.sync.log.manager,model_fusion_sync_log,group_fusion_inventory_manager,1,1,1,1
access_sync_log_user,fusion.sync.log.user,model_fusion_sync_log,group_fusion_inventory_user,1,0,0,0
access_booking_manager,fusion.inventory.booking.manager,model_fusion_inventory_booking,group_fusion_inventory_manager,1,1,1,1
access_booking_user,fusion.inventory.booking.user,model_fusion_inventory_booking,group_fusion_inventory_user,1,1,1,0
access_booking_portal,fusion.inventory.booking.portal,model_fusion_inventory_booking,base.group_portal,1,1,1,0
access_warehouse_inv_manager,fusion.warehouse.inventory.manager,model_fusion_warehouse_inventory,group_fusion_inventory_manager,1,1,1,1
access_warehouse_inv_user,fusion.warehouse.inventory.user,model_fusion_warehouse_inventory,group_fusion_inventory_user,1,0,0,0
access_inter_transfer_manager,fusion.inter.company.transfer.manager,model_fusion_inter_company_transfer,group_fusion_inventory_manager,1,1,1,1
access_inter_transfer_user,fusion.inter.company.transfer.user,model_fusion_inter_company_transfer,group_fusion_inventory_user,1,0,0,0
access_discrepancy_manager,fusion.inventory.discrepancy.manager,model_fusion_inventory_discrepancy,group_fusion_inventory_manager,1,1,1,1
access_discrepancy_user,fusion.inventory.discrepancy.user,model_fusion_inventory_discrepancy,group_fusion_inventory_user,1,0,0,0
access_serial_scan_wizard,fusion.serial.scan.wizard.user,model_fusion_serial_scan_wizard,group_fusion_inventory_user,1,1,1,1
access_serial_scan_line,fusion.serial.scan.line.user,model_fusion_serial_scan_line,group_fusion_inventory_user,1,1,1,1
access_brand_manager,product.brand.manager,model_product_brand,group_fusion_inventory_manager,1,1,1,1
access_brand_user,product.brand.user,model_product_brand,group_fusion_inventory_user,1,0,0,0
access_brand_internal,product.brand.internal,model_product_brand,base.group_user,1,0,0,0
access_brand_rule_manager,product.brand.pricing.rule.manager,model_product_brand_pricing_rule,group_fusion_inventory_manager,1,1,1,1
access_brand_rule_user,product.brand.pricing.rule.user,model_product_brand_pricing_rule,group_fusion_inventory_user,1,0,0,0
access_brand_rule_internal,product.brand.pricing.rule.internal,model_product_brand_pricing_rule,base.group_user,1,0,0,0
access_sync_warehouse_manager,fusion.sync.warehouse.manager,model_fusion_sync_warehouse,group_fusion_inventory_manager,1,1,1,1
access_sync_warehouse_user,fusion.sync.warehouse.user,model_fusion_sync_warehouse,group_fusion_inventory_user,1,0,0,0
access_sync_warehouse_internal,fusion.sync.warehouse.internal,model_fusion_sync_warehouse,base.group_user,1,0,0,0
access_sync_stock_manager,fusion.sync.stock.manager,model_fusion_sync_stock,group_fusion_inventory_manager,1,1,1,1
access_sync_stock_user,fusion.sync.stock.user,model_fusion_sync_stock,group_fusion_inventory_user,1,0,0,0
access_sync_stock_internal,fusion.sync.stock.internal,model_fusion_sync_stock,base.group_user,1,0,0,0
access_inter_transfer_user_create,fusion.inter.company.transfer.user.create,model_fusion_inter_company_transfer,group_fusion_inventory_user,1,1,1,0
access_inter_transfer_portal,fusion.inter.company.transfer.portal,model_fusion_inter_company_transfer,base.group_portal,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_sync_config_manager fusion.sync.config.manager model_fusion_sync_config group_fusion_inventory_manager 1 1 1 1
3 access_sync_config_user fusion.sync.config.user model_fusion_sync_config group_fusion_inventory_user 1 0 0 0
4 access_sync_mapping_manager fusion.product.sync.mapping.manager model_fusion_product_sync_mapping group_fusion_inventory_manager 1 1 1 1
5 access_sync_mapping_user fusion.product.sync.mapping.user model_fusion_product_sync_mapping group_fusion_inventory_user 1 0 0 0
6 access_sync_mapping_internal fusion.product.sync.mapping.internal model_fusion_product_sync_mapping base.group_user 1 0 0 0
7 access_sync_log_manager fusion.sync.log.manager model_fusion_sync_log group_fusion_inventory_manager 1 1 1 1
8 access_sync_log_user fusion.sync.log.user model_fusion_sync_log group_fusion_inventory_user 1 0 0 0
9 access_booking_manager fusion.inventory.booking.manager model_fusion_inventory_booking group_fusion_inventory_manager 1 1 1 1
10 access_booking_user fusion.inventory.booking.user model_fusion_inventory_booking group_fusion_inventory_user 1 1 1 0
11 access_booking_portal fusion.inventory.booking.portal model_fusion_inventory_booking base.group_portal 1 1 1 0
12 access_warehouse_inv_manager fusion.warehouse.inventory.manager model_fusion_warehouse_inventory group_fusion_inventory_manager 1 1 1 1
13 access_warehouse_inv_user fusion.warehouse.inventory.user model_fusion_warehouse_inventory group_fusion_inventory_user 1 0 0 0
14 access_inter_transfer_manager fusion.inter.company.transfer.manager model_fusion_inter_company_transfer group_fusion_inventory_manager 1 1 1 1
15 access_inter_transfer_user fusion.inter.company.transfer.user model_fusion_inter_company_transfer group_fusion_inventory_user 1 0 0 0
16 access_discrepancy_manager fusion.inventory.discrepancy.manager model_fusion_inventory_discrepancy group_fusion_inventory_manager 1 1 1 1
17 access_discrepancy_user fusion.inventory.discrepancy.user model_fusion_inventory_discrepancy group_fusion_inventory_user 1 0 0 0
18 access_serial_scan_wizard fusion.serial.scan.wizard.user model_fusion_serial_scan_wizard group_fusion_inventory_user 1 1 1 1
19 access_serial_scan_line fusion.serial.scan.line.user model_fusion_serial_scan_line group_fusion_inventory_user 1 1 1 1
20 access_brand_manager product.brand.manager model_product_brand group_fusion_inventory_manager 1 1 1 1
21 access_brand_user product.brand.user model_product_brand group_fusion_inventory_user 1 0 0 0
22 access_brand_internal product.brand.internal model_product_brand base.group_user 1 0 0 0
23 access_brand_rule_manager product.brand.pricing.rule.manager model_product_brand_pricing_rule group_fusion_inventory_manager 1 1 1 1
24 access_brand_rule_user product.brand.pricing.rule.user model_product_brand_pricing_rule group_fusion_inventory_user 1 0 0 0
25 access_brand_rule_internal product.brand.pricing.rule.internal model_product_brand_pricing_rule base.group_user 1 0 0 0
26 access_sync_warehouse_manager fusion.sync.warehouse.manager model_fusion_sync_warehouse group_fusion_inventory_manager 1 1 1 1
27 access_sync_warehouse_user fusion.sync.warehouse.user model_fusion_sync_warehouse group_fusion_inventory_user 1 0 0 0
28 access_sync_warehouse_internal fusion.sync.warehouse.internal model_fusion_sync_warehouse base.group_user 1 0 0 0
29 access_sync_stock_manager fusion.sync.stock.manager model_fusion_sync_stock group_fusion_inventory_manager 1 1 1 1
30 access_sync_stock_user fusion.sync.stock.user model_fusion_sync_stock group_fusion_inventory_user 1 0 0 0
31 access_sync_stock_internal fusion.sync.stock.internal model_fusion_sync_stock base.group_user 1 0 0 0
32 access_inter_transfer_user_create fusion.inter.company.transfer.user.create model_fusion_inter_company_transfer group_fusion_inventory_user 1 1 1 0
33 access_inter_transfer_portal fusion.inter.company.transfer.portal model_fusion_inter_company_transfer base.group_portal 1 0 0 0

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<record id="group_fusion_inventory_user" model="res.groups">
<field name="name">Fusion Inventory / User</field>
<field name="implied_ids" eval="[(4, ref('stock.group_stock_user'))]"/>
</record>
<record id="group_fusion_inventory_manager" model="res.groups">
<field name="name">Fusion Inventory / Manager</field>
<field name="implied_ids" eval="[(4, ref('group_fusion_inventory_user'))]"/>
</record>
<!-- Admins automatically get Manager access (group_user is noupdate=1, handled by post_init_hook) -->
<record id="base.group_system" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fusion_inventory_manager'))]"/>
</record>
</data>
<data noupdate="1">
<record id="rule_booking_own" model="ir.rule">
<field name="name">Fusion Inventory: Own Bookings Only</field>
<field name="model_id" ref="model_fusion_inventory_booking"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_inventory_user'))]"/>
</record>
<record id="rule_booking_manager" model="ir.rule">
<field name="name">Fusion Inventory: Manager Sees All Bookings</field>
<field name="model_id" ref="model_fusion_inventory_booking"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_inventory_manager'))]"/>
</record>
<record id="rule_booking_portal" model="ir.rule">
<field name="name">Fusion Inventory: Portal Booking Read</field>
<field name="model_id" ref="model_fusion_inventory_booking"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -0,0 +1,17 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { FloatField, floatField } from "@web/views/fields/float/float_field";
export class MarginPercentField extends FloatField {
static template = "fusion_inventory.MarginPercentField";
}
export const marginPercentField = {
...floatField,
component: MarginPercentField,
displayName: "Margin %",
supportedTypes: ["float"],
};
registry.category("fields").add("fi_margin_pct", marginPercentField);

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_inventory.MarginPercentField" t-inherit="web.FloatField" t-inherit-mode="extension">
<xpath expr="//span[hasclass('o_field_float')]" position="after">
<span class="ms-1 text-muted fw-normal">%</span>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<menuitem id="menu_fusion_inventory_root"
name="Fusion Inventory"
parent="stock.menu_stock_root"
sequence="90"/>
<!-- Brands section -->
<menuitem id="menu_fi_brands"
name="Brands / Vendors"
parent="menu_fusion_inventory_root"
action="action_product_brand"
sequence="5"/>
<!-- Sync section -->
<menuitem id="menu_fi_sync"
name="Inventory Sync"
parent="menu_fusion_inventory_root"
sequence="10"/>
<menuitem id="menu_fi_sync_config"
name="Remote Connections"
parent="menu_fi_sync"
action="action_fusion_sync_config"
sequence="10"/>
<menuitem id="menu_fi_product_mapping"
name="Product Mappings"
parent="menu_fi_sync"
action="action_fusion_product_mapping"
sequence="20"/>
<menuitem id="menu_fi_sync_log"
name="Sync Log"
parent="menu_fi_sync"
action="action_fusion_sync_log"
sequence="30"/>
<!-- Warehouse section -->
<menuitem id="menu_fi_warehouse"
name="Shared Warehouse"
parent="menu_fusion_inventory_root"
sequence="20"/>
<menuitem id="menu_fi_warehouse_inventory"
name="Warehouse Inventory"
parent="menu_fi_warehouse"
action="action_warehouse_inventory"
sequence="10"/>
<menuitem id="menu_fi_inter_company"
name="Inter-Company Transfers"
parent="menu_fi_warehouse"
action="action_inter_company_transfer"
sequence="20"/>
<!-- Reports section -->
<menuitem id="menu_fi_reports"
name="Reports"
parent="menu_fusion_inventory_root"
sequence="30"/>
<menuitem id="menu_fi_discrepancies"
name="Discrepancies"
parent="menu_fi_reports"
action="action_inventory_discrepancy"
sequence="10"/>
</data>
</odoo>

View File

@@ -0,0 +1,847 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ═══════════════════════════════════════════════════════════
Portal "My Inventory" link on portal home
═══════════════════════════════════════════════════════════ -->
<template id="portal_my_home_inventory" name="Show Inventory"
inherit_id="portal.portal_my_home"
customize_show="True" priority="60">
<xpath expr="//div[hasclass('o_portal_docs')]" position="before">
<t t-call="portal.portal_docs_entry">
<t t-set="icon" t-value="'/fusion_inventory/static/description/icon.png'"/>
<t t-set="title">Inventory Sheet</t>
<t t-set="url" t-value="'/my/inventory'"/>
<t t-set="text">View live inventory, search products, and book items</t>
<t t-set="placeholder_count" t-value="'fi_inventory'"/>
</t>
</xpath>
</template>
<!-- ═══════════════════════════════════════════════════════════
Portal Inventory Sheet - Main Page
═══════════════════════════════════════════════════════════ -->
<template id="portal_inventory_sheet" name="Inventory Sheet">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container-fluid px-2 px-md-4 py-3" id="fi_inventory_app">
<!-- ── Header ── -->
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h3 class="mb-0">Inventory Sheet</h3>
<small class="text-muted">
<span id="fi_last_update">Just now</span>
<span class="spinner-border spinner-border-sm ms-1 d-none"
id="fi_loading" role="status"/>
</small>
</div>
<!-- ── Search + Filters ── -->
<div class="row g-2 mb-3">
<div class="col-12 col-md-5">
<div class="input-group">
<input type="text" id="fi_search" class="form-control"
placeholder="Search by name, SKU, or serial..."
t-att-value="search"/>
<button class="btn btn-primary" id="fi_search_btn"
type="button">
<i class="fa fa-search"/>
</button>
</div>
</div>
<div class="col-12 col-md-4 position-relative">
<input type="text" id="fi_category_search" class="form-control"
placeholder="Search categories..." autocomplete="off"/>
<input type="hidden" id="fi_category_filter" value=""/>
<div id="fi_category_dropdown"
class="position-absolute w-100 bg-white border rounded-bottom shadow-sm d-none"
style="z-index:1050; max-height:250px; overflow-y:auto; top:100%;">
</div>
</div>
<div class="col-12 col-md-3" t-if="has_sync">
<select id="fi_warehouse_filter" class="form-select">
<option value="all"
t-att-selected="warehouse_filter == 'all'">All Locations</option>
<option value="local"
t-att-selected="warehouse_filter == 'local'">Local Only</option>
<option value="remote"
t-att-selected="warehouse_filter == 'remote'">Remote Only</option>
<t t-foreach="all_warehouses" t-as="wh">
<option t-att-value="wh['id']">
<t t-if="wh['type'] == 'remote'">&#x1F517; </t>
<t t-esc="wh['name']"/>
<t t-if="wh['company']"> (<t t-esc="wh['company']"/>)</t>
</option>
</t>
</select>
</div>
</div>
<!-- ── Compact table styles ── -->
<style>
#fi_inventory_table {
font-size: 0.8rem;
table-layout: fixed;
width: 100%;
}
#fi_inventory_table th,
#fi_inventory_table td {
padding: 0.3rem 0.4rem;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
#fi_inventory_table .fi-col-product {
width: auto;
white-space: normal;
word-wrap: break-word;
}
#fi_inventory_table .fi-col-sku {
width: 80px;
white-space: nowrap;
}
#fi_inventory_table .fi-col-cat {
width: 100px;
white-space: normal;
word-wrap: break-word;
}
#fi_inventory_table .fi-col-num {
width: 50px;
white-space: nowrap;
text-align: right;
}
#fi_inventory_table .fi-col-price {
width: 65px;
white-space: nowrap;
text-align: right;
}
#fi_inventory_table .fi-col-action {
width: 60px;
text-align: center;
white-space: nowrap;
}
#fi_inventory_table thead th {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.02em;
border-bottom: 2px solid #adb5bd;
}
#fi_inventory_table .badge {
font-size: 0.7rem;
padding: 0.15em 0.4em;
}
#fi_inventory_table .btn-sm {
font-size: 0.68rem;
padding: 0.1rem 0.35rem;
}
</style>
<!-- ── Legend ── -->
<div class="d-flex flex-wrap gap-3 mb-3 small">
<span><span class="badge bg-success">&#9679;</span> In Stock</span>
<span><span class="badge bg-warning text-dark">&#9679;</span> Booked</span>
<span><span class="badge bg-info">&#9679;</span> Incoming (PO)</span>
<span><span class="badge bg-danger">&#9679;</span> Out of Stock</span>
<span t-if="has_sync"><span class="badge bg-purple" style="background:#6f42c1!important;">&#9679;</span> Remote</span>
</div>
<!-- ══════════ Desktop Table ══════════ -->
<div class="table-responsive d-none d-md-block">
<table class="table table-hover table-sm table-bordered align-middle"
id="fi_inventory_table">
<thead class="table-light">
<tr>
<th class="fi-col-product">Product</th>
<th class="fi-col-sku">SKU</th>
<th class="fi-col-cat">Category</th>
<th class="fi-col-num">Local</th>
<th class="fi-col-num">Avail</th>
<th class="fi-col-num" t-if="has_sync">Remote</th>
<th class="fi-col-num" t-if="has_sync">Total</th>
<th class="fi-col-num">Booked</th>
<th class="fi-col-num">Incom.</th>
<th class="fi-col-price">Price</th>
<th class="fi-col-num">Margin</th>
<th class="fi-col-action">Action</th>
</tr>
</thead>
<tbody id="fi_table_body">
<t t-foreach="products" t-as="p">
<tr t-att-data-id="p['id']" t-att-data-tmpl="p.get('tmpl_id', 0)">
<td class="fi-col-product">
<strong t-esc="p['name']"/>
</td>
<td class="fi-col-sku text-muted">
<t t-esc="p['default_code']"/>
</td>
<td class="fi-col-cat"><t t-esc="p['category']"/></td>
<td class="fi-col-num"><t t-esc="p['qty_on_hand']"/></td>
<td class="fi-col-num">
<span t-attf-class="fw-bold #{'text-danger' if p['available_qty'] &lt;= 0 else 'text-success'}">
<t t-esc="p['available_qty']"/>
</span>
</td>
<td class="fi-col-num" t-if="has_sync">
<span t-if="p.get('remote_qty', 0) > 0"
class="badge text-white fi-remote-badge"
style="background:#6f42c1; cursor:pointer;"
t-att-title="', '.join([w['warehouse'] + ': ' + str(w['qty']) for w in p.get('remote_warehouses', [])])">
<t t-esc="p['remote_qty']"/>
</span>
<span t-if="p.get('remote_qty', 0) &lt;= 0"
class="text-muted">0</span>
</td>
<td class="fi-col-num" t-if="has_sync">
<strong><t t-esc="p.get('total_qty', p['qty_on_hand'])"/></strong>
</td>
<td class="fi-col-num">
<span t-if="p['booked_qty'] > 0"
class="badge bg-warning text-dark">
<t t-esc="p['booked_qty']"/>
</span>
</td>
<td class="fi-col-num">
<span t-if="p['shadow_qty'] > 0"
class="badge bg-info">
<t t-esc="p['shadow_qty']"/>
</span>
</td>
<td class="fi-col-price">
$<t t-esc="'%.2f' % p['sale_price']"/>
</td>
<td class="fi-col-num">
<span t-attf-class="badge #{'bg-success' if p['margin_pct'] > 0 else 'bg-secondary'}">
<t t-esc="'%.1f' % p['margin_pct']"/>%
</span>
</td>
<td class="fi-col-action">
<div class="btn-group btn-group-sm">
<button t-if="p['available_qty'] > 0"
class="btn btn-sm btn-outline-primary fi-book-btn"
t-att-data-id="p['id']"
t-att-data-name="p['name']">
Book
</button>
<button t-if="has_sync and p.get('remote_qty', 0) > 0 and p['id'] > 0"
class="btn btn-sm btn-outline-secondary fi-transfer-btn"
t-att-data-id="p['id']"
t-att-data-name="p['name']"
t-att-data-remote-qty="p['remote_qty']"
title="Transfer from remote">
<i class="fa fa-exchange"/>
</button>
</div>
<span t-if="p['available_qty'] &lt;= 0 and p.get('remote_qty', 0) &lt;= 0"
class="text-muted small">--</span>
</td>
</tr>
</t>
<!-- Remote-only products -->
<t t-foreach="remote_only_products" t-as="rp">
<tr class="table-light fst-italic" t-att-data-id="0">
<td class="fi-col-product">
<span class="badge bg-secondary me-1">Remote</span>
<t t-esc="rp['name']"/>
</td>
<td class="fi-col-sku text-muted"><t t-esc="rp['default_code']"/></td>
<td class="fi-col-cat"><t t-esc="rp['category']"/></td>
<td class="fi-col-num text-muted">0</td>
<td class="fi-col-num text-muted">0</td>
<td class="fi-col-num" t-if="has_sync">
<span class="badge text-white" style="background:#6f42c1;">
<t t-esc="rp['remote_qty']"/>
</span>
</td>
<td class="fi-col-num" t-if="has_sync">
<strong><t t-esc="rp['total_qty']"/></strong>
</td>
<td class="fi-col-num">-</td>
<td class="fi-col-num">-</td>
<td class="fi-col-price">$<t t-esc="'%.2f' % rp['sale_price']"/></td>
<td class="fi-col-num">-</td>
<td class="fi-col-action text-muted small">
<t t-esc="rp.get('config_name', '')"/>
</td>
</tr>
</t>
</tbody>
</table>
</div>
<!-- ══════════ Mobile Cards ══════════ -->
<div class="d-block d-md-none" id="fi_mobile_cards">
<t t-foreach="products" t-as="p">
<div class="card mb-2 fi-card" t-att-data-id="p['id']"
t-attf-style="border-left: 4px solid #{'#ffc107' if p['booked_qty'] > 0 else '#198754' if p['available_qty'] > 0 else '#dc3545'};">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-start">
<div style="max-width:65%;">
<div class="fw-bold text-truncate">
<t t-esc="p['name']"/>
</div>
<small class="text-muted">
<t t-esc="p['default_code']"/>
<t t-if="p['category']"> | <t t-esc="p['category']"/></t>
</small>
</div>
<div class="text-end">
<div class="fw-bold">$<t t-esc="'%.2f' % p['sale_price']"/></div>
<span t-attf-class="badge #{'bg-success' if p['margin_pct'] > 0 else 'bg-secondary'} small">
<t t-esc="'%.1f' % p['margin_pct']"/>%
</span>
</div>
</div>
<div t-attf-class="row g-1 mt-2 text-center #{'row-cols-5' if has_sync else ''}" style="font-size:.85rem;">
<div t-attf-class="#{'col' if has_sync else 'col-3'}">
<div class="fw-bold"><t t-esc="p['qty_on_hand']"/></div>
<div class="text-muted" style="font-size:.7rem;">Local</div>
</div>
<div t-attf-class="#{'col' if has_sync else 'col-3'}">
<div t-attf-class="fw-bold #{'text-danger' if p['available_qty'] &lt;= 0 else ''}">
<t t-esc="p['available_qty']"/>
</div>
<div class="text-muted" style="font-size:.7rem;">Avail</div>
</div>
<div t-attf-class="#{'col' if has_sync else 'col-3'}" t-if="has_sync">
<div class="fw-bold" style="color:#6f42c1;">
<t t-esc="p.get('remote_qty', 0)"/>
</div>
<div class="text-muted" style="font-size:.7rem;">Remote</div>
</div>
<div t-attf-class="#{'col' if has_sync else 'col-3'}">
<div class="fw-bold"><t t-esc="p['booked_qty']"/></div>
<div class="text-muted" style="font-size:.7rem;">Booked</div>
</div>
<div t-attf-class="#{'col' if has_sync else 'col-3'}">
<div class="fw-bold"><t t-esc="p['shadow_qty']"/></div>
<div class="text-muted" style="font-size:.7rem;">Incoming</div>
</div>
</div>
<div class="mt-2 text-end">
<div class="btn-group btn-group-sm">
<button t-if="p['available_qty'] > 0"
class="btn btn-sm btn-outline-primary fi-book-btn"
t-att-data-id="p['id']"
t-att-data-name="p['name']">
Book
</button>
<button t-if="has_sync and p.get('remote_qty', 0) > 0 and p['id'] > 0"
class="btn btn-sm btn-outline-secondary fi-transfer-btn"
t-att-data-id="p['id']"
t-att-data-name="p['name']"
t-att-data-remote-qty="p['remote_qty']">
<i class="fa fa-exchange"/> Transfer
</button>
</div>
</div>
</div>
</div>
</t>
</div>
<!-- ══════════ Pagination ══════════ -->
<nav class="mt-3" id="fi_pagination">
</nav>
<div class="text-muted small text-center mt-2">
<span id="fi_count_text">Showing <t t-esc="len(products)"/> of <t t-esc="total_products"/> products</span>
| Auto-refreshes every 30 seconds
</div>
</div>
<!-- ══════════ Transfer Modal ══════════ -->
<div class="modal fade" id="fi_transfer_modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Transfer from Remote</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"/>
</div>
<div class="modal-body">
<p>
Transfer <strong id="fi_transfer_product_name"></strong>
from remote inventory.
</p>
<div class="mb-3">
<label class="form-label">Available at remote:</label>
<span id="fi_transfer_remote_qty" class="fw-bold"></span>
</div>
<div class="mb-3">
<label for="fi_transfer_qty" class="form-label">Quantity to transfer:</label>
<input type="number" id="fi_transfer_qty" class="form-control"
min="1" value="1"/>
</div>
<div id="fi_transfer_result" class="d-none">
<div id="fi_transfer_success" class="alert alert-success d-none"></div>
<div id="fi_transfer_error" class="alert alert-danger d-none"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="fi_transfer_confirm_btn">
<span class="spinner-border spinner-border-sm d-none me-1" id="fi_transfer_spinner"/>
Confirm Transfer
</button>
</div>
</div>
</div>
</div>
<!-- ══════════ JavaScript: Polling + Booking + Transfer ══════════ -->
<script type="text/javascript">
(function () {
'use strict';
var refreshInterval = 30000;
var timer = null;
var searchTimer = null;
var currentPage = <t t-esc="page"/>;
var hasSync = <t t-esc="'true' if has_sync else 'false'"/>;
function updateTimestamp() {
var el = document.getElementById('fi_last_update');
if (el) el.textContent = new Date().toLocaleTimeString();
}
function showLoading(show) {
var el = document.getElementById('fi_loading');
if (el) el.classList.toggle('d-none', !show);
}
function refreshData(resetPage) {
if (resetPage) currentPage = 1;
var searchEl = document.getElementById('fi_search');
var catEl = document.getElementById('fi_category_filter');
var whEl = document.getElementById('fi_warehouse_filter');
var search = searchEl ? searchEl.value.trim() : '';
var catId = catEl ? catEl.value.trim() : '';
var warehouse = whEl ? whEl.value : 'all';
showLoading(true);
fetch('/my/inventory/data', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
search: search,
category_ids: catId ? [parseInt(catId)] : null,
page: currentPage,
warehouse: warehouse
}
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
showLoading(false);
updateTimestamp();
if (data.result &amp;&amp; data.result.products) {
rebuildTable(data.result.products);
rebuildMobileCards(data.result.products);
updateCount(data.result.products.length, data.result.total);
rebuildPagination(data.result.page, data.result.total_pages);
}
})
.catch(function() { showLoading(false); });
}
function debouncedSearch() {
if (searchTimer) clearTimeout(searchTimer);
currentPage = 1;
searchTimer = setTimeout(refreshData, 300);
}
function goToPage(pg) {
currentPage = pg;
refreshData();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function rebuildPagination(page, totalPages) {
var nav = document.getElementById('fi_pagination');
if (!nav) return;
if (totalPages &lt;= 1) { nav.innerHTML = ''; return; }
var html = '&lt;ul class="pagination pagination-sm justify-content-center">';
html += '&lt;li class="page-item' + (page &lt;= 1 ? ' disabled' : '') + '">'
+ '&lt;a class="page-link" href="#" data-page="' + (page - 1) + '">&amp;lt;&lt;/a>&lt;/li>';
for (var pg = 1; pg &lt;= totalPages; pg++) {
if (pg &lt;= 3 || Math.abs(pg - page) &lt;= 1 || pg === totalPages) {
html += '&lt;li class="page-item' + (pg === page ? ' active' : '') + '">'
+ '&lt;a class="page-link" href="#" data-page="' + pg + '">' + pg + '&lt;/a>&lt;/li>';
} else if (pg === 4 &amp;&amp; page > 5) {
html += '&lt;li class="page-item disabled">&lt;span class="page-link">...&lt;/span>&lt;/li>';
} else if (pg === totalPages - 1 &amp;&amp; page &lt; totalPages - 3) {
html += '&lt;li class="page-item disabled">&lt;span class="page-link">...&lt;/span>&lt;/li>';
}
}
html += '&lt;li class="page-item' + (page >= totalPages ? ' disabled' : '') + '">'
+ '&lt;a class="page-link" href="#" data-page="' + (page + 1) + '">&amp;gt;&lt;/a>&lt;/li>';
html += '&lt;/ul>';
nav.innerHTML = html;
nav.querySelectorAll('a.page-link[data-page]').forEach(function(a) {
a.addEventListener('click', function(e) {
e.preventDefault();
var pg = parseInt(this.getAttribute('data-page'));
if (pg >= 1 &amp;&amp; pg &lt;= totalPages) goToPage(pg);
});
});
}
function updateCount(shown, total) {
var el = document.getElementById('fi_count_text');
if (el) el.textContent = 'Showing ' + shown + ' of ' + total + ' products';
}
function rebuildTable(products) {
var tbody = document.getElementById('fi_table_body');
if (!tbody) return;
var html = '';
products.forEach(function(p) {
var isRemoteOnly = p.remote_only || false;
var availClass = p.available_qty &lt;= 0 ? 'text-danger' : 'text-success';
var marginClass = p.margin_pct > 0 ? 'bg-success' : 'bg-secondary';
var remoteQty = p.remote_qty || 0;
var totalQty = p.total_qty || p.qty_on_hand;
var actionHtml = '';
if (!isRemoteOnly) {
if (p.available_qty > 0) {
actionHtml += '&lt;button class="btn btn-sm btn-outline-primary fi-book-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '">Book&lt;/button>';
}
if (hasSync &amp;&amp; remoteQty > 0 &amp;&amp; p.id > 0) {
actionHtml += '&lt;button class="btn btn-sm btn-outline-secondary fi-transfer-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '" data-remote-qty="' + remoteQty + '">&lt;i class="fa fa-exchange">&lt;/i>&lt;/button>';
}
if (!actionHtml) actionHtml = '&lt;span class="text-muted small">--&lt;/span>';
actionHtml = '&lt;div class="btn-group btn-group-sm">' + actionHtml + '&lt;/div>';
} else {
actionHtml = '&lt;span class="text-muted small">' + escHtml(p.config_name || '') + '&lt;/span>';
}
var remoteCol = '';
var totalCol = '';
if (hasSync) {
var whTooltip = (p.remote_warehouses || []).map(function(w) { return w.warehouse + ': ' + w.qty; }).join(', ');
remoteCol = remoteQty > 0
? '&lt;span class="badge text-white fi-remote-badge" style="background:#6f42c1; cursor:pointer;" title="' + escHtml(whTooltip) + '">' + remoteQty + '&lt;/span>'
: '&lt;span class="text-muted">0&lt;/span>';
totalCol = '&lt;strong>' + totalQty + '&lt;/strong>';
}
var trClass = isRemoteOnly ? ' class="table-light fst-italic"' : '';
var nameCell = isRemoteOnly
? '&lt;span class="badge bg-secondary me-1">Remote&lt;/span>' + escHtml(p.name)
: '&lt;strong>' + escHtml(p.name) + '&lt;/strong>';
html += '&lt;tr' + trClass + ' data-id="' + p.id + '">'
+ '&lt;td class="fi-col-product">' + nameCell + '&lt;/td>'
+ '&lt;td class="fi-col-sku text-muted">' + escHtml(p.default_code) + '&lt;/td>'
+ '&lt;td class="fi-col-cat">' + escHtml(p.category) + '&lt;/td>'
+ '&lt;td class="fi-col-num">' + p.qty_on_hand + '&lt;/td>'
+ '&lt;td class="fi-col-num">&lt;span class="fw-bold ' + availClass + '">' + p.available_qty + '&lt;/span>&lt;/td>'
+ (hasSync ? '&lt;td class="fi-col-num">' + remoteCol + '&lt;/td>' : '')
+ (hasSync ? '&lt;td class="fi-col-num">' + totalCol + '&lt;/td>' : '')
+ '&lt;td class="fi-col-num">' + (p.booked_qty > 0 ? '&lt;span class="badge bg-warning text-dark">' + p.booked_qty + '&lt;/span>' : '') + '&lt;/td>'
+ '&lt;td class="fi-col-num">' + (p.shadow_qty > 0 ? '&lt;span class="badge bg-info">' + p.shadow_qty + '&lt;/span>' : '') + '&lt;/td>'
+ '&lt;td class="fi-col-price">$' + p.sale_price.toFixed(2) + '&lt;/td>'
+ '&lt;td class="fi-col-num">&lt;span class="badge ' + marginClass + '">' + p.margin_pct.toFixed(1) + '%&lt;/span>&lt;/td>'
+ '&lt;td class="fi-col-action">' + actionHtml + '&lt;/td>'
+ '&lt;/tr>';
});
tbody.innerHTML = html;
bindBookButtons();
bindTransferButtons();
}
function rebuildMobileCards(products) {
var container = document.getElementById('fi_mobile_cards');
if (!container) return;
var html = '';
products.forEach(function(p) {
if (p.remote_only) return;
var borderColor = p.booked_qty > 0 ? '#ffc107' : (p.available_qty > 0 ? '#198754' : '#dc3545');
var marginClass = p.margin_pct > 0 ? 'bg-success' : 'bg-secondary';
var remoteQty = p.remote_qty || 0;
var availClass = p.available_qty &lt;= 0 ? 'text-danger' : '';
var remoteMobile = '';
if (hasSync) {
remoteMobile = '&lt;div class="col">'
+ '&lt;div class="fw-bold" style="color:#6f42c1;">' + remoteQty + '&lt;/div>'
+ '&lt;div class="text-muted" style="font-size:.7rem;">Remote&lt;/div>'
+ '&lt;/div>';
}
var btns = '';
if (p.available_qty > 0) {
btns += '&lt;button class="btn btn-sm btn-outline-primary fi-book-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '">Book&lt;/button>';
}
if (hasSync &amp;&amp; remoteQty > 0 &amp;&amp; p.id > 0) {
btns += '&lt;button class="btn btn-sm btn-outline-secondary fi-transfer-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '" data-remote-qty="' + remoteQty + '">&lt;i class="fa fa-exchange">&lt;/i> Transfer&lt;/button>';
}
var colClass = hasSync ? 'col' : 'col-3';
var rowClass = hasSync ? 'row g-1 mt-2 text-center row-cols-5' : 'row g-1 mt-2 text-center';
html += '&lt;div class="card mb-2 fi-card" data-id="' + p.id + '" style="border-left: 4px solid ' + borderColor + ';">'
+ '&lt;div class="card-body py-2 px-3">'
+ '&lt;div class="d-flex justify-content-between align-items-start">'
+ '&lt;div style="max-width:65%;">'
+ '&lt;div class="fw-bold text-truncate">' + escHtml(p.name) + '&lt;/div>'
+ '&lt;small class="text-muted">' + escHtml(p.default_code) + (p.category ? ' | ' + escHtml(p.category) : '') + '&lt;/small>'
+ '&lt;/div>'
+ '&lt;div class="text-end">'
+ '&lt;div class="fw-bold">$' + p.sale_price.toFixed(2) + '&lt;/div>'
+ '&lt;span class="badge ' + marginClass + ' small">' + p.margin_pct.toFixed(1) + '%&lt;/span>'
+ '&lt;/div>'
+ '&lt;/div>'
+ '&lt;div class="' + rowClass + '" style="font-size:.85rem;">'
+ '&lt;div class="' + colClass + '">&lt;div class="fw-bold">' + p.qty_on_hand + '&lt;/div>&lt;div class="text-muted" style="font-size:.7rem;">Local&lt;/div>&lt;/div>'
+ '&lt;div class="' + colClass + '">&lt;div class="fw-bold ' + availClass + '">' + p.available_qty + '&lt;/div>&lt;div class="text-muted" style="font-size:.7rem;">Avail&lt;/div>&lt;/div>'
+ remoteMobile
+ '&lt;div class="' + colClass + '">&lt;div class="fw-bold">' + p.booked_qty + '&lt;/div>&lt;div class="text-muted" style="font-size:.7rem;">Booked&lt;/div>&lt;/div>'
+ '&lt;div class="' + colClass + '">&lt;div class="fw-bold">' + p.shadow_qty + '&lt;/div>&lt;div class="text-muted" style="font-size:.7rem;">Incoming&lt;/div>&lt;/div>'
+ '&lt;/div>'
+ (btns ? '&lt;div class="mt-2 text-end">&lt;div class="btn-group btn-group-sm">' + btns + '&lt;/div>&lt;/div>' : '')
+ '&lt;/div>&lt;/div>';
});
container.innerHTML = html;
bindBookButtons();
bindTransferButtons();
}
function escHtml(s) {
if (!s) return '';
return s.replace(/&amp;/g, '&amp;amp;').replace(/&lt;/g, '&amp;lt;').replace(/>/g, '&amp;gt;').replace(/"/g, '&amp;quot;');
}
/* ── Booking ── */
function bookProduct(productId, productName) {
if (!confirm('Book "' + productName + '" for 24 hours?')) return;
fetch('/my/inventory/book', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {product_id: productId, quantity: 1}
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var res = data.result || {};
if (res.success) {
alert('Product booked! Expires at ' + res.expires_at);
refreshData();
} else {
alert(res.error || 'Booking failed');
}
})
.catch(function() { alert('Network error'); });
}
function bindBookButtons() {
document.querySelectorAll('.fi-book-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
bookProduct(
parseInt(this.getAttribute('data-id')),
this.getAttribute('data-name') || ''
);
});
});
}
/* ── Transfer ── */
var currentTransferProductId = null;
function bindTransferButtons() {
document.querySelectorAll('.fi-transfer-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
var pid = parseInt(this.getAttribute('data-id'));
var pname = this.getAttribute('data-name') || '';
var remoteQty = parseFloat(this.getAttribute('data-remote-qty') || '0');
openTransferModal(pid, pname, remoteQty);
});
});
}
function openTransferModal(productId, productName, remoteQty) {
currentTransferProductId = productId;
document.getElementById('fi_transfer_product_name').textContent = productName;
document.getElementById('fi_transfer_remote_qty').textContent = remoteQty;
document.getElementById('fi_transfer_qty').value = 1;
document.getElementById('fi_transfer_qty').max = remoteQty;
document.getElementById('fi_transfer_result').classList.add('d-none');
document.getElementById('fi_transfer_success').classList.add('d-none');
document.getElementById('fi_transfer_error').classList.add('d-none');
document.getElementById('fi_transfer_confirm_btn').disabled = false;
var modal = new bootstrap.Modal(document.getElementById('fi_transfer_modal'));
modal.show();
}
function executeTransfer() {
if (!currentTransferProductId) return;
var qty = parseFloat(document.getElementById('fi_transfer_qty').value) || 1;
var btn = document.getElementById('fi_transfer_confirm_btn');
var spinner = document.getElementById('fi_transfer_spinner');
btn.disabled = true;
spinner.classList.remove('d-none');
fetch('/my/inventory/transfer', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
product_id: currentTransferProductId,
quantity: qty
}
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
spinner.classList.add('d-none');
document.getElementById('fi_transfer_result').classList.remove('d-none');
var res = data.result || {};
if (res.success) {
var msg = 'Transfer initiated!';
if (res.local_po) msg += ' Local PO: ' + res.local_po;
if (res.remote_so) msg += ' | Remote SO: ' + res.remote_so;
var successEl = document.getElementById('fi_transfer_success');
successEl.textContent = msg;
successEl.classList.remove('d-none');
refreshData();
} else {
var errorEl = document.getElementById('fi_transfer_error');
errorEl.textContent = res.error || 'Transfer failed';
errorEl.classList.remove('d-none');
btn.disabled = false;
}
})
.catch(function() {
spinner.classList.add('d-none');
btn.disabled = false;
alert('Network error');
});
}
/* ── Category Search ── */
var catSearchTimer = null;
function setupCategorySearch() {
var input = document.getElementById('fi_category_search');
var hidden = document.getElementById('fi_category_filter');
var dropdown = document.getElementById('fi_category_dropdown');
if (!input || !dropdown) return;
input.addEventListener('input', function() {
if (catSearchTimer) clearTimeout(catSearchTimer);
var q = input.value.trim();
if (q.length === 0) {
hidden.value = '';
dropdown.classList.add('d-none');
refreshData(true);
return;
}
catSearchTimer = setTimeout(function() {
fetch('/my/inventory/categories', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0', method: 'call',
params: { search: q }
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var cats = data.result || [];
var html = '';
if (cats.length === 0) {
html = '&lt;div class="px-3 py-2 text-muted small">No categories found&lt;/div>';
}
cats.forEach(function(c) {
html += '&lt;div class="px-3 py-2 fi-cat-option" style="cursor:pointer;" data-id="' + c.id + '" data-name="' + escHtml(c.name) + '">'
+ escHtml(c.name) + '&lt;/div>';
});
dropdown.innerHTML = html;
dropdown.classList.remove('d-none');
dropdown.querySelectorAll('.fi-cat-option').forEach(function(opt) {
opt.addEventListener('click', function() {
hidden.value = this.getAttribute('data-id');
input.value = this.getAttribute('data-name');
dropdown.classList.add('d-none');
refreshData(true);
});
opt.addEventListener('mouseenter', function() {
this.style.background = '#e9ecef';
});
opt.addEventListener('mouseleave', function() {
this.style.background = '';
});
});
});
}, 250);
});
input.addEventListener('focus', function() {
if (input.value.trim()) input.dispatchEvent(new Event('input'));
});
document.addEventListener('click', function(e) {
if (!input.contains(e.target) &amp;&amp; !dropdown.contains(e.target)) {
dropdown.classList.add('d-none');
}
});
}
/* ── Init ── */
document.addEventListener('DOMContentLoaded', function() {
bindBookButtons();
bindTransferButtons();
setupCategorySearch();
var searchBtn = document.getElementById('fi_search_btn');
var searchInput = document.getElementById('fi_search');
var whFilter = document.getElementById('fi_warehouse_filter');
var transferBtn = document.getElementById('fi_transfer_confirm_btn');
if (searchBtn) searchBtn.addEventListener('click', function() { refreshData(true); });
if (searchInput) {
searchInput.addEventListener('input', debouncedSearch);
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); refreshData(true); }
});
}
if (whFilter) whFilter.addEventListener('change', function() { refreshData(true); });
if (transferBtn) transferBtn.addEventListener('click', executeTransfer);
rebuildPagination(<t t-esc="page"/>, <t t-esc="total_pages"/>);
timer = setInterval(refreshData, refreshInterval);
updateTimestamp();
});
})();
</script>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Extra Cost + Price Impact to PTAV list view (Configure Variants grid) -->
<record id="view_ptav_list_inherit_fi" model="ir.ui.view">
<field name="name">product.template.attribute.value.list.fi</field>
<field name="model">product.template.attribute.value</field>
<field name="inherit_id" ref="product.product_template_attribute_value_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='price_extra']" position="after">
<field name="x_fi_extra_cost" widget="monetary"
string="Extra Cost"
options="{'field_digits': True}"/>
<field name="x_fi_extra_price_impact" widget="monetary"
string="+ Price (Cost)"
readonly="1"
options="{'field_digits': True}"/>
</xpath>
</field>
</record>
<!-- Add Extra Cost + Price Impact to PTAV form view -->
<record id="view_ptav_form_inherit_fi" model="ir.ui.view">
<field name="name">product.template.attribute.value.form.fi</field>
<field name="model">product.template.attribute.value</field>
<field name="inherit_id" ref="product.product_template_attribute_value_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='price_extra']" position="after">
<field name="x_fi_extra_cost" widget="monetary"
options="{'field_digits': True}"/>
<field name="x_fi_extra_price_impact" widget="monetary"
readonly="1"
options="{'field_digits': True}"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,175 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Brand Form View -->
<record id="view_product_brand_form" model="ir.ui.view">
<field name="name">product.brand.form</field>
<field name="model">product.brand</field>
<field name="arch" type="xml">
<form string="Brand">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
invisible="active"/>
<field name="active" invisible="1"/>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" name="action_view_products"
type="object" icon="fa-cube">
<field string="Products" name="product_count" widget="statinfo"/>
</button>
<button class="oe_stat_button" name="action_view_sub_brands"
type="object" icon="fa-sitemap"
invisible="child_count == 0">
<field string="Sub-Brands" name="child_count" widget="statinfo"/>
</button>
</div>
<field name="logo" widget="image" class="oe_avatar"
options="{'convert_to_webp': True, 'preview_image': 'logo'}"/>
<div class="oe_title">
<label for="name" string="Brand Name"/>
<h1><field name="name" placeholder="e.g. Invacare"/></h1>
</div>
<group>
<group string="Manufacturer / Vendor">
<field name="partner_id"/>
<field name="parent_id"
options="{'no_create': True}"
domain="[('id', '!=', id)]"/>
</group>
<group string="Default Pricing">
<label for="primary_discount_pct" string="Primary Discount"/>
<div class="d-flex align-items-center">
<field name="primary_discount_pct" class="oe_inline"
style="max-width: 80px;"/>
<span class="ms-1 text-muted">%</span>
</div>
<label for="secondary_discount_pct" string="Secondary Discount"/>
<div class="d-flex align-items-center">
<field name="secondary_discount_pct" class="oe_inline"
style="max-width: 80px;"/>
<span class="ms-1 text-muted">%</span>
</div>
<label for="net_discount_pct" string="Effective Discount"/>
<div class="d-flex align-items-center">
<field name="net_discount_pct" class="oe_inline fw-bold"
style="max-width: 80px;"/>
<span class="ms-1 text-muted">%</span>
</div>
</group>
</group>
<notebook>
<page string="Pricing Rules" name="pricing_rules">
<field name="pricing_rule_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="apply_on"/>
<field name="categ_id"
column_invisible="True"
invisible="apply_on != 'category'"/>
<field name="product_tmpl_id"
column_invisible="True"
invisible="apply_on != 'product'"/>
<field name="pricing_method"/>
<field name="primary_discount_pct" string="Primary %"
invisible="pricing_method != 'tiered_pct'"/>
<field name="secondary_discount_pct" string="Secondary %"
invisible="pricing_method != 'tiered_pct'"/>
<field name="flat_discount_pct" string="Discount %"
invisible="pricing_method != 'flat_pct'"/>
<field name="fixed_rebate_amount" string="Rebate $"
invisible="pricing_method != 'fixed_rebate'"/>
<field name="fixed_cost_price" string="Cost $"
invisible="pricing_method != 'fixed_cost'"/>
<field name="net_discount_pct" string="Effective %"/>
</list>
</field>
</page>
<page string="Sub-Brands" name="sub_brands">
<field name="child_ids">
<list>
<field name="logo" widget="image"
options="{'size': [32, 32]}" class="p-0"/>
<field name="name"/>
<field name="partner_id"/>
<field name="primary_discount_pct" string="Primary %"/>
<field name="secondary_discount_pct" string="Secondary %"/>
<field name="net_discount_pct" string="Effective %"/>
<field name="product_count" string="Products"/>
</list>
</field>
</page>
<page string="Notes" name="notes">
<field name="notes" nolabel="1"
placeholder="Internal notes about the pricing arrangement with this brand..."/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Brand List View -->
<record id="view_product_brand_list" model="ir.ui.view">
<field name="name">product.brand.list</field>
<field name="model">product.brand</field>
<field name="arch" type="xml">
<list string="Brands">
<field name="logo" widget="image" options="{'size': [32, 32]}" class="p-0"/>
<field name="name"/>
<field name="parent_id" optional="show"/>
<field name="partner_id"/>
<field name="primary_discount_pct" string="Primary %"/>
<field name="secondary_discount_pct" string="Secondary %"/>
<field name="net_discount_pct" string="Effective %"/>
<field name="product_count" string="Products"/>
</list>
</field>
</record>
<!-- Brand Search View -->
<record id="view_product_brand_search" model="ir.ui.view">
<field name="name">product.brand.search</field>
<field name="model">product.brand</field>
<field name="arch" type="xml">
<search string="Brands">
<field name="name" string="Brand Name"/>
<field name="partner_id" string="Vendor"/>
<field name="parent_id" string="Parent Brand"/>
<separator/>
<filter string="Top-Level Brands" name="top_level"
domain="[('parent_id', '=', False)]"/>
<filter string="Sub-Brands" name="sub_brands"
domain="[('parent_id', '!=', False)]"/>
<filter string="Has Pricing Rules" name="has_rules"
domain="[('pricing_rule_ids', '!=', False)]"/>
<filter string="Has Pricing" name="has_pricing"
domain="[('primary_discount_pct', '&gt;', 0)]"/>
<filter string="Archived" name="archived"
domain="[('active', '=', False)]"/>
<separator/>
<filter string="Parent Brand" name="group_parent"
context="{'group_by': 'parent_id'}"/>
<filter string="Vendor" name="group_vendor"
context="{'group_by': 'partner_id'}"/>
</search>
</field>
</record>
<!-- Brand Action -->
<record id="action_product_brand" model="ir.actions.act_window">
<field name="name">Brands / Vendors</field>
<field name="res_model">product.brand</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first brand
</p>
<p>
Brands link products to manufacturers and define tiered pricing
structures. Use pricing rules for category-specific or
product-specific overrides.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,174 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ═══════════════════════════════════════════════════════════
Product Form: Two-column layout
LEFT = Brand/Classification + Taxes (moved fields)
RIGHT = Pricing only (Sales Price, Margin, Profit, Cost)
═══════════════════════════════════════════════════════════ -->
<record id="view_product_template_form_inherit_fi" model="ir.ui.view">
<field name="name">product.template.form.fusion.inventory</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view"/>
<field name="priority">50</field>
<field name="arch" type="xml">
<!-- ── Make fields required ── -->
<xpath expr="//field[@name='type']" position="attributes">
<attribute name="required">1</attribute>
</xpath>
<xpath expr="//field[@name='list_price']" position="attributes">
<attribute name="required">1</attribute>
</xpath>
<xpath expr="//field[@name='standard_price']" position="attributes">
<attribute name="required">1</attribute>
</xpath>
<xpath expr="//field[@name='categ_id']" position="attributes">
<attribute name="required">1</attribute>
</xpath>
<xpath expr="//field[@name='default_code']" position="attributes">
<attribute name="required">1</attribute>
</xpath>
<!-- ═════════════════════════════════════════════════════
RIGHT COLUMN: Margin + Profit above Cost
(everything else pricing-related stays here)
═════════════════════════════════════════════════════ -->
<xpath expr="//label[@id='standard_price_label']" position="before">
<label for="x_fi_margin_pct" string="Margin"/>
<div name="margin_pct_uom" class="d-flex align-items-center">
<field name="x_fi_margin_pct" class="oe_inline"
style="max-width: 80px;"/>
<span class="ms-1 text-muted">%</span>
</div>
<label for="x_fi_profit_amount" string="Profit"/>
<div name="profit_amount_wrapper">
<field name="x_fi_profit_amount" class="oe_inline"
widget="monetary" readonly="1"
options="{'currency_field': 'currency_id'}"/>
</div>
</xpath>
<!-- ═════════════════════════════════════════════════════
LEFT COLUMN: move fields from right, add sections
═════════════════════════════════════════════════════ -->
<xpath expr="//group[@name='group_general']" position="inside">
<!-- ── Brand &amp; Classification ── -->
<separator string="Brand &amp; Classification"/>
<field name="x_fi_brand_ids" widget="many2many_tags"
options="{'color_field': 'id'}"
placeholder="Select brand(s)..."/>
<label for="x_fi_expected_cost" string="Expected Cost"
invisible="not x_fi_brand_ids"/>
<div class="d-flex align-items-center"
invisible="not x_fi_brand_ids">
<field name="x_fi_expected_cost" class="oe_inline"
widget="monetary" readonly="1"
options="{'currency_field': 'currency_id'}"/>
<span class="ms-2 text-muted fst-italic">(from brand discount)</span>
</div>
<xpath expr="//field[@name='categ_id']" position="move"/>
<xpath expr="//field[@name='default_code']" position="move"/>
<xpath expr="//field[@name='barcode']" position="move"/>
<xpath expr="//label[@for='base_unit_count']" position="move"/>
<xpath expr="//div[@name='base_unit_price']" position="move"/>
<xpath expr="//group[@name='group_standard_price']/field[@name='company_id']" position="move"/>
<!-- ── Taxes ── -->
<separator string="Taxes"/>
<xpath expr="//label[@for='taxes_id']" position="move"/>
<xpath expr="//div[@name='taxes_div']" position="move"/>
<xpath expr="//field[@name='supplier_taxes_id']" position="move"/>
</xpath>
<!-- ── Shipping Cost below Cost ── -->
<xpath expr="//div[@name='standard_price_uom']" position="after">
<label for="x_fi_shipping_cost" string="Shipping Cost"/>
<div name="shipping_cost_wrapper">
<field name="x_fi_shipping_cost" class="oe_inline"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
</div>
</xpath>
<!-- ── Apply Margin to Variants button in right column ── -->
<xpath expr="//group[@name='group_standard_price']" position="inside">
<div colspan="2" class="mt-2"
invisible="product_variant_count &lt;= 1">
<button name="action_apply_margin_to_all_variants"
type="object"
string="Apply Margin to All Variants"
class="btn-secondary btn-sm"
icon="fa-refresh"
confirm="Apply the template margin to all non-overridden variants? Overridden variants will be skipped."/>
</div>
</xpath>
<!-- ── Fusion Inventory tab ── -->
<xpath expr="//notebook" position="inside">
<page string="Fusion Inventory" name="fusion_inventory_tab">
<group string="Name Case Conversion">
<field name="x_fi_case_conversion" widget="radio"/>
</group>
<group string="Remote Inventory" invisible="not has_remote_mapping">
<group>
<field name="remote_qty_available"/>
<field name="remote_qty_forecast"/>
</group>
</group>
</page>
</xpath>
<!-- ── Purchase History tab ── -->
<xpath expr="//notebook" position="inside">
<page string="Purchase History" name="purchase_history_tab">
<div class="d-flex align-items-center mb-3">
<h3 class="mb-0">Vendor Bill History</h3>
<button name="action_view_purchase_history" type="object"
string="Open Full History" class="btn-link ms-3"
icon="fa-external-link"/>
<button name="action_refresh_cost_from_bills" type="object"
string="Refresh Cost from Latest Bill"
class="btn-secondary ms-3"
icon="fa-refresh"
confirm="Update this product's cost to the latest vendor bill price?"/>
</div>
<field name="x_fi_purchase_history_ids" readonly="1" nolabel="1">
<list limit="50" default_order="date desc">
<field name="move_id" string="Bill Number"/>
<field name="date" string="Date"/>
<field name="partner_id" string="Vendor"/>
<field name="price_unit" string="Unit Cost"/>
<field name="quantity"/>
<field name="x_fi_suggested_price" string="Suggested Price"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
<!-- ═══════════════════════════════════════════════════════════
Taxes: make required
═══════════════════════════════════════════════════════════ -->
<record id="view_product_template_form_inherit_fi_taxes" model="ir.ui.view">
<field name="name">product.template.form.fusion.inventory.taxes</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="account.product_template_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='taxes_id']" position="attributes">
<attribute name="required">1</attribute>
</xpath>
<xpath expr="//field[@name='supplier_taxes_id']" position="attributes">
<attribute name="required">1</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add remote stock + brand columns to product list view -->
<record id="view_product_template_list_inherit_sync" model="ir.ui.view">
<field name="name">product.template.list.sync</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_tree_view"/>
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<field name="x_fi_brand_ids" widget="many2many_tags" string="Brand(s)" optional="show"/>
<field name="x_fi_margin_pct" string="Margin %" optional="show"/>
<field name="remote_qty_available" string="Remote Stock" optional="show"
decoration-danger="remote_qty_available == 0 and has_remote_mapping"
decoration-success="remote_qty_available > 0"/>
<field name="has_remote_mapping" column_invisible="1"/>
</xpath>
</field>
</record>
<!-- Add brand search/filter/group-by to product search view -->
<record id="view_product_template_search_inherit_fi" model="ir.ui.view">
<field name="name">product.template.search.fusion.inventory</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_search_view"/>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<field name="x_fi_brand_ids" string="Brand"/>
<separator/>
<filter string="Has Brand" name="has_brand"
domain="[('x_fi_brand_ids', '!=', False)]"/>
<filter string="No Brand" name="no_brand"
domain="[('x_fi_brand_ids', '=', False)]"/>
<separator/>
<filter string="Brand" name="group_brand"
context="{'group_by': 'x_fi_brand_ids'}"/>
</xpath>
</field>
</record>
<!-- ═══════════════════════════════════════════════════════════
Variant Form: margin, profit, override
═══════════════════════════════════════════════════════════ -->
<record id="view_product_product_form_fi" model="ir.ui.view">
<field name="name">product.product.form.fusion.inventory</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_variant_easy_edit_view"/>
<field name="arch" type="xml">
<!-- Make Sale Price always read-only on variant form -->
<xpath expr="//group[@name='pricing']//field[@name='lst_price']" position="attributes">
<attribute name="readonly">1</attribute>
</xpath>
<xpath expr="//group[@name='pricing']//field[@name='cost_currency_id']" position="after">
<label for="x_fi_shipping_cost" string="Shipping Cost"/>
<div>
<field name="x_fi_shipping_cost" class="oe_inline"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
</div>
<label for="x_fi_variant_margin_pct" string="Margin"/>
<div class="d-flex align-items-center">
<field name="x_fi_variant_margin_pct" class="oe_inline"
style="max-width: 80px;"/>
<span class="ms-1 text-muted">%</span>
<field name="x_fi_margin_override" class="ms-3"/>
<label for="x_fi_margin_override" string="Override"
class="ms-1 text-muted"/>
</div>
<label for="x_fi_variant_profit" string="Profit"/>
<div>
<field name="x_fi_variant_profit" class="oe_inline"
widget="monetary" readonly="1"
options="{'currency_field': 'currency_id'}"/>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_fusion_inventory" model="ir.ui.view">
<field name="name">res.config.settings.view.form.fusion.inventory</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Inventory" string="Fusion Inventory"
name="fusion_inventory"
groups="stock.group_stock_manager">
<!-- GENERAL SETTINGS -->
<h2>General</h2>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fi_auto_update_cost"/>
</div>
<div class="o_setting_right_pane">
<label for="fi_auto_update_cost"/>
<div class="text-muted">
Automatically update product cost when a vendor bill is confirmed.
Uses the latest bill line price (zero-price lines are skipped).
</div>
<div class="mt-3">
<button name="action_sync_all_costs_from_bills"
type="object"
string="Sync All Product Costs Now"
class="btn-secondary"
icon="fa-refresh"
confirm="This will update every product's cost to its latest vendor bill price. Continue?"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Default Margin (%)</span>
<div class="text-muted">
Default margin percentage applied to newly created products.
</div>
<div class="mt-2">
<field name="fi_default_margin" style="max-width: 100px;"/> %
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Booking Hold Duration</span>
<div class="text-muted">
How many hours a product booking holds in the inventory sheet
before automatically expiring.
</div>
<div class="mt-2">
<field name="fi_booking_hold_hours" style="max-width: 80px;"/> hours
</div>
</div>
</div>
</div>
<!-- CASE CONVERSION -->
<h2>Product Name Case Conversion</h2>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Global Case Conversion</span>
<div class="text-muted">
Automatically convert ALL product names to the selected case.
This overrides individual product settings and applies to new products.
Set to "No Conversion" to let individual products control their own case.
</div>
<div class="mt-3">
<field name="fi_case_conversion" widget="radio"/>
</div>
<div class="mt-3">
<button name="action_apply_case_conversion_all"
type="object" string="Apply to All Existing Products"
class="btn-secondary"
confirm="This will convert all existing product names. Continue?"/>
</div>
</div>
</div>
</div>
<!-- OPENAI / AI -->
<h2>AI Configuration</h2>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">OpenAI API Key</span>
<div class="text-muted">
Used for discrepancy analysis and notes parsing.
If empty, falls back to the Fusion Digitize API key.
</div>
<div class="mt-2">
<field name="fi_openai_api_key" password="True"
placeholder="Leave empty to use Fusion Digitize key"/>
</div>
</div>
</div>
</div>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Brand smart button + Create Brand on vendor contacts -->
<record id="view_partner_form_fi_brand" model="ir.ui.view">
<field name="name">res.partner.form.fusion.inventory.brand</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button" name="action_view_brands"
type="object" icon="fa-tags"
invisible="brand_count == 0">
<field string="Brands" name="brand_count" widget="statinfo"/>
</button>
<button class="oe_stat_button" name="action_create_brand"
type="object" icon="fa-plus-circle"
string="Create Brand"
invisible="supplier_rank == 0 or brand_count &gt; 0"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ═══════════════════════════════════════════════════════════
Stock Picking Form: SO/Invoice tracking + Serial Scanner
═══════════════════════════════════════════════════════════ -->
<record id="view_picking_form_inherit_fi" model="ir.ui.view">
<field name="name">stock.picking.form.fusion.inventory</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<!-- ── Smart buttons: Sale Order ── -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not x_fi_sale_order_id">
<field name="x_fi_sale_order_id" widget="statinfo"
string="Sale Order"/>
</button>
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="x_fi_invoice_count == 0">
<field name="x_fi_invoice_count" widget="statinfo"
string="Invoices"/>
</button>
<button name="action_view_purchase_order" type="object"
class="oe_stat_button" icon="fa-truck"
invisible="not x_fi_purchase_order_id">
<field name="x_fi_purchase_order_id" widget="statinfo"
string="Purchase Order"/>
</button>
<button name="action_view_bills" type="object"
class="oe_stat_button" icon="fa-credit-card"
invisible="x_fi_bill_count == 0">
<field name="x_fi_bill_count" widget="statinfo"
string="Bills"/>
</button>
</xpath>
<!-- ── Status pills below header: Sale Order ── -->
<xpath expr="//header" position="after">
<div class="d-flex gap-2 px-3 pb-2"
invisible="not x_fi_sale_order_id">
<span class="text-muted">SO Status:</span>
<field name="x_fi_sale_order_state" widget="badge"
decoration-info="x_fi_sale_order_state == 'draft'"
decoration-primary="x_fi_sale_order_state == 'sale'"
decoration-success="x_fi_sale_order_state == 'done'"
decoration-danger="x_fi_sale_order_state == 'cancel'"/>
<span class="text-muted ms-3">Invoice:</span>
<field name="x_fi_invoice_status" widget="badge"
decoration-danger="x_fi_invoice_status == 'no'"
decoration-warning="x_fi_invoice_status == 'invoiced'"
decoration-success="x_fi_invoice_status == 'paid'"/>
</div>
<div class="d-flex gap-2 px-3 pb-2"
invisible="not x_fi_purchase_order_id">
<span class="text-muted">PO Status:</span>
<field name="x_fi_purchase_order_state" widget="badge"
decoration-info="x_fi_purchase_order_state == 'draft'"
decoration-primary="x_fi_purchase_order_state == 'purchase'"
decoration-success="x_fi_purchase_order_state == 'done'"
decoration-danger="x_fi_purchase_order_state == 'cancel'"/>
<span class="text-muted ms-3">Bill:</span>
<field name="x_fi_bill_status" widget="badge"
decoration-danger="x_fi_bill_status == 'no'"
decoration-warning="x_fi_bill_status == 'billed'"
decoration-success="x_fi_bill_status == 'paid'"/>
</div>
</xpath>
<!-- ── Serial Number Scan button ── -->
<xpath expr="//header" position="inside">
<button name="action_scan_serial_numbers" type="object"
string="Scan Serial Numbers"
class="btn-secondary"
invisible="picking_type_code != 'outgoing'"/>
</xpath>
</field>
</record>
<!-- ═══════════════════════════════════════════════════════════
Stock Picking List: Invoice status column + filters
═══════════════════════════════════════════════════════════ -->
<record id="view_picking_list_inherit_fi" model="ir.ui.view">
<field name="name">stock.picking.list.fusion.inventory</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.vpicktree"/>
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<field name="x_fi_invoice_status" string="Invoice Status"
widget="badge" optional="show"
decoration-danger="x_fi_invoice_status == 'no'"
decoration-warning="x_fi_invoice_status == 'invoiced'"
decoration-success="x_fi_invoice_status == 'paid'"/>
<field name="x_fi_bill_status" string="Bill Status"
widget="badge" optional="show"
decoration-danger="x_fi_bill_status == 'no'"
decoration-warning="x_fi_bill_status == 'billed'"
decoration-success="x_fi_bill_status == 'paid'"/>
</xpath>
</field>
</record>
<!-- ═══════════════════════════════════════════════════════════
Stock Picking Search: Filters for invoice status
═══════════════════════════════════════════════════════════ -->
<record id="view_picking_search_inherit_fi" model="ir.ui.view">
<field name="name">stock.picking.search.fusion.inventory</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_internal_search"/>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<separator/>
<filter name="fi_paid_not_delivered" string="Paid - Ready to Deliver"
domain="[('x_fi_invoice_status', '=', 'paid'), ('state', 'not in', ('done', 'cancel'))]"/>
<filter name="fi_invoiced_not_delivered" string="Invoiced - Not Delivered"
domain="[('x_fi_invoice_status', '=', 'invoiced'), ('state', 'not in', ('done', 'cancel'))]"/>
<filter name="fi_not_invoiced" string="Not Invoiced"
domain="[('x_fi_invoice_status', '=', 'no'), ('picking_type_code', '=', 'outgoing')]"/>
<separator/>
<filter name="fi_bill_paid" string="Bill Paid"
domain="[('x_fi_bill_status', '=', 'paid'), ('picking_type_code', '=', 'incoming')]"/>
<filter name="fi_billed_not_paid" string="Billed - Not Paid"
domain="[('x_fi_bill_status', '=', 'billed'), ('picking_type_code', '=', 'incoming')]"/>
<filter name="fi_not_billed" string="Not Billed"
domain="[('x_fi_bill_status', '=', 'no'), ('picking_type_code', '=', 'incoming')]"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,404 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Sync Config Form View -->
<record id="view_fusion_sync_config_form" model="ir.ui.view">
<field name="name">fusion.sync.config.form</field>
<field name="model">fusion.sync.config</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_test_connection" type="object"
string="Test Connection" class="btn-primary"
invisible="state == 'connected'"/>
<button name="action_test_connection" type="object"
string="Re-Test Connection"
invisible="state != 'connected'"/>
<button name="action_sync_now" type="object"
string="Sync Now" class="btn-primary"
invisible="state != 'connected'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,connected"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="e.g., Mobility Specialties"/></h1>
</div>
<group>
<group string="Connection">
<field name="url" placeholder="https://erp.mobilityspecialties.com"/>
<field name="db_name" placeholder="mobility-prod"/>
<field name="username" placeholder="admin"/>
<field name="api_key" password="True"/>
</group>
<group string="Sync Settings">
<field name="sync_products"/>
<field name="sync_stock"/>
<field name="sync_interval"/>
<field name="remote_warehouse_name"
placeholder="Leave empty for all warehouses"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group>
<group string="Inter-Company">
<field name="remote_partner_id"
placeholder="Select the partner representing the remote company"/>
<field name="local_company_name"
placeholder="e.g., Mobility Specialties Inc"/>
</group>
<group string="Shared Warehouse">
<field name="is_shared_warehouse"/>
<field name="warehouse_location_id"
invisible="not is_shared_warehouse"/>
</group>
</group>
<group string="Status">
<field name="last_sync"/>
<field name="last_sync_status"/>
<field name="remote_uid" invisible="state != 'connected'"/>
</group>
<notebook>
<page string="Product Mappings" name="mappings">
<field name="active" invisible="1"/>
<p class="text-muted" invisible="state == 'connected'">
Connect and sync to see product mappings here.
</p>
</page>
<page string="Remote Warehouses" name="warehouses">
<field name="sync_warehouse_ids" readonly="1">
<list>
<field name="name"/>
<field name="code"/>
<field name="company_name"/>
<field name="remote_warehouse_id"/>
<field name="remote_lot_stock_id"/>
<field name="active" widget="boolean"/>
</list>
</field>
<p class="text-muted" invisible="sync_warehouse_count > 0">
Run "Sync Now" to discover remote warehouses.
</p>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Sync Config List View -->
<record id="view_fusion_sync_config_list" model="ir.ui.view">
<field name="name">fusion.sync.config.list</field>
<field name="model">fusion.sync.config</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="url"/>
<field name="is_shared_warehouse" widget="boolean"/>
<field name="state" widget="badge"
decoration-success="state == 'connected'"
decoration-danger="state == 'error'"
decoration-info="state == 'draft'"/>
<field name="last_sync"/>
<field name="last_sync_status"/>
</list>
</field>
</record>
<!-- Product Mapping List View -->
<record id="view_fusion_product_sync_mapping_list" model="ir.ui.view">
<field name="name">fusion.product.sync.mapping.list</field>
<field name="model">fusion.product.sync.mapping</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="remote_product_name"/>
<field name="remote_default_code"/>
<field name="remote_barcode" optional="hide"/>
<field name="local_product_id"/>
<field name="auto_matched" widget="boolean"/>
<field name="remote_qty_available"
decoration-danger="remote_qty_available == 0"
decoration-success="remote_qty_available > 0"/>
<field name="remote_qty_forecast"/>
<field name="owner_config_id" optional="show"/>
<field name="last_stock_sync"/>
<field name="config_id" column_invisible="1"/>
</list>
</field>
</record>
<!-- Product Mapping Search View -->
<record id="view_fusion_product_sync_mapping_search" model="ir.ui.view">
<field name="name">fusion.product.sync.mapping.search</field>
<field name="model">fusion.product.sync.mapping</field>
<field name="arch" type="xml">
<search>
<field name="remote_product_name"/>
<field name="remote_default_code"/>
<field name="local_product_id"/>
<separator/>
<filter name="mapped" string="Mapped"
domain="[('local_product_id', '!=', False)]"/>
<filter name="unmapped" string="Unmapped"
domain="[('local_product_id', '=', False)]"/>
<filter name="in_stock" string="Remote In Stock"
domain="[('remote_qty_available', '&gt;', 0)]"/>
</search>
</field>
</record>
<!-- Sync Log List View -->
<record id="view_fusion_sync_log_list" model="ir.ui.view">
<field name="name">fusion.sync.log.list</field>
<field name="model">fusion.sync.log</field>
<field name="arch" type="xml">
<list>
<field name="create_date" string="Date"/>
<field name="config_id"/>
<field name="direction"/>
<field name="sync_type"/>
<field name="status" widget="badge"
decoration-success="status == 'success'"
decoration-warning="status == 'partial'"
decoration-danger="status == 'error'"/>
<field name="summary"/>
<field name="product_count"/>
</list>
</field>
</record>
<!-- Sync Config Action -->
<record id="action_fusion_sync_config" model="ir.actions.act_window">
<field name="name">Remote Connections</field>
<field name="res_model">fusion.sync.config</field>
<field name="view_mode">list,form</field>
</record>
<!-- Product Mapping Action -->
<record id="action_fusion_product_mapping" model="ir.actions.act_window">
<field name="name">Product Mappings</field>
<field name="res_model">fusion.product.sync.mapping</field>
<field name="view_mode">list</field>
<field name="context">{'search_default_mapped': 1}</field>
</record>
<!-- Sync Log Action -->
<record id="action_fusion_sync_log" model="ir.actions.act_window">
<field name="name">Sync Log</field>
<field name="res_model">fusion.sync.log</field>
<field name="view_mode">list</field>
</record>
<!-- Inter-Company Transfer Views -->
<record id="view_inter_company_transfer_form" model="ir.ui.view">
<field name="name">fusion.inter.company.transfer.form</field>
<field name="model">fusion.inter.company.transfer</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_execute_transfer" type="object"
string="Execute Transfer (One-Click)" class="btn-primary"
invisible="state not in ('draft', 'requested')"
confirm="This will create remote SO, remote Invoice, local PO, and local Vendor Bill automatically. Continue?"/>
<button name="action_request" type="object"
string="Request (Manual)" class="btn-secondary"
invisible="state != 'draft'"/>
<button name="action_create_remote_so" type="object"
string="Create Remote SO" class="btn-primary"
invisible="state != 'requested'"/>
<button name="action_create_local_po" type="object"
string="Create Local PO" class="btn-primary"
invisible="state != 'so_created'"/>
<button name="action_create_invoice" type="object"
string="Create Invoice" class="btn-primary"
invisible="state != 'po_created'"/>
<button name="action_create_vendor_bill" type="object"
string="Create Vendor Bill" class="btn-secondary"
invisible="state != 'invoiced'"/>
<button name="action_create_delivery_task" type="object"
string="Create Delivery Task" class="btn-secondary"
invisible="state not in ('po_created', 'invoiced')"/>
<button name="action_mark_transferred" type="object"
string="Mark Transferred" class="btn-primary"
invisible="state != 'invoiced'"/>
<button name="action_complete" type="object"
string="Complete" class="btn-success"
invisible="state != 'transferred'"/>
<button name="action_retry" type="object"
string="Retry" class="btn-warning"
invisible="state != 'error'"/>
<button name="action_cancel" type="object"
string="Cancel" class="btn-secondary"
invisible="state in ('done', 'cancelled')"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,requested,so_created,po_created,invoiced,transferred,done"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="%(action_inter_company_transfer)d"
type="action" class="oe_stat_button"
icon="fa-shopping-cart"
invisible="not local_po_id">
<field name="local_po_id" widget="statinfo" string="Local PO"/>
</button>
</div>
<div class="alert alert-danger" role="alert"
invisible="state != 'error'">
<strong>Transfer failed at step: </strong>
<field name="error_step" readonly="1"/>
</div>
<group>
<group string="Transfer Details">
<field name="config_id"/>
<field name="product_id"/>
<field name="product_mapping_id" readonly="1"/>
<field name="quantity"/>
<field name="requested_by" readonly="1"/>
</group>
<group string="References">
<field name="remote_so_id" readonly="1"/>
<field name="remote_so_name" readonly="1"/>
<field name="local_po_id" readonly="1"/>
<field name="remote_invoice_id" readonly="1"/>
<field name="local_bill_id" readonly="1"/>
<field name="task_id" readonly="1"/>
</group>
</group>
<field name="notes" placeholder="Notes about this transfer..."/>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_inter_company_transfer_list" model="ir.ui.view">
<field name="name">fusion.inter.company.transfer.list</field>
<field name="model">fusion.inter.company.transfer</field>
<field name="arch" type="xml">
<list>
<field name="product_id"/>
<field name="config_id"/>
<field name="quantity"/>
<field name="state" widget="badge"
decoration-info="state in ('draft', 'requested')"
decoration-primary="state in ('so_created', 'po_created')"
decoration-warning="state == 'invoiced'"
decoration-success="state in ('transferred', 'done')"
decoration-danger="state == 'cancelled'"/>
<field name="local_po_id"/>
<field name="requested_by"/>
<field name="create_date"/>
</list>
</field>
</record>
<record id="action_inter_company_transfer" model="ir.actions.act_window">
<field name="name">Inter-Company Transfers</field>
<field name="res_model">fusion.inter.company.transfer</field>
<field name="view_mode">list,form</field>
</record>
<!-- Warehouse Inventory Views -->
<record id="view_warehouse_inventory_list" model="ir.ui.view">
<field name="name">fusion.warehouse.inventory.list</field>
<field name="model">fusion.warehouse.inventory</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="product_id"/>
<field name="lot_id"/>
<field name="owner_config_id"/>
<field name="quantity"/>
<field name="location_bin"/>
<field name="state" widget="badge"
decoration-success="state == 'available'"
decoration-warning="state == 'reserved'"
decoration-info="state == 'in_transit'"
decoration-muted="state == 'transferred'"/>
</list>
</field>
</record>
<record id="action_warehouse_inventory" model="ir.actions.act_window">
<field name="name">Shared Warehouse Inventory</field>
<field name="res_model">fusion.warehouse.inventory</field>
<field name="view_mode">list</field>
</record>
<!-- Discrepancy Views -->
<record id="view_inventory_discrepancy_list" model="ir.ui.view">
<field name="name">fusion.inventory.discrepancy.list</field>
<field name="model">fusion.inventory.discrepancy</field>
<field name="arch" type="xml">
<list>
<field name="scan_date"/>
<field name="product_id"/>
<field name="discrepancy_type"/>
<field name="missing_serials"/>
<field name="expected_qty"/>
<field name="actual_qty"/>
<field name="difference"
decoration-danger="difference != 0"/>
<field name="source"/>
<field name="state" widget="badge"
decoration-danger="state == 'detected'"
decoration-warning="state == 'reviewed'"
decoration-success="state == 'resolved'"
decoration-muted="state == 'ignored'"/>
</list>
</field>
</record>
<record id="view_inventory_discrepancy_form" model="ir.ui.view">
<field name="name">fusion.inventory.discrepancy.form</field>
<field name="model">fusion.inventory.discrepancy</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_mark_reviewed" type="object"
string="Mark Reviewed" class="btn-primary"
invisible="state != 'detected'"/>
<button name="action_mark_resolved" type="object"
string="Resolve" class="btn-success"
invisible="state != 'reviewed'"/>
<button name="action_ignore" type="object"
string="Ignore" class="btn-secondary"
invisible="state in ('resolved', 'ignored')"/>
<field name="state" widget="statusbar"
statusbar_visible="detected,reviewed,resolved"/>
</header>
<sheet>
<group>
<group>
<field name="product_id"/>
<field name="discrepancy_type"/>
<field name="scan_date"/>
<field name="reviewed_by"/>
</group>
<group>
<field name="expected_qty"/>
<field name="actual_qty"/>
<field name="difference"/>
<field name="source"/>
</group>
</group>
<group string="Serial Numbers">
<field name="missing_serials" nolabel="1"/>
</group>
<group string="Resolution">
<field name="resolution_notes" nolabel="1"
placeholder="Describe how this was resolved..."/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_inventory_discrepancy" model="ir.actions.act_window">
<field name="name">Discrepancies</field>
<field name="res_model">fusion.inventory.discrepancy</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_state': 'detected'}</field>
</record>
</odoo>

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import serial_scan_wizard

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import re
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
SKIP_WORDS = frozenset({
'total', 'price', 'quantity', 'subtotal', 'discount', 'amount',
'invoice', 'order', 'product', 'delivery', 'shipping', 'tracking',
'payment', 'customer', 'vendor', 'notes', 'description', 'reference',
'number', 'date', 'name', 'address', 'phone', 'email', 'unit',
'piece', 'each', 'item', 'line', 'from', 'with', 'that', 'this',
'have', 'will', 'been', 'were', 'would', 'could', 'should',
})
SERIAL_PATTERN = re.compile(r'(?<!\w)([\w][\w-]{3,}[\w])(?!\w)')
class FusionSerialScanWizard(models.TransientModel):
_name = 'fusion.serial.scan.wizard'
_description = 'Serial Number Scan Wizard'
picking_id = fields.Many2one(
'stock.picking', string='Transfer', required=True, readonly=True)
line_ids = fields.One2many(
'fusion.serial.scan.line', 'wizard_id', string='Results')
scan_summary = fields.Text(string='Summary', readonly=True)
def _scan(self):
"""Extract serial numbers from SO/invoice text and match against stock.lot."""
self.ensure_one()
pick = self.picking_id
so = pick.sale_id or self.env['sale.order'].search(
[('name', '=', pick.origin)], limit=1)
if not so:
self.scan_summary = 'No linked sale order found.'
return
text_sources = self._collect_text_sources(so)
product_ids = pick.move_ids.mapped('product_id').ids
results = []
seen = set()
for source_label, text in text_sources:
clean = re.sub(r'<[^>]+>', ' ', text or '')
candidates = SERIAL_PATTERN.findall(clean)
for candidate in candidates:
if len(candidate) < 5:
continue
key = candidate.upper()
if key in seen or candidate.lower() in SKIP_WORDS:
continue
seen.add(key)
if not re.search(r'\d', candidate):
continue
lots = self.env['stock.lot'].search([
('name', '=ilike', candidate),
], limit=5)
matched_product = lots.filtered(
lambda l: l.product_id.id in product_ids)
results.append({
'wizard_id': self.id,
'serial_text': candidate,
'source': source_label,
'found_in_system': bool(lots),
'matched_product': bool(matched_product),
'lot_id': matched_product[:1].id if matched_product else (
lots[:1].id if lots else False),
'product_id': (matched_product[:1].product_id.id if matched_product
else (lots[:1].product_id.id if lots else False)),
'lot_product_name': (matched_product[:1].product_id.name if matched_product
else (lots[:1].product_id.name if lots else '')),
})
if results:
self.env['fusion.serial.scan.line'].create(results)
found = sum(1 for r in results if r['found_in_system'])
matched = sum(1 for r in results if r['matched_product'])
self.scan_summary = (
f'Scanned {len(text_sources)} text sources. '
f'Found {len(results)} potential serial numbers: '
f'{found} exist in system, {matched} match products in this transfer.'
)
def _collect_text_sources(self, so):
"""Gather all text from SO lines, notes, and related invoices."""
sources = []
for line in so.order_line:
if line.name:
sources.append((f'SO Line: {line.product_id.name}', line.name))
if so.note:
sources.append(('SO Notes', so.note))
if so.internal_note if hasattr(so, 'internal_note') else False:
sources.append(('SO Internal Note', so.internal_note))
for inv in so.invoice_ids.filtered(lambda m: m.state == 'posted'):
for line in inv.invoice_line_ids:
if line.name:
sources.append((
f'Invoice {inv.name}: {line.product_id.name if line.product_id else ""}',
line.name))
if inv.narration:
sources.append((f'Invoice {inv.name} Notes', inv.narration))
return sources
class FusionSerialScanLine(models.TransientModel):
_name = 'fusion.serial.scan.line'
_description = 'Serial Scan Result Line'
wizard_id = fields.Many2one(
'fusion.serial.scan.wizard', ondelete='cascade', required=True)
serial_text = fields.Char(string='Serial Number', readonly=True)
source = fields.Char(string='Found In', readonly=True)
found_in_system = fields.Boolean(string='Exists in System', readonly=True)
matched_product = fields.Boolean(
string='Matches Transfer Product', readonly=True)
lot_id = fields.Many2one('stock.lot', string='Matched Lot', readonly=True)
product_id = fields.Many2one(
'product.product', string='Lot Product', readonly=True)
lot_product_name = fields.Char(string='Lot Product Name', readonly=True)

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_serial_scan_wizard_form" model="ir.ui.view">
<field name="name">fusion.serial.scan.wizard.form</field>
<field name="model">fusion.serial.scan.wizard</field>
<field name="arch" type="xml">
<form string="Serial Number Scan Results">
<group>
<field name="picking_id" readonly="1"/>
<field name="scan_summary" readonly="1"/>
</group>
<field name="line_ids" readonly="1" nolabel="1">
<list>
<field name="serial_text" string="Serial Number"/>
<field name="source" string="Found In"/>
<field name="found_in_system" widget="boolean"
decoration-success="found_in_system"
decoration-danger="not found_in_system"/>
<field name="matched_product" widget="boolean"
string="Matches Transfer"
decoration-success="matched_product"/>
<field name="lot_id" string="Matched Lot"/>
<field name="lot_product_name" string="Lot Product"/>
</list>
</field>
<footer>
<button string="Close" class="btn-primary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,32 +0,0 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Inventory Sync',
'version': '19.0.1.0.0',
'category': 'Inventory',
'summary': 'Sync inventory between Westin Healthcare and Mobility Specialties via XML-RPC',
'description': """
Cross-database inventory sync between two Odoo instances.
- Connects to a remote Odoo instance via XML-RPC
- Syncs product catalog with mapping table
- Shows remote stock levels on local products
- Cron-based periodic sync (configurable interval)
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': [
'base',
'stock',
'product',
],
'data': [
'security/ir.model.access.csv',
'data/ir_cron_data.xml',
'views/sync_config_views.xml',
'views/product_views.xml',
],
'installable': True,
'auto_install': False,
'application': False,
}

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="cron_sync_remote_inventory" model="ir.cron">
<field name="name">Fusion: Sync Remote Inventory</field>
<field name="model_id" ref="fusion_inventory_sync.model_fusion_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_sync_inventory()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -1,6 +0,0 @@
# -*- coding: utf-8 -*-
from . import sync_config
from . import product_sync_mapping
from . import product_template
from . import stock_move
from . import sync_log

View File

@@ -1,33 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class ProductTemplate(models.Model):
"""Extend product template with remote inventory visibility."""
_inherit = 'product.template'
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',
store=False,
help='Total on-hand quantity at remote locations (Mobility Specialties)')
remote_qty_forecast = fields.Float(
string='Remote Forecast',
compute='_compute_remote_stock',
store=False,
help='Total forecasted quantity at remote locations')
has_remote_mapping = fields.Boolean(
string='Has Remote Link',
compute='_compute_remote_stock',
store=False)
@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)

View File

@@ -1,116 +0,0 @@
# -*- coding: utf-8 -*-
import logging
from odoo import models, fields
_logger = logging.getLogger(__name__)
class StockMove(models.Model):
"""Extend stock moves to push changes to remote Odoo instance."""
_inherit = 'stock.move'
def _action_done(self, cancel_backorder=False):
"""Override to trigger remote sync after stock moves complete."""
res = super()._action_done(cancel_backorder=cancel_backorder)
# After moves are done, queue a remote stock push for affected products
try:
self._push_stock_to_remote()
except Exception as e:
# Never block local operations due to sync failure
_logger.warning(f'Remote stock push failed (non-blocking): {e}')
return res
def _push_stock_to_remote(self):
"""Push stock level changes to the remote Odoo instance.
Only pushes for products that have a sync mapping.
Runs async-safe: failures don't block local operations.
"""
if not self:
return
# Get unique product templates from completed moves
product_tmpls = self.mapped('product_id.product_tmpl_id')
if not product_tmpls:
return
# Find sync mappings for these products
Mapping = self.env['fusion.product.sync.mapping']
mappings = Mapping.search([
('local_product_id', 'in', product_tmpls.ids),
('config_id.active', '=', True),
('config_id.state', '=', 'connected'),
('remote_product_id', '!=', 0),
])
if not mappings:
return
# Group by config for efficient batch push
configs = {}
for mapping in mappings:
config = mapping.config_id
if config.id not in configs:
configs[config.id] = {
'config': config,
'mappings': self.env['fusion.product.sync.mapping'],
}
configs[config.id]['mappings'] |= mapping
for config_data in configs.values():
config = config_data['config']
config_mappings = config_data['mappings']
try:
self._push_stock_levels(config, config_mappings)
except Exception as e:
_logger.warning(
f'Failed to push stock to {config.name}: {e}'
)
def _push_stock_levels(self, config, mappings):
"""Push current local stock levels to the remote instance.
This updates the remote side with our current on-hand qty
so the remote instance knows what we have available.
"""
uid, models_proxy = config._get_xmlrpc_connection()
for mapping in mappings:
local_product = mapping.local_product_id
if not local_product:
continue
# Get current local stock for this product
local_qty = local_product.qty_available
local_forecast = local_product.virtual_available
# Update the mapping record with current local stock
mapping.write({
'last_stock_sync': fields.Datetime.now(),
})
# Log the push for debugging
_logger.info(
f'Stock push: {local_product.name} -> {config.name} '
f'(local_qty={local_qty}, remote_id={mapping.remote_product_id})'
)
# Optionally update a custom field on the remote side
# This writes to a field on the remote product to track
# what the partner store has available
try:
models_proxy.execute_kw(
config.db_name, uid, config.api_key,
'product.template', 'write',
[[mapping.remote_product_id], {
'x_partner_qty_available': local_qty,
}]
)
except Exception as e:
# If the remote field doesn't exist yet, just log it
_logger.debug(
f'Could not update remote field x_partner_qty_available: {e}. '
f'Create this field on the remote instance for full bi-directional sync.'
)

View File

@@ -1,257 +0,0 @@
# -*- coding: utf-8 -*-
import logging
import xmlrpc.client
from odoo import models, fields, api
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionSyncConfig(models.Model):
"""Configuration for remote Odoo instance connection."""
_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 scope
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.')
def _get_xmlrpc_connection(self):
"""Establish XML-RPC connection to the remote Odoo instance."""
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)}')
def action_test_connection(self):
"""Test the connection to the remote Odoo instance."""
self.ensure_one()
try:
uid, models_proxy = self._get_xmlrpc_connection()
# Test by reading the server version
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):
"""Manually trigger a full sync."""
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,
},
}
def _run_sync(self):
"""Execute the sync process."""
self.ensure_one()
try:
uid, models_proxy = self._get_xmlrpc_connection()
results = []
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) if results else 'Nothing to sync'
self.write({
'state': 'connected',
'last_sync': fields.Datetime.now(),
'last_sync_status': status,
})
# Log the sync operation
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(f'Inventory sync complete: {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,
})
_logger.error(error_msg)
def _sync_products(self, uid, models_proxy):
"""Sync product catalog from remote instance."""
self.ensure_one()
Mapping = self.env['fusion.product.sync.mapping']
# Read remote products (only storable/consumable, with internal reference)
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', 'list_price', 'type', 'categ_id'],
'limit': 5000}
)
synced = 0
for rp in remote_products:
# Try to find existing mapping
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_list_price': rp.get('list_price', 0),
'remote_category': rp.get('categ_id', [False, ''])[1] if rp.get('categ_id') else '',
}
if mapping:
mapping.write(vals)
else:
# Try auto-match by internal reference (SKU)
local_product = False
if rp.get('default_code'):
local_product = self.env['product.template'].search([
('default_code', '=', rp['default_code'])
], limit=1)
# Fallback: try match by exact name
if not local_product and rp.get('name'):
local_product = self.env['product.template'].search([
('name', 'ilike', rp['name'])
], limit=1)
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 _sync_stock_levels(self, uid, models_proxy):
"""Sync stock quantities from remote instance."""
self.ensure_one()
Mapping = self.env['fusion.product.sync.mapping']
# Get all mappings with remote product IDs
mappings = Mapping.search([
('config_id', '=', self.id),
('remote_product_id', '!=', 0),
])
if not mappings:
return 0
# Build list of remote product template IDs
remote_tmpl_ids = mappings.mapped('remote_product_id')
# Get remote product.product IDs for these templates
remote_products = models_proxy.execute_kw(
self.db_name, uid, self.api_key,
'product.product', 'search_read',
[[('product_tmpl_id', 'in', remote_tmpl_ids)]],
{'fields': ['id', 'product_tmpl_id', 'qty_available', 'virtual_available']}
)
# Build dict: template_id -> stock info
stock_by_tmpl = {}
for rp in remote_products:
tmpl_id = rp['product_tmpl_id'][0] if isinstance(rp['product_tmpl_id'], list) else rp['product_tmpl_id']
if tmpl_id not in stock_by_tmpl:
stock_by_tmpl[tmpl_id] = {'qty_available': 0, 'virtual_available': 0}
stock_by_tmpl[tmpl_id]['qty_available'] += rp.get('qty_available', 0)
stock_by_tmpl[tmpl_id]['virtual_available'] += rp.get('virtual_available', 0)
updated = 0
for mapping in mappings:
stock = stock_by_tmpl.get(mapping.remote_product_id, {})
mapping.write({
'remote_qty_available': stock.get('qty_available', 0),
'remote_qty_forecast': stock.get('virtual_available', 0),
'last_stock_sync': fields.Datetime.now(),
})
updated += 1
return updated
@api.model
def _cron_sync_inventory(self):
"""Cron job: run sync for all active configurations."""
configs = self.search([('active', '=', True), ('state', '!=', 'draft')])
for config in configs:
try:
config._run_sync()
except Exception as e:
_logger.error(f'Cron sync failed for {config.name}: {e}')

View File

@@ -1,7 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_sync_config_manager,fusion.sync.config.manager,model_fusion_sync_config,stock.group_stock_manager,1,1,1,1
access_sync_config_user,fusion.sync.config.user,model_fusion_sync_config,stock.group_stock_user,1,0,0,0
access_sync_mapping_manager,fusion.product.sync.mapping.manager,model_fusion_product_sync_mapping,stock.group_stock_manager,1,1,1,1
access_sync_mapping_user,fusion.product.sync.mapping.user,model_fusion_product_sync_mapping,stock.group_stock_user,1,0,0,0
access_sync_log_manager,fusion.sync.log.manager,model_fusion_sync_log,stock.group_stock_manager,1,1,1,1
access_sync_log_user,fusion.sync.log.user,model_fusion_sync_log,stock.group_stock_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_sync_config_manager fusion.sync.config.manager model_fusion_sync_config stock.group_stock_manager 1 1 1 1
3 access_sync_config_user fusion.sync.config.user model_fusion_sync_config stock.group_stock_user 1 0 0 0
4 access_sync_mapping_manager fusion.product.sync.mapping.manager model_fusion_product_sync_mapping stock.group_stock_manager 1 1 1 1
5 access_sync_mapping_user fusion.product.sync.mapping.user model_fusion_product_sync_mapping stock.group_stock_user 1 0 0 0
6 access_sync_log_manager fusion.sync.log.manager model_fusion_sync_log stock.group_stock_manager 1 1 1 1
7 access_sync_log_user fusion.sync.log.user model_fusion_sync_log stock.group_stock_user 1 0 0 0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add remote stock columns to product list view -->
<record id="view_product_template_list_inherit_sync" model="ir.ui.view">
<field name="name">product.template.list.sync</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_tree_view"/>
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<field name="remote_qty_available" string="Remote Stock" optional="show"
decoration-danger="remote_qty_available == 0 and has_remote_mapping"
decoration-success="remote_qty_available > 0"/>
<field name="has_remote_mapping" column_invisible="1"/>
</xpath>
</field>
</record>
<!-- Add remote stock info to product form view -->
<record id="view_product_template_form_inherit_sync" model="ir.ui.view">
<field name="name">product.template.form.sync</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Remote Inventory" name="remote_inventory"
invisible="not has_remote_mapping">
<group>
<group string="Remote Stock Levels">
<field name="remote_qty_available"/>
<field name="remote_qty_forecast"/>
</group>
</group>
<field name="sync_mapping_ids" readonly="1">
<list>
<field name="config_id"/>
<field name="remote_product_name"/>
<field name="remote_default_code"/>
<field name="remote_qty_available"/>
<field name="remote_qty_forecast"/>
<field name="last_stock_sync"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,179 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Sync Config Form View -->
<record id="view_fusion_sync_config_form" model="ir.ui.view">
<field name="name">fusion.sync.config.form</field>
<field name="model">fusion.sync.config</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_test_connection" type="object"
string="Test Connection" class="btn-primary"
invisible="state == 'connected'"/>
<button name="action_test_connection" type="object"
string="Re-Test Connection"
invisible="state != 'connected'"/>
<button name="action_sync_now" type="object"
string="Sync Now" class="btn-primary"
invisible="state != 'connected'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,connected"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="e.g., Mobility Specialties"/></h1>
</div>
<group>
<group string="Connection">
<field name="url" placeholder="https://erp.mobilityspecialties.com"/>
<field name="db_name" placeholder="mobility-prod"/>
<field name="username" placeholder="admin"/>
<field name="api_key" password="True"/>
</group>
<group string="Sync Settings">
<field name="sync_products"/>
<field name="sync_stock"/>
<field name="sync_interval"/>
<field name="remote_warehouse_name"
placeholder="Leave empty for all warehouses"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group string="Status">
<field name="last_sync"/>
<field name="last_sync_status"/>
<field name="remote_uid" invisible="state != 'connected'"/>
</group>
<notebook>
<page string="Product Mappings" name="mappings">
<field name="active" invisible="1"/>
<p class="text-muted" invisible="state == 'connected'">
Connect and sync to see product mappings here.
</p>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Sync Config List View -->
<record id="view_fusion_sync_config_list" model="ir.ui.view">
<field name="name">fusion.sync.config.list</field>
<field name="model">fusion.sync.config</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="url"/>
<field name="state" widget="badge"
decoration-success="state == 'connected'"
decoration-danger="state == 'error'"
decoration-info="state == 'draft'"/>
<field name="last_sync"/>
<field name="last_sync_status"/>
</list>
</field>
</record>
<!-- Product Mapping List View -->
<record id="view_fusion_product_sync_mapping_list" model="ir.ui.view">
<field name="name">fusion.product.sync.mapping.list</field>
<field name="model">fusion.product.sync.mapping</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="remote_product_name"/>
<field name="remote_default_code"/>
<field name="local_product_id"/>
<field name="auto_matched" widget="boolean"/>
<field name="remote_qty_available"
decoration-danger="remote_qty_available == 0"
decoration-success="remote_qty_available > 0"/>
<field name="remote_qty_forecast"/>
<field name="last_stock_sync"/>
<field name="config_id" column_invisible="1"/>
</list>
</field>
</record>
<!-- Product Mapping Search View -->
<record id="view_fusion_product_sync_mapping_search" model="ir.ui.view">
<field name="name">fusion.product.sync.mapping.search</field>
<field name="model">fusion.product.sync.mapping</field>
<field name="arch" type="xml">
<search>
<field name="remote_product_name"/>
<field name="remote_default_code"/>
<field name="local_product_id"/>
<separator/>
<filter name="mapped" string="Mapped"
domain="[('local_product_id', '!=', False)]"/>
<filter name="unmapped" string="Unmapped"
domain="[('local_product_id', '=', False)]"/>
<filter name="in_stock" string="Remote In Stock"
domain="[('remote_qty_available', '&gt;', 0)]"/>
</search>
</field>
</record>
<!-- Menu Items -->
<menuitem id="menu_fusion_sync_root"
name="Inventory Sync"
parent="stock.menu_stock_config_settings"
sequence="100"/>
<record id="action_fusion_sync_config" model="ir.actions.act_window">
<field name="name">Sync Configurations</field>
<field name="res_model">fusion.sync.config</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_sync_config"
name="Remote Connections"
parent="menu_fusion_sync_root"
action="action_fusion_sync_config"
sequence="10"/>
<record id="action_fusion_product_mapping" model="ir.actions.act_window">
<field name="name">Product Mappings</field>
<field name="res_model">fusion.product.sync.mapping</field>
<field name="view_mode">list</field>
<field name="context">{'search_default_mapped': 1}</field>
</record>
<menuitem id="menu_fusion_product_mapping"
name="Product Mappings"
parent="menu_fusion_sync_root"
action="action_fusion_product_mapping"
sequence="20"/>
<!-- Sync Log List View -->
<record id="view_fusion_sync_log_list" model="ir.ui.view">
<field name="name">fusion.sync.log.list</field>
<field name="model">fusion.sync.log</field>
<field name="arch" type="xml">
<list>
<field name="create_date" string="Date"/>
<field name="config_id"/>
<field name="direction"/>
<field name="sync_type"/>
<field name="status" widget="badge"
decoration-success="status == 'success'"
decoration-warning="status == 'partial'"
decoration-danger="status == 'error'"/>
<field name="summary"/>
<field name="product_count"/>
</list>
</field>
</record>
<record id="action_fusion_sync_log" model="ir.actions.act_window">
<field name="name">Sync Log</field>
<field name="res_model">fusion.sync.log</field>
<field name="view_mode">list</field>
</record>
<menuitem id="menu_fusion_sync_log"
name="Sync Log"
parent="menu_fusion_sync_root"
action="action_fusion_sync_log"
sequence="30"/>
</odoo>