[ADD] Inventory: add stock valuation cheat sheet

An adaptation of the venerable business memento from 8.0 to the new
inventory valuation mechanics of Odoo 19.0 by scavenging and cobbling
together of the scripts `entries.js` and `coa-valuation.js`. The shared
data is kept in a separate file.

Additionally, we remove the old inventory valuation documentation.

Task ID: 5107300

closes odoo/documentation#14906

Signed-off-by: Felicia Kuan (feku) <feku@odoo.com>
This commit is contained in:
Lulu Grimalkin (lugr)
2025-10-06 11:20:16 +02:00
committed by “Dallas”
parent c97a8e6bad
commit 912dde6d26
63 changed files with 2387 additions and 773 deletions

View File

@@ -1,6 +1,6 @@
/* global Immutable, React */
(function () {
// NOTE: used by cheat_sheet.rst
// NOTE: used by accounting cheat_sheet.rst
'use strict';
function highlight(primary, secondary) {

View File

@@ -1,7 +1,7 @@
/* global Immutable, React */
/* global createAtom */
(function () {
// NOTE: used by cheat_sheet.rst
// NOTE: used by accounting cheat_sheet.rst
'use strict';
var data = createAtom();

View File

@@ -2,7 +2,7 @@
/* global createAtom, findAncestor */
(function () {
'use strict';
// NOTE: cheat_sheet.rst
// NOTE: used by accounting cheat_sheet.rst
var data = createAtom();
data.addWatch('chart', function (k, m, prev, next) {

View File

@@ -6,7 +6,7 @@
});
function highlight() {
// NOTE: used by double-entry.rst
// NOTE: used by valuation cheat_sheet.rst
$('.highlighter-list').each(function () {
var $this = $(this),
$target = $($this.data('target'));
@@ -34,7 +34,7 @@
* - automatically select first control on startup
*/
function alternatives() {
// NOTE: used by double-entry.rst & valuation_methods pages
// NOTE: used by valuation cheat_sheet.rst
$('dl.alternatives').each(function (index) {
var $list = $(this),
$contents = $list.children('dd');
@@ -51,7 +51,18 @@
label.appendChild(input);
label.appendChild(document.createTextNode(' '));
label.appendChild(document.createTextNode(this.textContent));
// Hack to bold the definition since we have to strip rST formatting
const [headText, tailText] = this.textContent.split(':', 2);
if (tailText) {
const bold = document.createElement('b'),
defined = document.createTextNode(`${headText}:`);
bold.appendChild(defined);
label.appendChild(bold);
}
label.appendChild(document.createTextNode(tailText || headText));
label.normalize();
return label;
}))
@@ -65,9 +76,10 @@
})
.find('input:first').click();
});
$('.alternatives-note').insertAfter($('.alternatives-controls'));
}
function checks_handling() {
// NOTE: used by cheat_sheet.rst
// NOTE: used by accounting cheat_sheet.rst
var $section = $('.checks-handling');
if (!$section.length) { return; }

View File

@@ -1,5 +1,5 @@
(function () {
// NOTE: cheat_sheet.rst
// NOTE: used by accounting cheat_sheet.rst
document.addEventListener('DOMContentLoaded', function () {
var $rec = $('#reconciliation .reconciliation-example');
if (!$rec.length) { return; }

View File

@@ -0,0 +1,262 @@
/* global Immutable, React */
/* global createAtom */
/* global VALUATION_{STANDARDS,METHODS,JOURNALS,ENTRIES,REVIEWS} */
(function () {
'use strict';
// NOTE: used by valuation cheat_sheet.rst
const selectedMode = createAtom(['continental', 'periodic']);
const selectedOps = createAtom();
function watch (next) {
React.render(
React.createElement(Controls, { p: next }),
document.getElementById('accounting-entries-controls'));
React.render(
React.createElement(Chart, { p: next }),
document.querySelector('.accounting-entries'));
}
selectedOps.addWatch('chart', (k, m, prev, next) => watch(next));
selectedMode.addWatch('chart', (k, m, prev, next) => watch(selectedOps.deref()));
document.addEventListener('DOMContentLoaded', function () {
const chart = document.querySelector('.accounting-entries');
if (!chart) { return; }
const controls = document.createElement('div');
controls.setAttribute('id', 'accounting-entries-controls');
chart.parentNode.insertBefore(controls, chart);
selectedOps.reset(Immutable.Map({
// last-selected operation
active: null,
// set of all currently enabled operations
operations: Immutable.OrderedSet()
}));
});
function toKey(s, postfix) {
if (postfix) {
s += ' ' + postfix;
}
return s.replace(/[^0-9a-z ]/gi, '').toLowerCase().split(/\s+/).join('-');
}
const Controls = React.createClass({
render: function () {
const state = this.props.p;
return React.DOM.div(
null,
React.DOM.b(null, "Choose a standard:"),
VALUATION_STANDARDS.map(function (item, index) {
return React.DOM.label(
{ key: index },
React.DOM.input({
type: 'radio',
checked: item.get('name') === selectedMode.deref()[0],
onChange: function (e) {
const newValue = item.get('name');
selectedMode.reset([newValue, newValue === 'continental' ? 'periodic' : 'perpetual']);
}
}),
' ',
item.get('text')
);
}),
React.DOM.br(),
React.DOM.b(null, "Choose an accounting method:"),
VALUATION_METHODS.map(function (item, index) {
return React.DOM.label(
{ key: index },
React.DOM.input({
type: 'radio',
checked: item.get('name') === selectedMode.deref()[1],
onChange: e => selectedMode.swap(vals => [vals[0], item.get('name')]),
}),
' ',
item.get('text')
);
}),
React.DOM.br(),
React.DOM.b(null, "Activate operations to see the impact:"),
VALUATION_ENTRIES.map(function (item, key) {
return React.DOM.label(
{
key: key,
style: { display: 'block' },
className: (key === state.get('active') ? 'highlight-op' : void 0)
},
React.DOM.input({
type: 'checkbox',
checked: state.get('operations').contains(key),
onChange: function (e) {
if (e.target.checked) {
selectedOps.swap(d => d.set('active', key)
.update('operations', ops => ops.add(key)));
} else {
selectedOps.swap(d => d.set('active', null)
.update('operations', ops => ops.remove(key)));
}
}
}),
' ',
item.get('title')
);
}),
React.DOM.br(),
"Closing",
VALUATION_REVIEWS.map(function (item, key) {
// We bold the text if any of the operations in this review is
// relevant to the currently selected operations.
const boldable = item.getIn([...selectedMode.deref(), 'operations'])
.some(function (op) {
if (!op.has('entries') && !op.has('except'))
return true;
const opset = state.get('operations').toSet();
if (opset.isSuperset(op.get('entries', []))
&& opset.intersect(op.get('except', [])).isEmpty())
return true;
});
return React.DOM.label(
{
key: key,
style: { display: 'block' },
className: (key === state.get('active') ? 'highlight-op' : void 0)
},
React.DOM.input({
type: 'checkbox',
checked: state.get('operations').contains(key),
onChange: function (e) {
if (e.target.checked) {
selectedOps.swap(d => d.set('active', key)
.update('operations', ops => ops.add(key)));
} else {
selectedOps.swap(d => d.set('active', null)
.update('operations', ops => ops.remove(key)));
}
}
}),
' ',
boldable ? React.DOM.b(null, item.get('title')) : item.get('title'),
);
}),
React.DOM.br(),
);
}
});
const Chart = React.createClass({
render: function () {
// Only used for highlighting cells.
const lastop = Immutable.Map(
this.props.p.get('active')
? (VALUATION_ENTRIES.concat(VALUATION_REVIEWS)
.getIn([this.props.p.get('active'), ...selectedMode.deref(), 'operations'], Immutable.List()))
.map(op => [VALUATION_JOURNALS.getIn([selectedMode.deref()[0], ...op.get('account'), 'code']),
op.has('credit') ? 'credit' : 'debit'])
: Immutable.Map());
return React.DOM.div(
null,
React.DOM.table(
{ className: 'table table-condensed' },
React.DOM.thead(
null,
React.DOM.tr(
null,
React.DOM.th(),
React.DOM.th({ className: 'text-right' }, "Debit"),
React.DOM.th({ className: 'text-right' }, "Credit"),
React.DOM.th({ className: 'text-right' }, "Balance"))
),
React.DOM.tbody(
null,
this.accounts().map(function (data) {
// Don't highlight the cell if it's going to be empty.
const highlight = lastop.get(data.get('code')),
debit = format(data.get('debit')),
credit = format(data.get('credit'));
return React.DOM.tr(
{
key: data.get('code'),
className: data.get('level') ? 'parent-line' : 'child-line',
},
React.DOM.th(
null,
data.get('level') ? '\u2001 ' : '',
data.get('code') || '', ' ', data.get('title')
),
React.DOM.td(
{ className: React.addons.classSet({
'text-right': true,
'highlight-op': debit ? highlight === 'debit' : void 0 }) },
debit),
React.DOM.td(
{ className: React.addons.classSet({
'text-right': true,
'highlight-op': credit ? highlight === 'credit' : void 0 }) },
credit),
React.DOM.td(
{ className: 'text-right' },
((data.get('debit') || data.get('credit'))
? format(data.get('debit') - data.get('credit'), 0)
: ''),
)
);
})
)
)
);
},
accounts: function() {
const currentOperations = this.props.p.get('operations');
if (!currentOperations)
return null;
const totals = VALUATION_ENTRIES.concat(VALUATION_REVIEWS)
.filter((val, key) => currentOperations.includes(key))
.valueSeq()
.flatMap(entry => entry.getIn([...selectedMode.deref(), 'operations']))
.reduce(function (acc, op) {
// `entries' and `except' fields are explained in valuation-data.js (quod vide)
if (op.has('entries') || op.has('except')) {
const opset = currentOperations.toSet();
if (!(opset.isSuperset(op.get('entries', []))
&& opset.intersect(op.get('except', [])).isEmpty())) {
return acc;
}
}
const code = VALUATION_JOURNALS.getIn([selectedMode.deref()[0], ...op.get('account'), 'code']);
return acc
.updateIn([code, 'debit'],
d => (d || 0) + op.get('debit', 0))
.updateIn([code, 'credit'],
c => (c || 0) + op.get('credit', 0));
}, Immutable.Map());
return accounts.get(selectedMode.deref()[0]).map(account =>
account.merge(account.get('accounts')
.map(code => totals.get(code, NULL))
.reduce((acc, it) => acc.mergeWith((a, b) => a + b, it, NULL))));
}
});
const NULL = Immutable.Map({ debit: 0, credit: 0 });
const accounts = VALUATION_JOURNALS.map(method => method.toList().flatMap(function (cat) {
return Immutable.Seq.of(cat.set('level', 0)).concat(cat.filter(function (v, k) {
return k.toUpperCase() === k;
}).toIndexedSeq().map(function (acc) { return acc.set('level', 1) }));
}).map(function (account) { // add accounts: Seq<AccountCode> to each account
return account.set(
'accounts',
Immutable.Seq.of(account.get('code')).concat(
account.toIndexedSeq().map(function (val) {
return Immutable.Map.isMap(val) && val.get('code');
}).filter(function (val) { return !!val; })
)
);
}));
function format(val, def) {
if (!val) { return def === undefined ? '' : def; }
if (val % 1 === 0) { return val; }
return val.toFixed(2);
}
})();

1343
static/js/valuation-data.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
/* global Immutable, React */
/* global createAtom, findAncestor */
/* global VALUATION_{STANDARDS,METHODS,JOURNALS,ENTRIES,REVIEWS} */
(function () {
'use strict';
// NOTE: used by valuation cheat_sheet.rst
const selectedMode = createAtom()
const selectedOp = createAtom();
const entries = VALUATION_ENTRIES.concat(VALUATION_REVIEWS);
function watch (next) {
React.render(
React.createElement(Controls, { entryKey: next }),
document.getElementById('journaling-entries-controls'));
React.render(
React.createElement(FormatEntry, { entryKey: next }),
document.querySelector('.journal-entries'));
}
selectedOp.addWatch('chart', (k, m, prev, next) => watch([next, ...selectedMode.deref()]));
selectedMode.addWatch('chart', (k, m, prev, next) => watch([selectedOp.deref(), ...next]));
document.addEventListener('DOMContentLoaded', function () {
const entriesSection = findAncestor(document.querySelector('.journal-entries'), 'section');
if (!entriesSection) { return; }
const controls = document.createElement('div');
controls.setAttribute('id', 'journaling-entries-controls');
entriesSection.insertBefore(controls, entriesSection.lastElementChild);
selectedMode.reset(['continental', 'periodic']);
selectedOp.reset('initial_inventory');
});
const Controls = React.createClass({
render: function () {
const key = this.props.entryKey;
return React.DOM.div(
null,
React.DOM.b(null, "Choose a standard:"),
VALUATION_STANDARDS.map(function (item, index) {
return React.DOM.label(
{ key: index },
React.DOM.input({
type: 'radio',
checked: item.get('name') === key[1],
onChange: function (e) {
const newValue = item.get('name');
selectedMode.reset([newValue, newValue === 'continental' ? 'periodic' : 'perpetual']);
}
}),
' ',
item.get('text')
);
}),
React.DOM.br(),
React.DOM.b(null, "Choose an accounting method:"),
VALUATION_METHODS.map(function (item, index) {
return React.DOM.label(
{ key: index },
React.DOM.input({
type: 'radio',
checked: item.get('name') === key[2],
onChange: e => selectedMode.swap(vals => [vals[0], item.get('name')]),
}),
' ',
item.get('text')
);
}),
React.DOM.br(),
React.DOM.b(null, "Activate operations to see the impact:"),
VALUATION_ENTRIES.map(function (item, index) {
return React.DOM.label(
{ key: index },
React.DOM.input({
type: 'radio',
checked: index === key[0],
onChange: e => selectedOp.reset(index),
}),
' ',
item.get('title')
);
}),
React.DOM.br(),
"Closing",
VALUATION_REVIEWS.map(function (item, index) {
return React.DOM.label(
{ key: index },
React.DOM.input({
type: 'radio',
checked: index === key[0],
onChange: e => selectedOp.reset(index),
}),
' ',
item.get('title')
);
}),
React.DOM.br(),
);
}
});
const FormatEntry = React.createClass({
render: function () {
const entry = entries.getIn(this.props.entryKey);
return React.DOM.div(
null,
React.DOM.table(
{ className: 'table table-sm d-c-table' },
React.DOM.thead(
null,
React.DOM.tr(
null,
React.DOM.th(),
React.DOM.th(null, "Debit"),
React.DOM.th(null, "Credit"),
)
),
React.DOM.tbody(
null,
// Use `journal_operations' if it's a review. See `valuation-data.js'.
entry && entry.get('journal_operations', entry.get('operations', [])).map(this.renderRow)
)
),
React.createElement(Listing, {
heading: "Explanation",
items: entry && entry.get('explanation'),
}),
React.createElement(Listing, {
heading: "Configuration",
items: entry && entry.get('configuration'),
})
);
},
renderRow: function (entry, index) {
const standard = this.props.entryKey[1];
if (!entry) {
return React.DOM.tr(
{ key: 'spacer-' + index },
React.DOM.td({ colSpan: 3 }, "\u00A0")
);
}
const journalEntry = VALUATION_JOURNALS.getIn([standard, ...entry.get('account')]);
const title = journalEntry.get('title');
// Don't display 0 for 'General Balance for Inventory Initial Value'
const code = journalEntry.get('code') || '';
return React.DOM.tr(
{ key: index },
React.DOM.td(null, `${code} ${title}`),
React.DOM.td(null, entry.get('debit')),
React.DOM.td(null, entry.get('credit'))
);
}
});
const Listing = React.createClass({
render: function () {
if (!this.props.items || this.props.items.isEmpty()) {
return React.DOM.div();
}
const items = this.props.items;
const idx = items.indexOf(null);
if (idx !== -1) {
// console.log(items.slice(idx + 1).deref());
items = items.take(idx);
}
return React.DOM.div(
{ className: 'entries-listing' },
React.DOM.h4(null, this.props.heading, ':'),
items.map(function (item, index) {
return React.DOM.p({ key: index }, item);
})
);
}
});
}());