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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.91",
3
+ "version": "1.0.92",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
+ }
@@ -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
- return renderDemandForecastReport(user, spec, buckets, items.length);
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
- export function renderDemandForecastReport(user, spec, buckets, totalOpportunities) {
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
  }