thatcher 1.0.90 → 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
|
@@ -9,7 +9,7 @@ import { renderEngagementGrid } from '@/ui/engagement-grid-renderer.js';
|
|
|
9
9
|
import { renderBoardView } from '@/ui/board-view-renderer.js';
|
|
10
10
|
import { renderGridView } from '@/ui/grid-view-renderer.js';
|
|
11
11
|
import { renderCalendarView, renderTimelineView } from '@/ui/calendar-view-renderer.js';
|
|
12
|
-
import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport, renderDemandForecastReport, renderPivotReport } from '@/ui/report-renderer.js';
|
|
12
|
+
import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport, renderDemandForecastReport, renderPivotReport, renderCohortOverTimeReport } from '@/ui/report-renderer.js';
|
|
13
13
|
import { renderClientProgress } from '@/ui/client-progress-renderer.js';
|
|
14
14
|
import { renderLetterWorkflow } from '@/ui/letter-workflow-renderer.js';
|
|
15
15
|
import { renderAdvancedSearch } from '@/ui/advanced-search-renderer.js';
|
|
@@ -377,6 +377,12 @@ export async function handlePage(pathname, req, res) {
|
|
|
377
377
|
const agg = params.get('agg') || 'count';
|
|
378
378
|
return renderPivotReport(user, entityName, spec, items, rowField, colField, valueField, agg);
|
|
379
379
|
}
|
|
380
|
+
if (report === 'cohort-over-time') {
|
|
381
|
+
const dateField = params.get('date_field') || '';
|
|
382
|
+
const cohortField = params.get('cohort_field') || '';
|
|
383
|
+
const granularity = params.get('granularity') || 'month';
|
|
384
|
+
return renderCohortOverTimeReport(user, entityName, spec, items, dateField, cohortField, granularity);
|
|
385
|
+
}
|
|
380
386
|
if (report === 'rollup') {
|
|
381
387
|
const refField = params.get('ref_field') || '';
|
|
382
388
|
const rollupField = params.get('rollup_field') || '';
|
|
@@ -415,9 +421,18 @@ export async function handlePage(pathname, req, res) {
|
|
|
415
421
|
// entity route uses) -- no new unscoped query, and each item already
|
|
416
422
|
// carries a computed weighted_value from list()'s own formula-field
|
|
417
423
|
// pass, reused directly rather than recomputed.
|
|
418
|
-
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');
|
|
419
426
|
const buckets = projectDemandByMonth(items);
|
|
420
|
-
|
|
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);
|
|
421
436
|
}
|
|
422
437
|
const view = params.get('view');
|
|
423
438
|
if (view === 'board') return renderBoardView(user, entityName, spec, items);
|
|
@@ -329,17 +329,82 @@ export function renderPivotReport(user, entityName, spec, records, rowField, col
|
|
|
329
329
|
return page(user, `${label} Report | Thatcher`, null, content);
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
-
|
|
332
|
+
// Trend-by-cohort: how a metric evolves over time (rows = date periods),
|
|
333
|
+
// broken down by a cohort dimension (cols = cohort values) -- a genuinely
|
|
334
|
+
// distinct shape from pivotByFields' point-in-time cross-tab and
|
|
335
|
+
// countOverTime's ungrouped time series. Reuses pivotByFields directly
|
|
336
|
+
// rather than a parallel implementation: a synthetic '__period' field is
|
|
337
|
+
// pre-computed onto cloned records via the SAME bucketDateKey granularity
|
|
338
|
+
// logic countOverTime already uses, and a matching plain-text spec.fields
|
|
339
|
+
// entry is added so bucketLabel's enum-lookup branch is skipped (falls
|
|
340
|
+
// through to String(rawValue), exactly what a period string needs) -- then
|
|
341
|
+
// it's an ordinary two-field pivot. ISO-formatted period keys (YYYY-MM,
|
|
342
|
+
// YYYY-MM-DD, YYYY-Www) sort correctly under pivotByFields' existing
|
|
343
|
+
// lexical row sort, so no separate chronological sort is needed.
|
|
344
|
+
export function cohortOverTime(records, spec, dateField, cohortField, granularity = 'month') {
|
|
345
|
+
const periodField = '__period';
|
|
346
|
+
const augmentedRecords = [];
|
|
347
|
+
let unresolved = 0;
|
|
348
|
+
for (const r of records) {
|
|
349
|
+
const key = bucketDateKey(r[dateField], granularity);
|
|
350
|
+
if (key === null) { unresolved++; continue; }
|
|
351
|
+
augmentedRecords.push({ ...r, [periodField]: key });
|
|
352
|
+
}
|
|
353
|
+
const augmentedSpec = { ...spec, fields: { ...spec.fields, [periodField]: { type: 'text', label: 'Period' } } };
|
|
354
|
+
const { rows, cols, cells } = pivotByFields(augmentedRecords, augmentedSpec, periodField, cohortField, null, 'count');
|
|
355
|
+
return { rows, cols, cells, unresolved };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function renderCohortOverTimeReport(user, entityName, spec, records, dateField, cohortField, granularity) {
|
|
359
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
360
|
+
const dateFieldDef = spec.fields?.[dateField];
|
|
361
|
+
const cohortFieldDef = spec.fields?.[cohortField];
|
|
362
|
+
if (!dateFieldDef || !cohortFieldDef) {
|
|
363
|
+
const badField = !dateFieldDef ? dateField : cohortField;
|
|
364
|
+
return page(user, `${label} | Report`, null,
|
|
365
|
+
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
366
|
+
<div class="report-empty-state">Unknown field "${esc(badField)}" for this entity.</div>`);
|
|
367
|
+
}
|
|
368
|
+
const { rows, cols, cells, unresolved } = cohortOverTime(records, spec, dateField, cohortField, granularity);
|
|
369
|
+
const notice = unresolved
|
|
370
|
+
? `<div class="report-notice">${unresolved} record${unresolved === 1 ? '' : 's'} without a resolvable date excluded</div>`
|
|
371
|
+
: '';
|
|
372
|
+
const headerCells = cols.map(c => `<th>${esc(c)}</th>`).join('');
|
|
373
|
+
const bodyRows = rows.map((r, i) => {
|
|
374
|
+
const dataCells = cells[i].map(v => `<td style="text-align:right">${esc(String(v))}</td>`).join('');
|
|
375
|
+
return `<tr><td>${esc(r)}</td>${dataCells}</tr>`;
|
|
376
|
+
}).join('') || emptyState('No data to report on', 'bar-chart');
|
|
377
|
+
const content = `<div class="page-header">
|
|
378
|
+
<div><h1 class="page-title">${esc(label)}: ${esc(cohortFieldDef.label || cohortField)} over time</h1><p class="page-subtitle">${records.length} total records, grouped by ${esc(granularity)}</p></div>
|
|
379
|
+
</div>
|
|
380
|
+
${notice}
|
|
381
|
+
<div class="table-wrap"><table class="data-table">
|
|
382
|
+
<thead><tr><th>Period</th>${headerCells}</tr></thead>
|
|
383
|
+
<tbody>${bodyRows}</tbody>
|
|
384
|
+
</table></div>`;
|
|
385
|
+
return page(user, `${label} Report | Thatcher`, null, content);
|
|
386
|
+
}
|
|
387
|
+
|
|
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) {
|
|
333
394
|
const label = getEntityLabel(spec, true) || 'Opportunities';
|
|
334
395
|
const valueFieldDef = spec.fields?.value || { type: 'currency' };
|
|
335
396
|
const totalProjected = buckets.reduce((sum, [, v]) => sum + v, 0);
|
|
336
397
|
const notice = !buckets.length
|
|
337
398
|
? `<div class="report-notice">No open opportunities with a future expected close date</div>`
|
|
338
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
|
+
: '';
|
|
339
403
|
const content = `<div class="page-header">
|
|
340
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>
|
|
341
405
|
</div>
|
|
342
406
|
${notice}
|
|
407
|
+
${trendNotice}
|
|
343
408
|
${sumBarChart(buckets, valueFieldDef)}`;
|
|
344
409
|
return page(user, `Demand Forecast | Thatcher`, null, content);
|
|
345
410
|
}
|