thatcher 1.0.84 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.84",
3
+ "version": "1.0.86",
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",
@@ -209,6 +209,7 @@ const PRODUCT_ENTITY_DEFAULT = {
209
209
  description: { type: 'textarea', label: 'Description' },
210
210
  unit_price: { type: 'currency', label: 'Unit Price' },
211
211
  reorder_threshold: { type: 'number', min: 0, label: 'Reorder Threshold' },
212
+ lead_time_days: { type: 'number', min: 0, default: 14, label: 'Lead Time (days)' },
212
213
  },
213
214
  };
214
215
 
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ // Single source of truth for inventory demand-forecast math, shared between
2
+ // page-handler.js's product detail-view computation and any future forecast
3
+ // summary view -- kept in one place so they never drift, the same pattern
4
+ // contract-expiry.js already established for expiry math.
5
+ const DEFAULT_WINDOW_DAYS = 90;
6
+ const DEFAULT_LEAD_TIME_DAYS = 14;
7
+ const DAY_SECONDS = 86400;
8
+
9
+ // Average daily consumption over a trailing window: sum of every OUTBOUND
10
+ // (negative quantity) movement within the window, divided by the window's
11
+ // day-count -- not the number of movements, so sparse activity (e.g. one
12
+ // large monthly shipment) still averages correctly over the full period
13
+ // rather than only the days something happened.
14
+ export function averageDailyConsumption(movements, windowDays = DEFAULT_WINDOW_DAYS, nowSeconds = Math.floor(Date.now() / 1000)) {
15
+ const windowStart = nowSeconds - windowDays * DAY_SECONDS;
16
+ const outboundTotal = movements
17
+ .filter(m => Number(m.created_at) >= windowStart && Number(m.quantity) < 0)
18
+ .reduce((sum, m) => sum + Math.abs(Number(m.quantity) || 0), 0);
19
+ return outboundTotal / windowDays;
20
+ }
21
+
22
+ // null (not Infinity, not a crash) when there is no consumption to project
23
+ // against -- a caller rendering this must be able to tell "we don't know"
24
+ // apart from "never" without special-casing an infinite number.
25
+ export function daysUntilStockout(currentStock, avgDailyConsumption) {
26
+ if (!Number.isFinite(avgDailyConsumption) || avgDailyConsumption <= 0) return null;
27
+ if (currentStock <= 0) return 0;
28
+ return currentStock / avgDailyConsumption;
29
+ }
30
+
31
+ export function suggestedReorderDate(daysUntilStockoutValue, leadTimeDays = DEFAULT_LEAD_TIME_DAYS, nowSeconds = Math.floor(Date.now() / 1000)) {
32
+ if (daysUntilStockoutValue == null) return null;
33
+ const daysUntilReorder = daysUntilStockoutValue - leadTimeDays;
34
+ return nowSeconds + daysUntilReorder * DAY_SECONDS;
35
+ }
36
+
37
+ export function isReorderDue(reorderDateSeconds, nowSeconds = Math.floor(Date.now() / 1000)) {
38
+ if (reorderDateSeconds == null) return false;
39
+ return reorderDateSeconds <= nowSeconds;
40
+ }
41
+
42
+ export function computeInventoryForecast(product, movements, currentStock, options = {}) {
43
+ const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000);
44
+ const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS;
45
+ const leadTimeDays = product.lead_time_days ?? DEFAULT_LEAD_TIME_DAYS;
46
+
47
+ const avgDailyConsumption = averageDailyConsumption(movements, windowDays, nowSeconds);
48
+ const daysUntilOut = daysUntilStockout(currentStock, avgDailyConsumption);
49
+ const reorderDate = suggestedReorderDate(daysUntilOut, leadTimeDays, nowSeconds);
50
+ const reorderDue = isReorderDue(reorderDate, nowSeconds);
51
+
52
+ return { avg_daily_consumption: avgDailyConsumption, days_until_stockout: daysUntilOut, reorder_date: reorderDate, reorder_due: reorderDue };
53
+ }
@@ -139,6 +139,24 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
139
139
  })()
140
140
  : ''
141
141
 
142
+ // Forecast fields are computed alongside current_stock (page-handler.js's
143
+ // inventory-forecast.js), same not-a-spec-field treatment.
144
+ const forecastRow = entityName === 'product' && item.days_until_stockout !== undefined
145
+ ? (() => {
146
+ const daysLabel = item.days_until_stockout == null ? 'Unknown (no recent consumption)' : `${Math.round(item.days_until_stockout)} days`
147
+ const reorderLabel = item.reorder_date == null ? '-' : new Date(item.reorder_date * 1000).toISOString().slice(0, 10)
148
+ const pillCls = item.reorder_due ? 'pill-danger' : 'pill-success'
149
+ return `<div class="detail-row">
150
+ <span class="detail-row-label">Days Until Stockout</span>
151
+ <span class="detail-row-value">${esc(daysLabel)}</span>
152
+ </div>
153
+ <div class="detail-row">
154
+ <span class="detail-row-label">Suggested Reorder Date</span>
155
+ <span class="detail-row-value"><span class="pill ${pillCls}">${esc(reorderLabel)}${item.reorder_due ? ' (Reorder Now)' : ''}</span></span>
156
+ </div>`
157
+ })()
158
+ : ''
159
+
142
160
  // total_hours/billable_amount are computed (page-handler.js sums time_entry
143
161
  // rows, directly for a task or joined through task for a project), same
144
162
  // not-a-spec-field treatment as current_stock above.
@@ -177,7 +195,7 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
177
195
 
178
196
  const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
179
197
 
180
- const fieldRows = stockRow + timeTrackingRows + expiryRow + utilizationRow + visibleFields.map(([k, f]) =>
198
+ const fieldRows = stockRow + forecastRow + timeTrackingRows + expiryRow + utilizationRow + visibleFields.map(([k, f]) =>
181
199
  `<div class="detail-row">
182
200
  <span class="detail-row-label">${esc(f.label || k)}</span>
183
201
  <span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
@@ -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 } 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';
@@ -118,12 +118,18 @@ async function handleGenericEntityView(user, entityName, id, req) {
118
118
  if (entityName === 'product') {
119
119
  // current_stock is derived, never a stored/editable field -- summed fresh
120
120
  // from stock_movement history so it can never drift from the ledger the
121
- // way a separately-writable counter could.
121
+ // way a separately-writable counter could. stock_movement carries no
122
+ // organization_id (product is a system_entity, exempt from multi-tenancy
123
+ // auto-scoping the same way every other system entity this session is),
124
+ // so this unscoped list() matches the entity's actual access model
125
+ // rather than bypassing a row-access check that was never in effect.
122
126
  try {
123
127
  const movements = await list('stock_movement', { product_id: id });
124
128
  const currentStock = movements.reduce((sum, m) => sum + (Number(m.quantity) || 0), 0);
125
- resolvedItem = { ...resolvedItem, current_stock: currentStock };
126
- } catch { resolvedItem = { ...resolvedItem, current_stock: 0 }; }
129
+ const { computeInventoryForecast } = await import('@/lib/inventory-forecast.js');
130
+ const forecast = computeInventoryForecast(resolvedItem, movements, currentStock);
131
+ resolvedItem = { ...resolvedItem, current_stock: currentStock, ...forecast };
132
+ } catch { resolvedItem = { ...resolvedItem, current_stock: 0, avg_daily_consumption: 0, days_until_stockout: null, reorder_date: null, reorder_due: false }; }
127
133
  }
128
134
  if (entityName === 'task') {
129
135
  // total_hours/billable_amount are derived from time_entry history, same
@@ -370,6 +376,32 @@ export async function handlePage(pathname, req, res) {
370
376
  const relatedRecords = await list(relatedEntity, {}, { user });
371
377
  return renderRollupReport(user, entityName, spec, items, refField, relatedEntity, relatedSpec, relatedRecords, rollupField);
372
378
  }
379
+ if (report === 'inventory-forecast' && entityName === 'product') {
380
+ // items is already the {user}-scoped product list from above -- no new
381
+ // unscoped bulk query. stock_movement itself carries no organization_id
382
+ // (product is a system_entity, exempt from multi-tenancy auto-scoping
383
+ // like every other system entity this session), so it is read
384
+ // per-product the same unscoped way the detail view already does.
385
+ const { computeInventoryForecast } = await import('@/lib/inventory-forecast.js');
386
+ const forecastRows = [];
387
+ for (const p of items) {
388
+ const movements = await list('stock_movement', { product_id: p.id });
389
+ const currentStock = movements.reduce((sum, m) => sum + (Number(m.quantity) || 0), 0);
390
+ const forecast = computeInventoryForecast(p, movements, currentStock);
391
+ forecastRows.push({ ...p, current_stock: currentStock, ...forecast });
392
+ }
393
+ return renderInventoryForecastReport(user, spec, forecastRows);
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
+ }
373
405
  const view = params.get('view');
374
406
  if (view === 'board') return renderBoardView(user, entityName, spec, items);
375
407
  if (view === 'grid') return renderGridView(user, entityName, spec, items);
@@ -206,3 +206,54 @@ export function renderCountOverTimeReport(user, entityName, spec, records, dateF
206
206
  ${barChart(buckets)}`;
207
207
  return page(user, `${label} Report | Thatcher`, null, content);
208
208
  }
209
+
210
+ // One table per-product rather than a bar chart: unlike count-by-field/
211
+ // sum-by-field's single grouped metric, a forecast is inherently multiple
212
+ // values per row (stock, consumption rate, days-out, reorder date) -- a bar
213
+ // chart of any single one of those would discard the others, so a table is
214
+ // the correct shape here, reusing the same page()/esc() rendering discipline
215
+ // as every other report rather than inventing a new chart type.
216
+ export function renderInventoryForecastReport(user, spec, forecastRows) {
217
+ const label = getEntityLabel(spec, true) || 'Products';
218
+ const rows = forecastRows.map(p => {
219
+ const daysLabel = p.days_until_stockout == null ? 'Unknown' : String(Math.round(p.days_until_stockout))
220
+ const reorderLabel = p.reorder_date == null ? '-' : new Date(p.reorder_date * 1000).toISOString().slice(0, 10)
221
+ const rowCls = p.reorder_due ? 'style="background:var(--color-danger-bg,#fee2e2)"' : ''
222
+ return `<tr ${rowCls}>
223
+ <td>${esc(p.name || p.id)}</td>
224
+ <td>${esc(String(p.current_stock))}</td>
225
+ <td>${esc((p.avg_daily_consumption || 0).toFixed(2))}</td>
226
+ <td>${esc(daysLabel)}</td>
227
+ <td>${esc(reorderLabel)}${p.reorder_due ? ' <span class="pill pill-danger">Reorder Now</span>' : ''}</td>
228
+ </tr>`;
229
+ }).join('') || emptyState('No products to forecast', 'bar-chart');
230
+
231
+ const content = `<div class="page-header">
232
+ <div><h1 class="page-title">${esc(label)}: Inventory Forecast</h1><p class="page-subtitle">${forecastRows.length} products</p></div>
233
+ </div>
234
+ <div class="table-wrap"><table class="data-table">
235
+ <thead><tr><th>Product</th><th>Current Stock</th><th>Avg Daily Use</th><th>Days Until Stockout</th><th>Suggested Reorder</th></tr></thead>
236
+ <tbody>${rows}</tbody>
237
+ </table></div>`;
238
+ return page(user, `Inventory Forecast | Thatcher`, null, content);
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
+ }