changes
This commit is contained in:
@@ -722,7 +722,10 @@ class AssessmentPortal(CustomerPortal):
|
||||
|
||||
# Post message to chatter with photos
|
||||
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',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
attachment_ids=attachment_ids,
|
||||
|
||||
43
fusion_chatter_enhance/__manifest__.py
Normal file
43
fusion_chatter_enhance/__manifest__.py
Normal 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,
|
||||
}
|
||||
3
fusion_chatter_enhance/models/__init__.py
Normal file
3
fusion_chatter_enhance/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import ir_http
|
||||
from . import res_users
|
||||
18
fusion_chatter_enhance/models/ir_http.py
Normal file
18
fusion_chatter_enhance/models/ir_http.py
Normal 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
|
||||
31
fusion_chatter_enhance/models/res_users.py
Normal file
31
fusion_chatter_enhance/models/res_users.py
Normal 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']
|
||||
BIN
fusion_chatter_enhance/static/description/icon.png
Normal file
BIN
fusion_chatter_enhance/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
424
fusion_chatter_enhance/static/src/js/chatter_panel.js
Normal file
424
fusion_chatter_enhance/static/src/js/chatter_panel.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
341
fusion_chatter_enhance/static/src/scss/chatter_enhance.scss
Normal file
341
fusion_chatter_enhance/static/src/scss/chatter_enhance.scss
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
23
fusion_chatter_enhance/views/res_users_views.xml
Normal file
23
fusion_chatter_enhance/views/res_users_views.xml
Normal 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>
|
||||
22
fusion_chatter_enhance/views/templates.xml
Normal file
22
fusion_chatter_enhance/views/templates.xml
Normal 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>
|
||||
@@ -1,15 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2025 Nexa Systems Inc.
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Claim Assistant product family.
|
||||
|
||||
import logging
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_adp_device_codes(env):
|
||||
"""
|
||||
Post-init hook to load ADP Mobility Manual device codes.
|
||||
Called on module install AND upgrade.
|
||||
"""Post-init hook: load device codes then link products to them.
|
||||
|
||||
Called on module install AND upgrade. Each step is idempotent.
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
'wizard/xml_import_wizard_views.xml',
|
||||
'views/adp_claims_views.xml',
|
||||
'views/submission_history_views.xml',
|
||||
'views/product_template_adp_views.xml',
|
||||
'views/fusion_loaner_views.xml',
|
||||
'views/page11_sign_request_views.xml',
|
||||
'views/technician_task_views.xml',
|
||||
@@ -160,7 +161,6 @@
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'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/preview_button_widget.js',
|
||||
'fusion_claims/static/src/js/status_selection_filter.js',
|
||||
|
||||
@@ -84,6 +84,15 @@ class FusionADPDeviceCode(models.Model):
|
||||
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
|
||||
# ==========================================================================
|
||||
@@ -92,6 +101,28 @@ class FusionADPDeviceCode(models.Model):
|
||||
'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
|
||||
# ==========================================================================
|
||||
|
||||
@@ -10,70 +10,68 @@ class ProductProduct(models.Model):
|
||||
_inherit = 'product.product'
|
||||
|
||||
def get_adp_device_code(self):
|
||||
"""
|
||||
Get ADP device code from the field mapped in fusion settings.
|
||||
|
||||
The field name is configured in Settings → Sales → Fusion Central →
|
||||
Field Mappings → Product ADP Code Field.
|
||||
|
||||
Checks the mapped field on the product variant first, then on template.
|
||||
Returns the value from the mapped field, or empty string if not found.
|
||||
"""Get ADP device code, preferring the linked device code record.
|
||||
|
||||
Checks in order:
|
||||
1. Linked Many2one device code record on template
|
||||
2. x_fc_adp_device_code char field on template
|
||||
3. Mapped field from fusion settings (legacy)
|
||||
4. default_code
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Get the mapped field name from fusion settings
|
||||
tmpl = self.product_tmpl_id
|
||||
|
||||
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()
|
||||
field_name = ICP.get_param('fusion_claims.field_product_code', 'x_fc_adp_device_code')
|
||||
|
||||
if not field_name:
|
||||
return ''
|
||||
|
||||
# Check if the mapped field exists on the product variant (product.product)
|
||||
if field_name in self._fields:
|
||||
value = getattr(self, field_name, '') or ''
|
||||
if value:
|
||||
return value
|
||||
|
||||
# Check if the mapped field exists on the product template
|
||||
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 ''
|
||||
if field_name and field_name != 'x_fc_adp_device_code':
|
||||
if field_name in self._fields:
|
||||
value = getattr(self, field_name, '') or ''
|
||||
if value:
|
||||
return value
|
||||
if tmpl and field_name in tmpl._fields:
|
||||
value = getattr(tmpl, field_name, '') or ''
|
||||
if value:
|
||||
return value
|
||||
|
||||
return self.default_code or ''
|
||||
|
||||
def get_adp_price(self):
|
||||
"""
|
||||
Get ADP price from the field mapped in fusion settings.
|
||||
|
||||
The field name is configured in Settings → Sales → Fusion Central →
|
||||
Field Mappings → Product ADP Price Field.
|
||||
|
||||
Checks the mapped field on the product variant first, then on template.
|
||||
Returns the value from the mapped field, or 0.0 if not found.
|
||||
"""Get ADP price, preferring the linked device code record.
|
||||
|
||||
Checks in order:
|
||||
1. Linked Many2one device code record price on template
|
||||
2. x_fc_adp_price field on template
|
||||
3. Mapped field from fusion settings (legacy)
|
||||
4. list_price
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Get the mapped field name from fusion settings
|
||||
tmpl = self.product_tmpl_id
|
||||
|
||||
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()
|
||||
field_name = ICP.get_param('fusion_claims.field_product_adp_price', 'x_fc_adp_price')
|
||||
|
||||
if not field_name:
|
||||
return 0.0
|
||||
|
||||
# Check if the mapped field exists on the product variant (product.product)
|
||||
if field_name in self._fields:
|
||||
value = getattr(self, field_name, 0.0) or 0.0
|
||||
if value:
|
||||
return value
|
||||
|
||||
# Check if the mapped field exists on the product template
|
||||
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
|
||||
if field_name and field_name != 'x_fc_adp_price':
|
||||
if field_name in self._fields:
|
||||
value = getattr(self, field_name, 0.0) or 0.0
|
||||
if value:
|
||||
return value
|
||||
if tmpl and field_name in tmpl._fields:
|
||||
value = getattr(tmpl, field_name, 0.0) or 0.0
|
||||
if value:
|
||||
return value
|
||||
|
||||
return tmpl.list_price if tmpl else 0.0
|
||||
|
||||
def is_non_adp_funded(self):
|
||||
"""
|
||||
@@ -114,65 +112,71 @@ class ProductProduct(models.Model):
|
||||
return False
|
||||
|
||||
def action_sync_adp_price_from_database(self):
|
||||
"""
|
||||
Update product's ADP price from the device codes database.
|
||||
|
||||
Looks up the product's ADP device code in the fusion.adp.device.code table
|
||||
and updates the product's x_fc_adp_price field with the database value.
|
||||
|
||||
Returns a notification with the result.
|
||||
"""Sync product ADP data from the device codes database.
|
||||
|
||||
Looks up the product's device code in fusion.adp.device.code and
|
||||
populates the Many2one link, price, and device code string.
|
||||
"""
|
||||
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
||||
updated = []
|
||||
not_found = []
|
||||
no_code = []
|
||||
|
||||
|
||||
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:
|
||||
no_code.append(product.name)
|
||||
continue
|
||||
|
||||
|
||||
adp_device = ADPDevice.search([
|
||||
('device_code', '=', device_code),
|
||||
('active', '=', True)
|
||||
], limit=1)
|
||||
|
||||
if adp_device and adp_device.adp_price:
|
||||
# Update product template
|
||||
product_tmpl = product.product_tmpl_id
|
||||
old_price = 0
|
||||
|
||||
if hasattr(product_tmpl, 'x_fc_adp_price'):
|
||||
old_price = getattr(product_tmpl, 'x_fc_adp_price', 0) or 0
|
||||
product_tmpl.sudo().write({'x_fc_adp_price': adp_device.adp_price})
|
||||
updated.append({
|
||||
'name': product.name,
|
||||
'code': device_code,
|
||||
'old_price': old_price,
|
||||
'new_price': adp_device.adp_price,
|
||||
})
|
||||
|
||||
if adp_device:
|
||||
old_price = product_tmpl.x_fc_adp_price or 0
|
||||
write_vals = {
|
||||
'x_fc_adp_device_code_id': adp_device.id,
|
||||
'x_fc_adp_device_code': adp_device.device_code,
|
||||
'x_fc_adp_price': adp_device.adp_price,
|
||||
'x_fc_is_adp_product': True,
|
||||
}
|
||||
product_tmpl.sudo().write(write_vals)
|
||||
updated.append({
|
||||
'name': product.name,
|
||||
'code': device_code,
|
||||
'old_price': old_price,
|
||||
'new_price': adp_device.adp_price,
|
||||
})
|
||||
else:
|
||||
not_found.append(f"{product.name} ({device_code})")
|
||||
|
||||
# Build result message
|
||||
|
||||
message_parts = []
|
||||
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:
|
||||
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>"
|
||||
message_parts.append(msg)
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
message_parts.append("No products to process.")
|
||||
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# 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):
|
||||
@@ -11,12 +12,26 @@ class ProductTemplate(models.Model):
|
||||
|
||||
# ==========================================================================
|
||||
# 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',
|
||||
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,
|
||||
tracking=True,
|
||||
)
|
||||
@@ -24,16 +39,30 @@ class ProductTemplate(models.Model):
|
||||
x_fc_adp_price = fields.Float(
|
||||
string='ADP 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,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
x_fc_is_adp_product = fields.Boolean(
|
||||
string='Is ADP Product',
|
||||
compute='_compute_is_adp_product',
|
||||
x_fc_adp_device_type = fields.Char(
|
||||
related='x_fc_adp_device_code_id.device_type',
|
||||
string='Device Type',
|
||||
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')
|
||||
|
||||
# ==========================================================================
|
||||
# COMPUTED FIELDS
|
||||
# ONCHANGE / CONSTRAINTS
|
||||
# ==========================================================================
|
||||
|
||||
@api.depends('x_fc_adp_device_code', 'x_fc_adp_price')
|
||||
def _compute_is_adp_product(self):
|
||||
"""Determine if this is an ADP product based on having device code or price."""
|
||||
@api.onchange('x_fc_adp_device_code_id')
|
||||
def _onchange_adp_device_code_id(self):
|
||||
"""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:
|
||||
product.x_fc_is_adp_product = bool(
|
||||
product.x_fc_adp_device_code or product.x_fc_adp_price
|
||||
)
|
||||
if product.x_fc_is_adp_product and not product.x_fc_adp_device_code_id:
|
||||
raise ValidationError(
|
||||
_("'%s' is marked as an ADP Product but has no ADP Device Code selected.") % product.name
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# HELPER METHODS
|
||||
# ==========================================================================
|
||||
|
||||
def get_adp_price(self):
|
||||
"""
|
||||
Get ADP price with fallback to Studio field.
|
||||
|
||||
"""Get ADP price, preferring the linked device code record.
|
||||
|
||||
Checks in order:
|
||||
1. x_fc_adp_price (module field)
|
||||
2. list_price (default product price)
|
||||
1. Linked device code record price
|
||||
2. x_fc_adp_price (stored field)
|
||||
3. list_price (default product price)
|
||||
"""
|
||||
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:
|
||||
return self.x_fc_adp_price
|
||||
|
||||
|
||||
return self.list_price or 0.0
|
||||
|
||||
def get_adp_device_code(self):
|
||||
"""
|
||||
Get ADP device code.
|
||||
|
||||
"""Get ADP device code, preferring the linked device code record.
|
||||
|
||||
Checks in order:
|
||||
1. x_fc_adp_device_code (module field)
|
||||
2. default_code (internal reference)
|
||||
1. Linked device code record
|
||||
2. x_fc_adp_device_code (stored char)
|
||||
3. default_code (internal reference)
|
||||
"""
|
||||
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:
|
||||
return self.x_fc_adp_device_code
|
||||
|
||||
|
||||
return self.default_code or ''
|
||||
|
||||
# ==========================================================================
|
||||
|
||||
@@ -4626,11 +4626,13 @@ class SaleOrder(models.Model):
|
||||
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>'
|
||||
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:
|
||||
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'):
|
||||
product_tmpl.sudo().write({'x_fc_adp_price': pm['db_price']})
|
||||
|
||||
@@ -6905,9 +6907,11 @@ class SaleOrder(models.Model):
|
||||
# Post to chatter
|
||||
days_since_billed = (today - order.x_fc_billing_date).days
|
||||
order.message_post(
|
||||
body=f'<p><strong><i class="fa fa-check-circle text-success"/> Case Automatically Closed</strong></p>'
|
||||
f'<p>This case has been automatically closed after {days_since_billed} days since billing.</p>'
|
||||
f'<p>Billing Date: {order.x_fc_billing_date}</p>',
|
||||
body=Markup(
|
||||
'<p><strong><i class="fa fa-check-circle text-success"/> Case Automatically Closed</strong></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',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
77
fusion_claims/views/product_template_adp_views.xml
Normal file
77
fusion_claims/views/product_template_adp_views.xml
Normal 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>
|
||||
104
fusion_inventory/__init__.py
Normal file
104
fusion_inventory/__init__.py
Normal 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)
|
||||
68
fusion_inventory/__manifest__.py
Normal file
68
fusion_inventory/__manifest__.py
Normal 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',
|
||||
}
|
||||
6
fusion_inventory/controllers/__init__.py
Normal file
6
fusion_inventory/controllers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import portal_inventory
|
||||
from . import product_configurator
|
||||
Binary file not shown.
422
fusion_inventory/controllers/portal_inventory.py
Normal file
422
fusion_inventory/controllers/portal_inventory.py
Normal file
@@ -0,0 +1,422 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from odoo import http, fields
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
ITEMS_PER_PAGE = 50
|
||||
|
||||
|
||||
class PortalInventory(http.Controller):
|
||||
|
||||
# ── Core data builder ──
|
||||
|
||||
def _get_inventory_data(self, search='', category_ids=None, page=1,
|
||||
warehouse_filter=None):
|
||||
"""Build inventory data merging local stock with remote sync data.
|
||||
Fetches all matching products, filters/sorts globally, then paginates
|
||||
so local in-stock items always appear first across all pages."""
|
||||
Product = request.env['product.product'].sudo()
|
||||
Booking = request.env['fusion.inventory.booking'].sudo()
|
||||
POLine = request.env['purchase.order.line'].sudo()
|
||||
Mapping = request.env['fusion.product.sync.mapping'].sudo()
|
||||
|
||||
show_local = warehouse_filter in (None, '', 'all', 'local')
|
||||
show_remote = warehouse_filter in (None, '', 'all', 'remote')
|
||||
|
||||
domain = [
|
||||
('type', '!=', 'service'),
|
||||
('is_storable', '=', True),
|
||||
('active', '=', True),
|
||||
]
|
||||
if search:
|
||||
domain += ['|', '|',
|
||||
('name', 'ilike', search),
|
||||
('default_code', 'ilike', search),
|
||||
('barcode', 'ilike', search)]
|
||||
if category_ids:
|
||||
domain.append(('categ_id', 'in', category_ids))
|
||||
|
||||
all_products = Product.search(domain, order='name asc')
|
||||
|
||||
all_product_ids = all_products.ids
|
||||
all_tmpl_ids = all_products.mapped('product_tmpl_id').ids
|
||||
|
||||
bookings = Booking.search([
|
||||
('product_id', 'in', all_product_ids),
|
||||
('state', '=', 'active'),
|
||||
])
|
||||
booked_map = {}
|
||||
for b in bookings:
|
||||
booked_map.setdefault(b.product_id.id, 0)
|
||||
booked_map[b.product_id.id] += b.quantity
|
||||
|
||||
po_lines = POLine.search([
|
||||
('product_id', 'in', all_product_ids),
|
||||
('order_id.state', 'in', ('purchase', 'done')),
|
||||
])
|
||||
shadow_map = {}
|
||||
for pl in po_lines:
|
||||
remaining = pl.product_qty - pl.qty_received
|
||||
if remaining > 0:
|
||||
shadow_map.setdefault(pl.product_id.id, 0)
|
||||
shadow_map[pl.product_id.id] += remaining
|
||||
|
||||
remote_stock_map = {}
|
||||
remote_warehouses_map = {}
|
||||
if show_remote:
|
||||
mappings = Mapping.search([
|
||||
('local_product_id', 'in', all_tmpl_ids),
|
||||
('config_id.active', '=', True),
|
||||
])
|
||||
for m in mappings:
|
||||
tmpl_id = m.local_product_id.id
|
||||
total_remote = m.remote_qty_available or 0
|
||||
remote_stock_map[tmpl_id] = (
|
||||
remote_stock_map.get(tmpl_id, 0) + total_remote)
|
||||
|
||||
wh_details = []
|
||||
for sl in m.stock_line_ids:
|
||||
if sl.qty_available > 0:
|
||||
wh_details.append({
|
||||
'warehouse': sl.sync_warehouse_id.name,
|
||||
'code': sl.sync_warehouse_id.code,
|
||||
'company': sl.sync_warehouse_id.company_name,
|
||||
'qty': sl.qty_available,
|
||||
})
|
||||
if wh_details:
|
||||
remote_warehouses_map.setdefault(tmpl_id, []).extend(
|
||||
wh_details)
|
||||
|
||||
all_rows = []
|
||||
for p in all_products:
|
||||
booked = booked_map.get(p.id, 0)
|
||||
shadow = shadow_map.get(p.id, 0)
|
||||
cost = p.standard_price or 0
|
||||
sale = p.lst_price or 0
|
||||
margin_pct = (
|
||||
((sale - cost) / sale * 100) if sale > 0 and cost > 0 else 0)
|
||||
remote_qty = remote_stock_map.get(p.product_tmpl_id.id, 0)
|
||||
remote_whs = remote_warehouses_map.get(p.product_tmpl_id.id, [])
|
||||
local_qty = p.qty_available
|
||||
|
||||
total_stock = local_qty + remote_qty + shadow
|
||||
if total_stock <= 0:
|
||||
continue
|
||||
|
||||
if warehouse_filter == 'local' and local_qty <= 0:
|
||||
continue
|
||||
if warehouse_filter == 'remote' and remote_qty <= 0:
|
||||
continue
|
||||
|
||||
all_rows.append({
|
||||
'id': p.id,
|
||||
'tmpl_id': p.product_tmpl_id.id,
|
||||
'name': p.name or '',
|
||||
'default_code': p.default_code or '',
|
||||
'category': p.categ_id.display_name if p.categ_id else '',
|
||||
'category_id': p.categ_id.id if p.categ_id else 0,
|
||||
'qty_on_hand': local_qty,
|
||||
'available_qty': max(local_qty - booked, 0),
|
||||
'booked_qty': booked,
|
||||
'shadow_qty': shadow,
|
||||
'remote_qty': round(remote_qty, 1) if show_remote else 0,
|
||||
'remote_warehouses': remote_whs if show_remote else [],
|
||||
'total_qty': round(
|
||||
local_qty + (remote_qty if show_remote else 0), 1),
|
||||
'sale_price': round(sale, 2),
|
||||
'margin_pct': round(margin_pct, 1),
|
||||
})
|
||||
|
||||
all_rows.sort(key=lambda r: (-r['qty_on_hand'], r['name']))
|
||||
|
||||
total = len(all_rows)
|
||||
offset = (page - 1) * ITEMS_PER_PAGE
|
||||
page_rows = all_rows[offset:offset + ITEMS_PER_PAGE]
|
||||
|
||||
remote_only_rows = self._get_remote_only_products(
|
||||
search, category_ids, all_tmpl_ids, warehouse_filter)
|
||||
remote_total = len(remote_only_rows)
|
||||
|
||||
return page_rows, total, remote_only_rows, remote_total
|
||||
|
||||
def _get_remote_only_products(self, search='', category_ids=None,
|
||||
exclude_tmpl_ids=None,
|
||||
warehouse_filter=None):
|
||||
"""Return products that exist only on remote instances.
|
||||
Excluded when filter is 'local' or when a category filter is active
|
||||
(remote categories can't be matched to local category IDs)."""
|
||||
if warehouse_filter == 'local':
|
||||
return []
|
||||
|
||||
Mapping = request.env['fusion.product.sync.mapping'].sudo()
|
||||
|
||||
domain = [
|
||||
('local_product_id', '=', False),
|
||||
('config_id.active', '=', True),
|
||||
('remote_qty_available', '>', 0),
|
||||
]
|
||||
if search:
|
||||
domain.append(('remote_product_name', 'ilike', search))
|
||||
|
||||
if category_ids:
|
||||
cat_names = request.env['product.category'].sudo().browse(
|
||||
category_ids).mapped('name')
|
||||
if cat_names:
|
||||
cat_domain = ['|'] * (len(cat_names) - 1)
|
||||
for cn in cat_names:
|
||||
cat_domain.append(('remote_category', 'ilike', cn))
|
||||
domain += cat_domain
|
||||
else:
|
||||
return []
|
||||
|
||||
mappings = Mapping.search(domain, limit=50)
|
||||
|
||||
rows = []
|
||||
for m in mappings:
|
||||
wh_details = []
|
||||
for sl in m.stock_line_ids:
|
||||
if sl.qty_available > 0:
|
||||
wh_details.append({
|
||||
'warehouse': sl.sync_warehouse_id.name,
|
||||
'code': sl.sync_warehouse_id.code,
|
||||
'company': sl.sync_warehouse_id.company_name,
|
||||
'qty': sl.qty_available,
|
||||
})
|
||||
|
||||
rows.append({
|
||||
'id': 0,
|
||||
'tmpl_id': 0,
|
||||
'name': m.remote_product_name or '',
|
||||
'default_code': m.remote_default_code or '',
|
||||
'category': m.remote_category or '',
|
||||
'category_id': 0,
|
||||
'qty_on_hand': 0,
|
||||
'available_qty': 0,
|
||||
'booked_qty': 0,
|
||||
'shadow_qty': 0,
|
||||
'remote_qty': round(m.remote_qty_available, 1),
|
||||
'remote_warehouses': wh_details,
|
||||
'total_qty': round(m.remote_qty_available, 1),
|
||||
'sale_price': round(m.remote_list_price or 0, 2),
|
||||
'margin_pct': 0,
|
||||
'remote_only': True,
|
||||
'config_id': m.config_id.id,
|
||||
'config_name': m.config_id.name,
|
||||
})
|
||||
|
||||
return rows
|
||||
|
||||
# ── Main page ──
|
||||
|
||||
@http.route('/my/inventory', type='http', auth='user', website=True)
|
||||
def portal_inventory(self, search='', category=None, page=1,
|
||||
warehouse=None, **kw):
|
||||
page = int(page)
|
||||
category_ids = None
|
||||
if category:
|
||||
try:
|
||||
category_ids = [int(c) for c in category.split(',') if c]
|
||||
except (ValueError, AttributeError):
|
||||
category_ids = None
|
||||
|
||||
rows, total, remote_only, remote_total = self._get_inventory_data(
|
||||
search, category_ids, page, warehouse)
|
||||
|
||||
categories = request.env['product.category'].sudo().search(
|
||||
[], order='name asc')
|
||||
|
||||
total_pages = max(1, (total + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE)
|
||||
|
||||
has_sync = bool(request.env['fusion.sync.config'].sudo().search([
|
||||
('active', '=', True), ('state', '=', 'connected'),
|
||||
], limit=1))
|
||||
|
||||
all_warehouses = self._get_all_warehouses()
|
||||
|
||||
return request.render('fusion_inventory.portal_inventory_sheet', {
|
||||
'products': rows,
|
||||
'remote_only_products': remote_only,
|
||||
'categories': categories,
|
||||
'search': search,
|
||||
'selected_categories': category_ids or [],
|
||||
'page': page,
|
||||
'total_pages': total_pages,
|
||||
'total_products': total + remote_total,
|
||||
'page_name': 'fi_inventory',
|
||||
'has_sync': has_sync,
|
||||
'all_warehouses': all_warehouses,
|
||||
'warehouse_filter': warehouse or 'all',
|
||||
})
|
||||
|
||||
# ── JSON-RPC endpoints ──
|
||||
|
||||
@http.route('/my/inventory/data', type='json', auth='user')
|
||||
def portal_inventory_data(self, search='', category_ids=None, page=1,
|
||||
warehouse=None):
|
||||
rows, total, remote_only, _ = self._get_inventory_data(
|
||||
search, category_ids, int(page), warehouse)
|
||||
total_pages = max(1, (total + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE)
|
||||
return {
|
||||
'products': rows + remote_only,
|
||||
'total': total,
|
||||
'page': int(page),
|
||||
'total_pages': total_pages,
|
||||
}
|
||||
|
||||
@http.route('/my/inventory/book', type='json', auth='user')
|
||||
def portal_inventory_book(self, product_id, quantity=1):
|
||||
product_id = int(product_id)
|
||||
quantity = float(quantity)
|
||||
|
||||
product = request.env['product.product'].sudo().browse(product_id)
|
||||
if not product.exists():
|
||||
return {'error': 'Product not found'}
|
||||
|
||||
Booking = request.env['fusion.inventory.booking'].sudo()
|
||||
|
||||
existing = Booking.search([
|
||||
('product_id', '=', product_id),
|
||||
('user_id', '=', request.env.uid),
|
||||
('state', '=', 'active'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {'error': 'You already have an active booking for this product',
|
||||
'booking_id': existing.id}
|
||||
|
||||
hold_hours = int(request.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_inventory.booking_hold_hours', '24') or 24)
|
||||
|
||||
booking = Booking.create({
|
||||
'product_id': product_id,
|
||||
'user_id': request.env.uid,
|
||||
'quantity': quantity,
|
||||
'expiry_datetime': fields.Datetime.now() + timedelta(hours=hold_hours),
|
||||
'state': 'active',
|
||||
})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'booking_id': booking.id,
|
||||
'expires_at': fields.Datetime.to_string(booking.expiry_datetime),
|
||||
}
|
||||
|
||||
@http.route('/my/inventory/release', type='json', auth='user')
|
||||
def portal_inventory_release(self, booking_id):
|
||||
booking = request.env['fusion.inventory.booking'].sudo().browse(int(booking_id))
|
||||
if not booking.exists() or booking.user_id.id != request.env.uid:
|
||||
return {'error': 'Booking not found'}
|
||||
booking.action_release()
|
||||
return {'success': True}
|
||||
|
||||
@http.route('/my/inventory/categories', type='json', auth='user')
|
||||
def portal_inventory_categories(self, search=''):
|
||||
domain = []
|
||||
if search:
|
||||
domain.append(('name', 'ilike', search))
|
||||
categories = request.env['product.category'].sudo().search(
|
||||
domain, order='name asc', limit=100)
|
||||
return [{'id': c.id, 'name': c.display_name} for c in categories]
|
||||
|
||||
@http.route('/my/inventory/warehouses', type='json', auth='user')
|
||||
def portal_inventory_warehouses(self):
|
||||
return self._get_all_warehouses()
|
||||
|
||||
@http.route('/my/inventory/transfer', type='json', auth='user')
|
||||
def portal_inventory_transfer(self, product_id, quantity=1, config_id=None):
|
||||
"""Create and execute an inter-company transfer from the portal."""
|
||||
product_id = int(product_id)
|
||||
quantity = float(quantity)
|
||||
|
||||
product = request.env['product.product'].sudo().browse(product_id)
|
||||
if not product.exists():
|
||||
return {'error': 'Product not found'}
|
||||
|
||||
if config_id:
|
||||
config = request.env['fusion.sync.config'].sudo().browse(int(config_id))
|
||||
else:
|
||||
config = request.env['fusion.sync.config'].sudo().search([
|
||||
('active', '=', True),
|
||||
('state', '=', 'connected'),
|
||||
], limit=1)
|
||||
|
||||
if not config:
|
||||
return {'error': 'No sync connection configured'}
|
||||
|
||||
Mapping = request.env['fusion.product.sync.mapping'].sudo()
|
||||
mapping = Mapping.search([
|
||||
('config_id', '=', config.id),
|
||||
('local_product_id', '=', product.product_tmpl_id.id),
|
||||
], limit=1)
|
||||
|
||||
if not mapping:
|
||||
return {'error': f'Product not mapped on {config.name}. Run sync first.'}
|
||||
|
||||
if mapping.remote_qty_available < quantity:
|
||||
return {
|
||||
'error': f'Insufficient remote stock. Available: {mapping.remote_qty_available}',
|
||||
}
|
||||
|
||||
Transfer = request.env['fusion.inter.company.transfer'].sudo()
|
||||
transfer = Transfer.create({
|
||||
'config_id': config.id,
|
||||
'product_id': product_id,
|
||||
'quantity': quantity,
|
||||
'requested_by': request.env.uid,
|
||||
'notes': 'Created from portal inventory sheet.',
|
||||
})
|
||||
|
||||
try:
|
||||
transfer.action_execute_transfer()
|
||||
except Exception as e:
|
||||
_logger.error('Portal transfer failed: %s', e)
|
||||
return {
|
||||
'error': f'Transfer failed: {str(e)}',
|
||||
'transfer_id': transfer.id,
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'transfer_id': transfer.id,
|
||||
'state': transfer.state,
|
||||
'local_po': transfer.local_po_id.name if transfer.local_po_id else None,
|
||||
'remote_so': transfer.remote_so_name,
|
||||
}
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _get_all_warehouses(self):
|
||||
"""Return local (current company only) + remote warehouses for filter."""
|
||||
warehouses = []
|
||||
|
||||
user_company = request.env.company
|
||||
local_whs = request.env['stock.warehouse'].sudo().search([
|
||||
('company_id', '=', user_company.id),
|
||||
])
|
||||
for wh in local_whs:
|
||||
warehouses.append({
|
||||
'id': f'local_{wh.id}',
|
||||
'name': wh.name,
|
||||
'code': wh.code,
|
||||
'type': 'local',
|
||||
'company': wh.company_id.name,
|
||||
})
|
||||
|
||||
remote_whs = request.env['fusion.sync.warehouse'].sudo().search([
|
||||
('active', '=', True),
|
||||
])
|
||||
for wh in remote_whs:
|
||||
warehouses.append({
|
||||
'id': f'remote_{wh.id}',
|
||||
'name': wh.name,
|
||||
'code': wh.code,
|
||||
'type': 'remote',
|
||||
'company': wh.company_name,
|
||||
})
|
||||
|
||||
return warehouses
|
||||
27
fusion_inventory/controllers/product_configurator.py
Normal file
27
fusion_inventory/controllers/product_configurator.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo.http import request
|
||||
from odoo.addons.sale.controllers.product_configurator import (
|
||||
SaleProductConfiguratorController,
|
||||
)
|
||||
|
||||
_original_get_ptav_price_extra = (
|
||||
SaleProductConfiguratorController._get_ptav_price_extra
|
||||
)
|
||||
|
||||
|
||||
def _fi_get_ptav_price_extra(self, ptav, currency, date, product_or_template):
|
||||
extra = _original_get_ptav_price_extra(
|
||||
self, ptav, currency, date, product_or_template)
|
||||
impact = ptav.x_fi_extra_price_impact or 0.0
|
||||
if impact:
|
||||
extra += ptav.currency_id._convert(
|
||||
impact, currency, request.env.company, date.date())
|
||||
return extra
|
||||
|
||||
|
||||
SaleProductConfiguratorController._get_ptav_price_extra = (
|
||||
_fi_get_ptav_price_extra
|
||||
)
|
||||
25
fusion_inventory/data/ir_config_parameter_data.xml
Normal file
25
fusion_inventory/data/ir_config_parameter_data.xml
Normal 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>
|
||||
36
fusion_inventory/data/ir_cron_data.xml
Normal file
36
fusion_inventory/data/ir_cron_data.xml
Normal 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>
|
||||
23
fusion_inventory/models/__init__.py
Normal file
23
fusion_inventory/models/__init__.py
Normal 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
|
||||
59
fusion_inventory/models/account_move.py
Normal file
59
fusion_inventory/models/account_move.py
Normal 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
|
||||
316
fusion_inventory/models/inter_company_transfer.py
Normal file
316
fusion_inventory/models/inter_company_transfer.py
Normal 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,
|
||||
})
|
||||
71
fusion_inventory/models/inventory_booking.py
Normal file
71
fusion_inventory/models/inventory_booking.py
Normal 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'))
|
||||
183
fusion_inventory/models/inventory_discrepancy.py
Normal file
183
fusion_inventory/models/inventory_discrepancy.py
Normal 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
|
||||
151
fusion_inventory/models/product_brand.py
Normal file
151
fusion_inventory/models/product_brand.py
Normal 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},
|
||||
}
|
||||
80
fusion_inventory/models/product_brand_pricing_rule.py
Normal file
80
fusion_inventory/models/product_brand_pricing_rule.py
Normal 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
|
||||
167
fusion_inventory/models/product_product.py
Normal file
167
fusion_inventory/models/product_product.py
Normal 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
|
||||
@@ -1,9 +1,11 @@
|
||||
# -*- 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):
|
||||
"""Maps local products to remote products for inventory sync."""
|
||||
_name = 'fusion.product.sync.mapping'
|
||||
_description = 'Product Sync Mapping'
|
||||
_rec_name = 'remote_product_name'
|
||||
@@ -11,26 +13,40 @@ class FusionProductSyncMapping(models.Model):
|
||||
|
||||
config_id = fields.Many2one('fusion.sync.config', string='Sync Config',
|
||||
required=True, ondelete='cascade')
|
||||
# Local product link
|
||||
local_product_id = fields.Many2one('product.template', string='Local Product',
|
||||
help='The matching product in this Odoo instance')
|
||||
auto_matched = fields.Boolean(string='Auto-Matched',
|
||||
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_name = fields.Char(string='Remote Product Name')
|
||||
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_category = fields.Char(string='Remote Category')
|
||||
|
||||
# Remote stock levels (updated by sync)
|
||||
remote_qty_available = fields.Float(string='Remote On Hand', readonly=True,
|
||||
help='Quantity currently on hand at the remote location')
|
||||
remote_qty_forecast = fields.Float(string='Remote Forecast', readonly=True,
|
||||
help='Forecasted quantity (on hand - outgoing + incoming)')
|
||||
remote_qty_available = fields.Float(
|
||||
string='Remote On Hand',
|
||||
compute='_compute_remote_totals', store=True, readonly=True)
|
||||
remote_qty_forecast = fields.Float(
|
||||
string='Remote Forecast',
|
||||
compute='_compute_remote_totals', store=True, 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 = [
|
||||
('unique_remote_product',
|
||||
'UNIQUE(config_id, remote_product_id)',
|
||||
356
fusion_inventory/models/product_template.py
Normal file
356
fusion_inventory/models/product_template.py
Normal 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))
|
||||
68
fusion_inventory/models/product_template_attribute_value.py
Normal file
68
fusion_inventory/models/product_template_attribute_value.py
Normal 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)
|
||||
112
fusion_inventory/models/res_config_settings.py
Normal file
112
fusion_inventory/models/res_config_settings.py
Normal 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,
|
||||
},
|
||||
}
|
||||
60
fusion_inventory/models/res_partner.py
Normal file
60
fusion_inventory/models/res_partner.py
Normal 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',
|
||||
}
|
||||
44
fusion_inventory/models/stock_move.py
Normal file
44
fusion_inventory/models/stock_move.py
Normal 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)
|
||||
222
fusion_inventory/models/stock_picking.py
Normal file
222
fusion_inventory/models/stock_picking.py
Normal 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',
|
||||
}
|
||||
630
fusion_inventory/models/sync_config.py
Normal file
630
fusion_inventory/models/sync_config.py
Normal 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)
|
||||
@@ -1,9 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class FusionSyncLog(models.Model):
|
||||
"""Log sync operations for auditing and debugging."""
|
||||
_name = 'fusion.sync.log'
|
||||
_description = 'Inventory Sync Log'
|
||||
_order = 'create_date desc'
|
||||
30
fusion_inventory/models/sync_stock.py
Normal file
30
fusion_inventory/models/sync_stock.py
Normal 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.'),
|
||||
]
|
||||
34
fusion_inventory/models/sync_warehouse.py
Normal file
34
fusion_inventory/models/sync_warehouse.py
Normal 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.'),
|
||||
]
|
||||
78
fusion_inventory/models/warehouse_ownership.py
Normal file
78
fusion_inventory/models/warehouse_ownership.py
Normal 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'
|
||||
33
fusion_inventory/security/ir.model.access.csv
Normal file
33
fusion_inventory/security/ir.model.access.csv
Normal 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
|
||||
|
50
fusion_inventory/security/security.xml
Normal file
50
fusion_inventory/security/security.xml
Normal 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>
|
||||
BIN
fusion_inventory/static/description/icon.png
Normal file
BIN
fusion_inventory/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
17
fusion_inventory/static/src/js/margin_widget.js
Normal file
17
fusion_inventory/static/src/js/margin_widget.js
Normal 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);
|
||||
10
fusion_inventory/static/src/xml/margin_widget.xml
Normal file
10
fusion_inventory/static/src/xml/margin_widget.xml
Normal 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>
|
||||
72
fusion_inventory/views/menus.xml
Normal file
72
fusion_inventory/views/menus.xml
Normal 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>
|
||||
847
fusion_inventory/views/portal_inventory_templates.xml
Normal file
847
fusion_inventory/views/portal_inventory_templates.xml
Normal 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'">🔗 </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">●</span> In Stock</span>
|
||||
<span><span class="badge bg-warning text-dark">●</span> Booked</span>
|
||||
<span><span class="badge bg-info">●</span> Incoming (PO)</span>
|
||||
<span><span class="badge bg-danger">●</span> Out of Stock</span>
|
||||
<span t-if="has_sync"><span class="badge bg-purple" style="background:#6f42c1!important;">●</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'] <= 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) <= 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'] <= 0 and p.get('remote_qty', 0) <= 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'] <= 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 && 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 <= 1) { nav.innerHTML = ''; return; }
|
||||
|
||||
var html = '<ul class="pagination pagination-sm justify-content-center">';
|
||||
html += '<li class="page-item' + (page <= 1 ? ' disabled' : '') + '">'
|
||||
+ '<a class="page-link" href="#" data-page="' + (page - 1) + '">&lt;</a></li>';
|
||||
|
||||
for (var pg = 1; pg <= totalPages; pg++) {
|
||||
if (pg <= 3 || Math.abs(pg - page) <= 1 || pg === totalPages) {
|
||||
html += '<li class="page-item' + (pg === page ? ' active' : '') + '">'
|
||||
+ '<a class="page-link" href="#" data-page="' + pg + '">' + pg + '</a></li>';
|
||||
} else if (pg === 4 && page > 5) {
|
||||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||||
} else if (pg === totalPages - 1 && page < totalPages - 3) {
|
||||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||||
}
|
||||
}
|
||||
|
||||
html += '<li class="page-item' + (page >= totalPages ? ' disabled' : '') + '">'
|
||||
+ '<a class="page-link" href="#" data-page="' + (page + 1) + '">&gt;</a></li>';
|
||||
html += '</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 && pg <= 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 <= 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 += '<button class="btn btn-sm btn-outline-primary fi-book-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '">Book</button>';
|
||||
}
|
||||
if (hasSync && remoteQty > 0 && p.id > 0) {
|
||||
actionHtml += '<button class="btn btn-sm btn-outline-secondary fi-transfer-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '" data-remote-qty="' + remoteQty + '"><i class="fa fa-exchange"></i></button>';
|
||||
}
|
||||
if (!actionHtml) actionHtml = '<span class="text-muted small">--</span>';
|
||||
actionHtml = '<div class="btn-group btn-group-sm">' + actionHtml + '</div>';
|
||||
} else {
|
||||
actionHtml = '<span class="text-muted small">' + escHtml(p.config_name || '') + '</span>';
|
||||
}
|
||||
|
||||
var remoteCol = '';
|
||||
var totalCol = '';
|
||||
if (hasSync) {
|
||||
var whTooltip = (p.remote_warehouses || []).map(function(w) { return w.warehouse + ': ' + w.qty; }).join(', ');
|
||||
remoteCol = remoteQty > 0
|
||||
? '<span class="badge text-white fi-remote-badge" style="background:#6f42c1; cursor:pointer;" title="' + escHtml(whTooltip) + '">' + remoteQty + '</span>'
|
||||
: '<span class="text-muted">0</span>';
|
||||
totalCol = '<strong>' + totalQty + '</strong>';
|
||||
}
|
||||
|
||||
var trClass = isRemoteOnly ? ' class="table-light fst-italic"' : '';
|
||||
var nameCell = isRemoteOnly
|
||||
? '<span class="badge bg-secondary me-1">Remote</span>' + escHtml(p.name)
|
||||
: '<strong>' + escHtml(p.name) + '</strong>';
|
||||
|
||||
html += '<tr' + trClass + ' data-id="' + p.id + '">'
|
||||
+ '<td class="fi-col-product">' + nameCell + '</td>'
|
||||
+ '<td class="fi-col-sku text-muted">' + escHtml(p.default_code) + '</td>'
|
||||
+ '<td class="fi-col-cat">' + escHtml(p.category) + '</td>'
|
||||
+ '<td class="fi-col-num">' + p.qty_on_hand + '</td>'
|
||||
+ '<td class="fi-col-num"><span class="fw-bold ' + availClass + '">' + p.available_qty + '</span></td>'
|
||||
+ (hasSync ? '<td class="fi-col-num">' + remoteCol + '</td>' : '')
|
||||
+ (hasSync ? '<td class="fi-col-num">' + totalCol + '</td>' : '')
|
||||
+ '<td class="fi-col-num">' + (p.booked_qty > 0 ? '<span class="badge bg-warning text-dark">' + p.booked_qty + '</span>' : '') + '</td>'
|
||||
+ '<td class="fi-col-num">' + (p.shadow_qty > 0 ? '<span class="badge bg-info">' + p.shadow_qty + '</span>' : '') + '</td>'
|
||||
+ '<td class="fi-col-price">$' + p.sale_price.toFixed(2) + '</td>'
|
||||
+ '<td class="fi-col-num"><span class="badge ' + marginClass + '">' + p.margin_pct.toFixed(1) + '%</span></td>'
|
||||
+ '<td class="fi-col-action">' + actionHtml + '</td>'
|
||||
+ '</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 <= 0 ? 'text-danger' : '';
|
||||
|
||||
var remoteMobile = '';
|
||||
if (hasSync) {
|
||||
remoteMobile = '<div class="col">'
|
||||
+ '<div class="fw-bold" style="color:#6f42c1;">' + remoteQty + '</div>'
|
||||
+ '<div class="text-muted" style="font-size:.7rem;">Remote</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
var btns = '';
|
||||
if (p.available_qty > 0) {
|
||||
btns += '<button class="btn btn-sm btn-outline-primary fi-book-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '">Book</button>';
|
||||
}
|
||||
if (hasSync && remoteQty > 0 && p.id > 0) {
|
||||
btns += '<button class="btn btn-sm btn-outline-secondary fi-transfer-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '" data-remote-qty="' + remoteQty + '"><i class="fa fa-exchange"></i> Transfer</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 += '<div class="card mb-2 fi-card" data-id="' + p.id + '" style="border-left: 4px solid ' + borderColor + ';">'
|
||||
+ '<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">' + escHtml(p.name) + '</div>'
|
||||
+ '<small class="text-muted">' + escHtml(p.default_code) + (p.category ? ' | ' + escHtml(p.category) : '') + '</small>'
|
||||
+ '</div>'
|
||||
+ '<div class="text-end">'
|
||||
+ '<div class="fw-bold">$' + p.sale_price.toFixed(2) + '</div>'
|
||||
+ '<span class="badge ' + marginClass + ' small">' + p.margin_pct.toFixed(1) + '%</span>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="' + rowClass + '" style="font-size:.85rem;">'
|
||||
+ '<div class="' + colClass + '"><div class="fw-bold">' + p.qty_on_hand + '</div><div class="text-muted" style="font-size:.7rem;">Local</div></div>'
|
||||
+ '<div class="' + colClass + '"><div class="fw-bold ' + availClass + '">' + p.available_qty + '</div><div class="text-muted" style="font-size:.7rem;">Avail</div></div>'
|
||||
+ remoteMobile
|
||||
+ '<div class="' + colClass + '"><div class="fw-bold">' + p.booked_qty + '</div><div class="text-muted" style="font-size:.7rem;">Booked</div></div>'
|
||||
+ '<div class="' + colClass + '"><div class="fw-bold">' + p.shadow_qty + '</div><div class="text-muted" style="font-size:.7rem;">Incoming</div></div>'
|
||||
+ '</div>'
|
||||
+ (btns ? '<div class="mt-2 text-end"><div class="btn-group btn-group-sm">' + btns + '</div></div>' : '')
|
||||
+ '</div></div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
bindBookButtons();
|
||||
bindTransferButtons();
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&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 = '<div class="px-3 py-2 text-muted small">No categories found</div>';
|
||||
}
|
||||
cats.forEach(function(c) {
|
||||
html += '<div class="px-3 py-2 fi-cat-option" style="cursor:pointer;" data-id="' + c.id + '" data-name="' + escHtml(c.name) + '">'
|
||||
+ escHtml(c.name) + '</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) && !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>
|
||||
38
fusion_inventory/views/product_attribute_views.xml
Normal file
38
fusion_inventory/views/product_attribute_views.xml
Normal 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>
|
||||
175
fusion_inventory/views/product_brand_views.xml
Normal file
175
fusion_inventory/views/product_brand_views.xml
Normal 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', '>', 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>
|
||||
174
fusion_inventory/views/product_template_views.xml
Normal file
174
fusion_inventory/views/product_template_views.xml
Normal 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 & Classification ── -->
|
||||
<separator string="Brand & 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 <= 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>
|
||||
81
fusion_inventory/views/product_views.xml
Normal file
81
fusion_inventory/views/product_views.xml
Normal 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>
|
||||
117
fusion_inventory/views/res_config_settings_views.xml
Normal file
117
fusion_inventory/views/res_config_settings_views.xml
Normal 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>
|
||||
24
fusion_inventory/views/res_partner_views.xml
Normal file
24
fusion_inventory/views/res_partner_views.xml
Normal 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 > 0"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
137
fusion_inventory/views/stock_picking_views.xml
Normal file
137
fusion_inventory/views/stock_picking_views.xml
Normal 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>
|
||||
404
fusion_inventory/views/sync_config_views.xml
Normal file
404
fusion_inventory/views/sync_config_views.xml
Normal 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', '>', 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>
|
||||
5
fusion_inventory/wizard/__init__.py
Normal file
5
fusion_inventory/wizard/__init__.py
Normal 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
|
||||
135
fusion_inventory/wizard/serial_scan_wizard.py
Normal file
135
fusion_inventory/wizard/serial_scan_wizard.py
Normal 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)
|
||||
34
fusion_inventory/wizard/serial_scan_wizard_views.xml
Normal file
34
fusion_inventory/wizard/serial_scan_wizard_views.xml
Normal 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>
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.'
|
||||
)
|
||||
@@ -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}')
|
||||
@@ -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
|
||||
|
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB |
@@ -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>
|
||||
@@ -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', '>', 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>
|
||||
Reference in New Issue
Block a user