thatcher 1.0.85 → 1.0.86
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 +1 -1
- package/src/lib/demand-forecast.js +58 -0
- package/src/ui/page-handler.js +11 -1
- package/src/ui/report-renderer.js +20 -0
package/package.json
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Single source of truth for CRM demand-planning math, shared with any
|
|
2
|
+
// future forecast summary view -- the same small pure-computation module
|
|
3
|
+
// shape as contract-expiry.js and inventory-forecast.js. Forward-looking
|
|
4
|
+
// (open pipeline -> future revenue), the mirror image of
|
|
5
|
+
// inventory-forecast.js's backward-looking (movement history -> stockout).
|
|
6
|
+
const OPEN_STAGE_EXCLUSIONS = new Set(['won', 'lost']);
|
|
7
|
+
|
|
8
|
+
function monthBucketKey(dateSeconds) {
|
|
9
|
+
const d = new Date(dateSeconds * 1000);
|
|
10
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The stage field alone decides "still open" -- not the presence of a
|
|
14
|
+
// weighted_value or any date math, so a caller can't accidentally count a
|
|
15
|
+
// won/lost opportunity by omission.
|
|
16
|
+
function isOpenStage(opportunity) {
|
|
17
|
+
return !OPEN_STAGE_EXCLUSIONS.has(opportunity.stage);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A record already carries weighted_value if it passed through
|
|
21
|
+
// busybase/store.js's list()/get() (which now computes formula fields on
|
|
22
|
+
// every read) -- reused directly rather than recomputed, so this module
|
|
23
|
+
// never drifts from the same formula the opportunity entity itself defines.
|
|
24
|
+
// Falls back to computing value*probability/100 independently only if the
|
|
25
|
+
// field is genuinely absent (e.g. a deployment whose opportunity entity
|
|
26
|
+
// doesn't define weighted_value), never silently treating a present-but-null
|
|
27
|
+
// value as "compute it myself" -- null still means the formula ran and
|
|
28
|
+
// legitimately produced nothing.
|
|
29
|
+
function weightedValueOf(opportunity) {
|
|
30
|
+
if (opportunity.weighted_value !== undefined) return opportunity.weighted_value ?? 0;
|
|
31
|
+
const value = Number(opportunity.value) || 0;
|
|
32
|
+
const probability = Number(opportunity.probability) || 0;
|
|
33
|
+
return (value * probability) / 100;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function projectDemandByMonth(opportunities, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
37
|
+
const currentBucketKey = monthBucketKey(nowSeconds);
|
|
38
|
+
const buckets = new Map();
|
|
39
|
+
|
|
40
|
+
for (const opp of opportunities) {
|
|
41
|
+
if (!isOpenStage(opp)) continue;
|
|
42
|
+
if (opp.expected_close_date == null) continue;
|
|
43
|
+
|
|
44
|
+
const bucketKey = monthBucketKey(Number(opp.expected_close_date));
|
|
45
|
+
// A future bucket sorts >= the current month's key lexicographically
|
|
46
|
+
// (YYYY-MM strings compare correctly as dates); a past-due open
|
|
47
|
+
// opportunity's bucket key is strictly less than the current one and is
|
|
48
|
+
// excluded entirely rather than folded into the nearest future bucket --
|
|
49
|
+
// silently reassigning it would misrepresent when the demand was
|
|
50
|
+
// actually expected.
|
|
51
|
+
if (bucketKey < currentBucketKey) continue;
|
|
52
|
+
|
|
53
|
+
const weighted = weightedValueOf(opp);
|
|
54
|
+
buckets.set(bucketKey, (buckets.get(bucketKey) || 0) + weighted);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return [...buckets.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
58
|
+
}
|
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 } from '@/ui/report-renderer.js';
|
|
12
|
+
import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport, renderDemandForecastReport } 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';
|
|
@@ -392,6 +392,16 @@ export async function handlePage(pathname, req, res) {
|
|
|
392
392
|
}
|
|
393
393
|
return renderInventoryForecastReport(user, spec, forecastRows);
|
|
394
394
|
}
|
|
395
|
+
if (report === 'demand-forecast' && entityName === 'opportunity') {
|
|
396
|
+
// items is already the {user}-scoped opportunity list fetched at the
|
|
397
|
+
// top of this route (same list(entityName,{},{user}) every other
|
|
398
|
+
// entity route uses) -- no new unscoped query, and each item already
|
|
399
|
+
// carries a computed weighted_value from list()'s own formula-field
|
|
400
|
+
// pass, reused directly rather than recomputed.
|
|
401
|
+
const { projectDemandByMonth } = await import('@/lib/demand-forecast.js');
|
|
402
|
+
const buckets = projectDemandByMonth(items);
|
|
403
|
+
return renderDemandForecastReport(user, spec, buckets, items.length);
|
|
404
|
+
}
|
|
395
405
|
const view = params.get('view');
|
|
396
406
|
if (view === 'board') return renderBoardView(user, entityName, spec, items);
|
|
397
407
|
if (view === 'grid') return renderGridView(user, entityName, spec, items);
|
|
@@ -237,3 +237,23 @@ export function renderInventoryForecastReport(user, spec, forecastRows) {
|
|
|
237
237
|
</table></div>`;
|
|
238
238
|
return page(user, `Inventory Forecast | Thatcher`, null, content);
|
|
239
239
|
}
|
|
240
|
+
|
|
241
|
+
// Reuses sumBarChart (already built for sum-by-field's currency-aware bar
|
|
242
|
+
// rendering) rather than inventing a third chart type -- a demand-by-month
|
|
243
|
+
// bucket is the exact same [label, numericValue] shape sum-by-field already
|
|
244
|
+
// renders, just currency-formatted since weighted_value derives from a
|
|
245
|
+
// currency field.
|
|
246
|
+
export function renderDemandForecastReport(user, spec, buckets, totalOpportunities) {
|
|
247
|
+
const label = getEntityLabel(spec, true) || 'Opportunities';
|
|
248
|
+
const valueFieldDef = spec.fields?.value || { type: 'currency' };
|
|
249
|
+
const totalProjected = buckets.reduce((sum, [, v]) => sum + v, 0);
|
|
250
|
+
const notice = !buckets.length
|
|
251
|
+
? `<div class="report-notice">No open opportunities with a future expected close date</div>`
|
|
252
|
+
: '';
|
|
253
|
+
const content = `<div class="page-header">
|
|
254
|
+
<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>
|
|
255
|
+
</div>
|
|
256
|
+
${notice}
|
|
257
|
+
${sumBarChart(buckets, valueFieldDef)}`;
|
|
258
|
+
return page(user, `Demand Forecast | Thatcher`, null, content);
|
|
259
|
+
}
|