thatcher 1.0.91 → 1.0.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -33,6 +33,30 @@ function weightedValueOf(opportunity) {
|
|
|
33
33
|
return (value * probability) / 100;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// The historical mirror of projectDemandByMonth: past months' ACTUAL won
|
|
37
|
+
// value, not future open-pipeline projection. This is the time series a
|
|
38
|
+
// trend prediction (statistical-forecast.js's linearRegression) needs --
|
|
39
|
+
// projectDemandByMonth's forward-looking open-pipeline sum is the wrong
|
|
40
|
+
// input for "predict next period from history" since it contains no past
|
|
41
|
+
// data points to fit a trend against.
|
|
42
|
+
export function historicalWonValueByMonth(opportunities, nowSeconds = Math.floor(Date.now() / 1000), monthsBack = 6) {
|
|
43
|
+
const currentBucketKey = monthBucketKey(nowSeconds);
|
|
44
|
+
const buckets = new Map();
|
|
45
|
+
|
|
46
|
+
for (const opp of opportunities) {
|
|
47
|
+
if (opp.stage !== 'won') continue;
|
|
48
|
+
const closeDate = opp.actual_close_date ?? opp.expected_close_date;
|
|
49
|
+
if (closeDate == null) continue;
|
|
50
|
+
const bucketKey = monthBucketKey(Number(closeDate));
|
|
51
|
+
if (bucketKey > currentBucketKey) continue;
|
|
52
|
+
const value = Number(opp.value) || 0;
|
|
53
|
+
buckets.set(bucketKey, (buckets.get(bucketKey) || 0) + value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const sorted = [...buckets.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
57
|
+
return sorted.length > monthsBack ? sorted.slice(-monthsBack) : sorted;
|
|
58
|
+
}
|
|
59
|
+
|
|
36
60
|
export function projectDemandByMonth(opportunities, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
37
61
|
const currentBucketKey = monthBucketKey(nowSeconds);
|
|
38
62
|
const buckets = new Map();
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { daysUntilStockout, isReorderDue } from './inventory-forecast.js';
|
|
8
8
|
import { daysUntilExpiry } from './contract-expiry.js';
|
|
9
9
|
import { userCommittedHours, DEFAULT_WEEKLY_CAPACITY_HOURS } from './resource-capacity.js';
|
|
10
|
+
import { zScoreAnomaly } from './statistical-forecast.js';
|
|
10
11
|
|
|
11
12
|
const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
|
|
12
13
|
|
|
@@ -87,6 +88,36 @@ export function pipelineDropInsights(monthlyBuckets) {
|
|
|
87
88
|
return results;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// Statistical, not rule-based: flags a product whose most recent day's
|
|
92
|
+
// consumption is a z-score outlier relative to ITS OWN historical daily
|
|
93
|
+
// consumption -- the same absolute consumption number can be perfectly
|
|
94
|
+
// normal for a high-variance product and anomalous for a low-variance one,
|
|
95
|
+
// which stockoutRiskInsights' fixed day-threshold cannot express since it
|
|
96
|
+
// only reasons about days-until-stockout, never about whether today's rate
|
|
97
|
+
// itself is unusual for this specific product.
|
|
98
|
+
export function statisticalAnomalyInsights(products, movementsByProductId) {
|
|
99
|
+
const results = [];
|
|
100
|
+
for (const product of products) {
|
|
101
|
+
const movements = movementsByProductId[product.id] || [];
|
|
102
|
+
const byDay = new Map();
|
|
103
|
+
for (const m of movements) {
|
|
104
|
+
if (Number(m.quantity) >= 0) continue;
|
|
105
|
+
const day = new Date((Number(m.created_at) || 0) * 1000).toISOString().slice(0, 10);
|
|
106
|
+
byDay.set(day, (byDay.get(day) || 0) + Math.abs(Number(m.quantity) || 0));
|
|
107
|
+
}
|
|
108
|
+
const days = [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
109
|
+
if (days.length < 3) continue;
|
|
110
|
+
const latestValue = days[days.length - 1][1];
|
|
111
|
+
const history = days.slice(0, -1).map(([, v]) => v);
|
|
112
|
+
const { isAnomaly, zScore } = zScoreAnomaly(history, latestValue, 2);
|
|
113
|
+
if (isAnomaly) {
|
|
114
|
+
const direction = latestValue > (history.reduce((a, b) => a + b, 0) / history.length) ? 'above' : 'below';
|
|
115
|
+
results.push(insight('warning', 'anomaly', `${product.name || product.id}'s latest daily consumption is statistically ${direction} its historical pattern (z-score ${zScore.toFixed(2)})`, 'product', product.id));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return results;
|
|
119
|
+
}
|
|
120
|
+
|
|
90
121
|
export function sortBySeverity(insights) {
|
|
91
122
|
return [...insights].sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 3) - (SEVERITY_ORDER[b.severity] ?? 3));
|
|
92
123
|
}
|
|
@@ -109,6 +140,7 @@ export async function generateInsights(user, entityType = null) {
|
|
|
109
140
|
movementsByProductId[product.id] = await list('stock_movement', { product_id: product.id });
|
|
110
141
|
}
|
|
111
142
|
results.push(...stockoutRiskInsights(products, movementsByProductId));
|
|
143
|
+
results.push(...statisticalAnomalyInsights(products, movementsByProductId));
|
|
112
144
|
}
|
|
113
145
|
|
|
114
146
|
if (!entityType || entityType === 'contract') {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Data-derived predictive math, distinct from insights-engine.js's rule-based
|
|
2
|
+
// fixed-threshold checks (days<=14, pct>=90, dropPct>30) -- both functions
|
|
3
|
+
// here derive their parameters FROM the input series itself (slope/intercept,
|
|
4
|
+
// mean/stddev) rather than from a hardcoded constant, the standard
|
|
5
|
+
// lightweight statistical technique available without an ML training
|
|
6
|
+
// pipeline. Same small-pure-module shape as contract-expiry.js/
|
|
7
|
+
// inventory-forecast.js/resource-capacity.js.
|
|
8
|
+
|
|
9
|
+
// Least-squares fit over [x, y] points. x is typically a period index
|
|
10
|
+
// (0, 1, 2...), y the metric value at that period.
|
|
11
|
+
export function linearRegression(points) {
|
|
12
|
+
const n = points.length;
|
|
13
|
+
if (n < 2) return null;
|
|
14
|
+
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
|
|
15
|
+
for (const [x, y] of points) {
|
|
16
|
+
sumX += x; sumY += y; sumXY += x * y; sumXX += x * x;
|
|
17
|
+
}
|
|
18
|
+
const denominator = n * sumXX - sumX * sumX;
|
|
19
|
+
if (denominator === 0) return null;
|
|
20
|
+
const slope = (n * sumXY - sumX * sumY) / denominator;
|
|
21
|
+
const intercept = (sumY - slope * sumX) / n;
|
|
22
|
+
return { slope, intercept };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function predictNext(points) {
|
|
26
|
+
const fit = linearRegression(points);
|
|
27
|
+
if (!fit) return null;
|
|
28
|
+
const nextX = Math.max(...points.map(([x]) => x)) + 1;
|
|
29
|
+
return fit.slope * nextX + fit.intercept;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mean(values) {
|
|
33
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stddev(values, avg) {
|
|
37
|
+
const variance = values.reduce((sum, v) => sum + (v - avg) ** 2, 0) / values.length;
|
|
38
|
+
return Math.sqrt(variance);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Flags latestValue as anomalous relative to series' OWN historical mean/
|
|
42
|
+
// stddev -- a value that would be normal for a high-variance series and
|
|
43
|
+
// anomalous for a low-variance one, which a fixed threshold cannot express.
|
|
44
|
+
// stddev===0 (a perfectly flat history) means ANY deviation is infinitely
|
|
45
|
+
// many stddevs away, so any latestValue != mean is reported as anomalous
|
|
46
|
+
// with zScore=Infinity rather than dividing by zero into NaN.
|
|
47
|
+
export function zScoreAnomaly(series, latestValue, threshold = 2) {
|
|
48
|
+
if (series.length < 2) return { isAnomaly: false, zScore: null, mean: null, stddev: null };
|
|
49
|
+
const avg = mean(series);
|
|
50
|
+
const sd = stddev(series, avg);
|
|
51
|
+
if (sd === 0) return { isAnomaly: latestValue !== avg, zScore: latestValue === avg ? 0 : Infinity, mean: avg, stddev: 0 };
|
|
52
|
+
const zScore = (latestValue - avg) / sd;
|
|
53
|
+
return { isAnomaly: Math.abs(zScore) >= threshold, zScore, mean: avg, stddev: sd };
|
|
54
|
+
}
|
package/src/ui/page-handler.js
CHANGED
|
@@ -421,9 +421,18 @@ export async function handlePage(pathname, req, res) {
|
|
|
421
421
|
// entity route uses) -- no new unscoped query, and each item already
|
|
422
422
|
// carries a computed weighted_value from list()'s own formula-field
|
|
423
423
|
// pass, reused directly rather than recomputed.
|
|
424
|
-
const { projectDemandByMonth } = await import('@/lib/demand-forecast.js');
|
|
424
|
+
const { projectDemandByMonth, historicalWonValueByMonth } = await import('@/lib/demand-forecast.js');
|
|
425
|
+
const { predictNext } = await import('@/lib/statistical-forecast.js');
|
|
425
426
|
const buckets = projectDemandByMonth(items);
|
|
426
|
-
|
|
427
|
+
// Trend prediction over PAST won-value, distinct from `buckets`'
|
|
428
|
+
// forward-looking open-pipeline sum -- reuses the same {user}-scoped
|
|
429
|
+
// `items` list, no new query. Fewer than 2 historical months means no
|
|
430
|
+
// regression can be fit (linearRegression's own null-return case).
|
|
431
|
+
const historical = historicalWonValueByMonth(items);
|
|
432
|
+
const predictedNextValue = historical.length >= 2
|
|
433
|
+
? predictNext(historical.map(([, v], i) => [i, v]))
|
|
434
|
+
: null;
|
|
435
|
+
return renderDemandForecastReport(user, spec, buckets, items.length, predictedNextValue);
|
|
427
436
|
}
|
|
428
437
|
const view = params.get('view');
|
|
429
438
|
if (view === 'board') return renderBoardView(user, entityName, spec, items);
|
|
@@ -385,17 +385,26 @@ export function renderCohortOverTimeReport(user, entityName, spec, records, date
|
|
|
385
385
|
return page(user, `${label} Report | Thatcher`, null, content);
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
|
|
388
|
+
// predictedNextValue (optional) is a statistical trend prediction --
|
|
389
|
+
// distinct from `buckets`' own totalProjected sum, which is a sum of
|
|
390
|
+
// already-weighted open pipeline, not a prediction fitted from historical
|
|
391
|
+
// won-value data. null/undefined renders no trend line, same page shape as
|
|
392
|
+
// before this parameter existed.
|
|
393
|
+
export function renderDemandForecastReport(user, spec, buckets, totalOpportunities, predictedNextValue = null) {
|
|
389
394
|
const label = getEntityLabel(spec, true) || 'Opportunities';
|
|
390
395
|
const valueFieldDef = spec.fields?.value || { type: 'currency' };
|
|
391
396
|
const totalProjected = buckets.reduce((sum, [, v]) => sum + v, 0);
|
|
392
397
|
const notice = !buckets.length
|
|
393
398
|
? `<div class="report-notice">No open opportunities with a future expected close date</div>`
|
|
394
399
|
: '';
|
|
400
|
+
const trendNotice = predictedNextValue != null
|
|
401
|
+
? `<div class="report-notice">Statistical trend prediction (linear regression over historical won value): next month ~${esc(formatSumValue(predictedNextValue, valueFieldDef))}</div>`
|
|
402
|
+
: '';
|
|
395
403
|
const content = `<div class="page-header">
|
|
396
404
|
<div><h1 class="page-title">${esc(label)}: Demand Forecast</h1><p class="page-subtitle">${totalOpportunities} total opportunities, ${esc(formatSumValue(totalProjected, valueFieldDef))} projected across ${buckets.length} future month${buckets.length === 1 ? '' : 's'}</p></div>
|
|
397
405
|
</div>
|
|
398
406
|
${notice}
|
|
407
|
+
${trendNotice}
|
|
399
408
|
${sumBarChart(buckets, valueFieldDef)}`;
|
|
400
409
|
return page(user, `Demand Forecast | Thatcher`, null, content);
|
|
401
410
|
}
|