thatcher 1.0.71 → 1.0.73
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
|
@@ -197,10 +197,44 @@ function withProjectDefaults(masterConfig) {
|
|
|
197
197
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
const PRODUCT_ENTITY_DEFAULT = {
|
|
201
|
+
label: 'Product',
|
|
202
|
+
label_plural: 'Products',
|
|
203
|
+
system_entity: true,
|
|
204
|
+
fields: {
|
|
205
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
206
|
+
sku: { type: 'text', required: true, unique: true, label: 'SKU' },
|
|
207
|
+
description: { type: 'textarea', label: 'Description' },
|
|
208
|
+
unit_price: { type: 'currency', label: 'Unit Price' },
|
|
209
|
+
reorder_threshold: { type: 'number', min: 0, label: 'Reorder Threshold' },
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const STOCK_MOVEMENT_ENTITY_DEFAULT = {
|
|
214
|
+
label: 'Stock Movement',
|
|
215
|
+
label_plural: 'Stock Movements',
|
|
216
|
+
system_entity: true,
|
|
217
|
+
fields: {
|
|
218
|
+
product_id: { type: 'ref', ref: 'product', required: true, label: 'Product' },
|
|
219
|
+
quantity: { type: 'number', required: true, label: 'Quantity' },
|
|
220
|
+
movement_type: { type: 'enum', options: ['receipt', 'sale', 'adjustment', 'return'], required: true, label: 'Movement Type' },
|
|
221
|
+
reference: { type: 'text', label: 'Reference' },
|
|
222
|
+
notes: { type: 'textarea', label: 'Notes' },
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
function withInventoryDefaults(masterConfig) {
|
|
227
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
228
|
+
let changed = false;
|
|
229
|
+
if (!entities.product) { entities.product = PRODUCT_ENTITY_DEFAULT; changed = true; }
|
|
230
|
+
if (!entities.stock_movement) { entities.stock_movement = STOCK_MOVEMENT_ENTITY_DEFAULT; changed = true; }
|
|
231
|
+
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
232
|
+
}
|
|
233
|
+
|
|
200
234
|
export class ConfigGeneratorEngine {
|
|
201
235
|
constructor(masterConfig) {
|
|
202
236
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
203
|
-
this.masterConfig = deepFreeze(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))));
|
|
237
|
+
this.masterConfig = deepFreeze(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))));
|
|
204
238
|
this.specCache = new LRUCache(100);
|
|
205
239
|
this.debugMode = false;
|
|
206
240
|
this._plugins = new Map();
|
|
@@ -152,6 +152,28 @@ function resolveEnumOptions(fieldDef, entityName) {
|
|
|
152
152
|
return [];
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// Stock movements are immutable history (checked at creation only, never on
|
|
156
|
+
// edit -- there is no update path for them). An outbound movement (negative
|
|
157
|
+
// quantity) that would drive the running balance below zero is invalid: the
|
|
158
|
+
// balance is the sum of every OTHER movement for this product plus the new
|
|
159
|
+
// one, computed fresh from stored data, never trusting a client-supplied
|
|
160
|
+
// "current stock" value that doesn't exist as an editable field.
|
|
161
|
+
async function checkStockBalance(entityName, data) {
|
|
162
|
+
if (entityName !== 'stock_movement') return null;
|
|
163
|
+
const quantity = Number(data.quantity);
|
|
164
|
+
if (!Number.isFinite(quantity) || quantity >= 0) return null;
|
|
165
|
+
if (!data.product_id) return null;
|
|
166
|
+
|
|
167
|
+
const { list } = await import('@/lib/busybase/store');
|
|
168
|
+
const existingMovements = await list('stock_movement', { product_id: data.product_id });
|
|
169
|
+
const currentBalance = existingMovements.reduce((sum, m) => sum + (Number(m.quantity) || 0), 0);
|
|
170
|
+
const resultingBalance = currentBalance + quantity;
|
|
171
|
+
if (resultingBalance < 0) {
|
|
172
|
+
return `Insufficient stock: current balance is ${currentBalance}, this movement would result in ${resultingBalance}`;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
155
177
|
export async function validateEntity(entityName, data, existingRecord = null) {
|
|
156
178
|
const spec = getSpec(entityName);
|
|
157
179
|
const errors = {};
|
|
@@ -176,6 +198,9 @@ export async function validateEntity(entityName, data, existingRecord = null) {
|
|
|
176
198
|
if (uniqueErr && !errors[fieldName]) errors[fieldName] = uniqueErr;
|
|
177
199
|
}
|
|
178
200
|
|
|
201
|
+
const stockErr = await checkStockBalance(entityName, data);
|
|
202
|
+
if (stockErr) errors.quantity = stockErr;
|
|
203
|
+
|
|
179
204
|
return errors;
|
|
180
205
|
}
|
|
181
206
|
|
package/src/server/server.js
CHANGED
|
@@ -411,6 +411,19 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
411
411
|
// editable_by, so a hand-crafted request bypassing the rendered form
|
|
412
412
|
// cannot smuggle a restricted field in.
|
|
413
413
|
permissionService.enforceEditPermissions(user, spec, body);
|
|
414
|
+
{
|
|
415
|
+
// Same gap PUT/PATCH had (fixed last pass): this raw-CRUD POST
|
|
416
|
+
// never called any field validation, so required/min-max/enum/ref-
|
|
417
|
+
// existence and entity-specific rules (e.g. stock-movement balance
|
|
418
|
+
// enforcement below) were silently skipped for a hand-crafted create.
|
|
419
|
+
const { validateEntity, hasErrors } = await import('../lib/validation/entity-validators.js');
|
|
420
|
+
const createErrors = await validateEntity(entity, body, null);
|
|
421
|
+
if (hasErrors(createErrors)) {
|
|
422
|
+
res.writeHead(400);
|
|
423
|
+
res.end(JSON.stringify({ error: 'Validation failed', errors: createErrors }));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
414
427
|
result = await thatcher.create(entity, body, user);
|
|
415
428
|
const { logAction } = await import('../lib/busybase/audit.js');
|
|
416
429
|
logAction(entity, result.id, 'create', user.id, null, result);
|
|
@@ -124,9 +124,23 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
|
124
124
|
const userCanEdit = canEdit(user, entityName)
|
|
125
125
|
const userCanDelete = canDelete(user, entityName)
|
|
126
126
|
|
|
127
|
+
// current_stock is computed (page-handler.js sums stock_movement rows),
|
|
128
|
+
// never a spec.fields entry -- rendered as its own row rather than folded
|
|
129
|
+
// into the generic field loop below, which only knows about spec fields.
|
|
130
|
+
const stockRow = entityName === 'product' && typeof item.current_stock === 'number'
|
|
131
|
+
? (() => {
|
|
132
|
+
const low = item.reorder_threshold != null && item.current_stock <= item.reorder_threshold
|
|
133
|
+
const pillCls = low ? 'pill-danger' : 'pill-success'
|
|
134
|
+
return `<div class="detail-row">
|
|
135
|
+
<span class="detail-row-label">Current Stock</span>
|
|
136
|
+
<span class="detail-row-value"><span class="pill ${pillCls}">${esc(String(item.current_stock))}${low ? ' (Low Stock)' : ''}</span></span>
|
|
137
|
+
</div>`
|
|
138
|
+
})()
|
|
139
|
+
: ''
|
|
140
|
+
|
|
127
141
|
const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
|
|
128
142
|
|
|
129
|
-
const fieldRows = visibleFields.map(([k, f]) =>
|
|
143
|
+
const fieldRows = stockRow + visibleFields.map(([k, f]) =>
|
|
130
144
|
`<div class="detail-row">
|
|
131
145
|
<span class="detail-row-label">${esc(f.label || k)}</span>
|
|
132
146
|
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
|
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 } from '@/ui/report-renderer.js';
|
|
12
|
+
import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport } 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';
|
|
@@ -114,7 +114,17 @@ async function handleGenericEntityView(user, entityName, id, req) {
|
|
|
114
114
|
const item = await get(entityName, id, { user }); if (!item) return null;
|
|
115
115
|
if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'view');
|
|
116
116
|
if (isClientUser(user) && user.client_id && item.client_id && item.client_id !== user.client_id) return renderAccessDenied(user, entityName, 'view');
|
|
117
|
-
|
|
117
|
+
let [resolvedItem] = resolveRefFields([item], spec);
|
|
118
|
+
if (entityName === 'product') {
|
|
119
|
+
// current_stock is derived, never a stored/editable field -- summed fresh
|
|
120
|
+
// from stock_movement history so it can never drift from the ledger the
|
|
121
|
+
// way a separately-writable counter could.
|
|
122
|
+
try {
|
|
123
|
+
const movements = await list('stock_movement', { product_id: id });
|
|
124
|
+
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 }; }
|
|
127
|
+
}
|
|
118
128
|
// get(...,{user}) above already enforced row/org access for this exact
|
|
119
129
|
// record (a denied/absent record returns null before this point), so
|
|
120
130
|
// fetching its audit trail here is scoped by construction -- there is no
|
|
@@ -293,6 +303,27 @@ export async function handlePage(pathname, req, res) {
|
|
|
293
303
|
const granularity = params.get('granularity') || 'month';
|
|
294
304
|
return renderCountOverTimeReport(user, entityName, spec, items, dateField, granularity);
|
|
295
305
|
}
|
|
306
|
+
if (report === 'sum-by-field') {
|
|
307
|
+
const field = params.get('field') || '';
|
|
308
|
+
const groupBy = params.get('group_by') || '';
|
|
309
|
+
return renderSumByFieldReport(user, entityName, spec, items, field, groupBy);
|
|
310
|
+
}
|
|
311
|
+
if (report === 'rollup') {
|
|
312
|
+
const refField = params.get('ref_field') || '';
|
|
313
|
+
const rollupField = params.get('rollup_field') || '';
|
|
314
|
+
// The rollup groups by a field on the RELATED entity (refField.ref), so
|
|
315
|
+
// that entity's list access must be checked the same way viewing it
|
|
316
|
+
// directly would be -- a cross-entity report must not become a side
|
|
317
|
+
// channel for reading data the user couldn't otherwise view.
|
|
318
|
+
const refFieldDef = spec.fields?.[refField];
|
|
319
|
+
const relatedEntity = refFieldDef?.ref;
|
|
320
|
+
if (!relatedEntity || !canList(user, relatedEntity)) {
|
|
321
|
+
return renderAccessDenied(user, relatedEntity || entityName, 'view');
|
|
322
|
+
}
|
|
323
|
+
const relatedSpec = getSpec(relatedEntity);
|
|
324
|
+
const relatedRecords = await list(relatedEntity, {}, { user });
|
|
325
|
+
return renderRollupReport(user, entityName, spec, items, refField, relatedEntity, relatedSpec, relatedRecords, rollupField);
|
|
326
|
+
}
|
|
296
327
|
const view = params.get('view');
|
|
297
328
|
if (view === 'board') return renderBoardView(user, entityName, spec, items);
|
|
298
329
|
if (view === 'grid') return renderGridView(user, entityName, spec, items);
|
|
@@ -89,6 +89,101 @@ export function renderCountByFieldReport(user, entityName, spec, records, field)
|
|
|
89
89
|
return page(user, `${label} Report | Thatcher`, null, content);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
export function sumByField(records, spec, field, groupBy) {
|
|
93
|
+
const sums = new Map();
|
|
94
|
+
for (const r of records) {
|
|
95
|
+
const raw = r[field];
|
|
96
|
+
const num = typeof raw === 'string' ? Number(raw) : raw;
|
|
97
|
+
if (typeof num !== 'number' || isNaN(num)) continue;
|
|
98
|
+
const label = bucketLabel(spec, groupBy, r[groupBy]);
|
|
99
|
+
sums.set(label, (sums.get(label) || 0) + num);
|
|
100
|
+
}
|
|
101
|
+
const sorted = [...sums.entries()].sort((a, b) => b[1] - a[1]);
|
|
102
|
+
if (sorted.length <= MAX_BUCKETS) return sorted;
|
|
103
|
+
const top = sorted.slice(0, MAX_BUCKETS - 1);
|
|
104
|
+
const otherSum = sorted.slice(MAX_BUCKETS - 1).reduce((sum, [, v]) => sum + v, 0);
|
|
105
|
+
return [...top, ['(other)', otherSum]];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatSumValue(value, fieldDef) {
|
|
109
|
+
if (fieldDef?.type === 'currency') return (fieldDef.currency_symbol || '$') + (value / 100).toFixed(2);
|
|
110
|
+
return String(Math.round(value * 100) / 100);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sumBarChart(buckets, fieldDef) {
|
|
114
|
+
if (!buckets.length) return emptyState('No data to report on', 'bar-chart');
|
|
115
|
+
const max = Math.max(...buckets.map(([, v]) => Math.abs(v)), 1);
|
|
116
|
+
const rows = buckets.map(([label, value]) => {
|
|
117
|
+
const pct = Math.round((Math.abs(value) / max) * 100);
|
|
118
|
+
return `<div class="report-bar-row" style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
|
|
119
|
+
<div style="min-width:140px;max-width:220px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(label)}">${esc(label)}</div>
|
|
120
|
+
<div style="flex:1;height:20px;background:var(--color-border,#eee);border-radius:4px;overflow:hidden">
|
|
121
|
+
<div style="height:100%;width:${pct}%;background:var(--color-primary,#3b82f6);border-radius:4px"></div>
|
|
122
|
+
</div>
|
|
123
|
+
<div style="min-width:80px;text-align:right;font-size:13px;font-weight:600">${esc(formatSumValue(value, fieldDef))}</div>
|
|
124
|
+
</div>`;
|
|
125
|
+
}).join('');
|
|
126
|
+
return `<div class="report-bar-chart">${rows}</div>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function renderSumByFieldReport(user, entityName, spec, records, field, groupBy) {
|
|
130
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
131
|
+
const fieldDef = spec.fields?.[field];
|
|
132
|
+
const groupByDef = spec.fields?.[groupBy];
|
|
133
|
+
if (!fieldDef || !groupByDef) {
|
|
134
|
+
return page(user, `${label} | Report`, null,
|
|
135
|
+
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
136
|
+
<div class="report-empty-state">Unknown field "${esc(!fieldDef ? field : groupBy)}" for this entity.</div>`);
|
|
137
|
+
}
|
|
138
|
+
const buckets = sumByField(records, spec, field, groupBy);
|
|
139
|
+
const content = `<div class="page-header">
|
|
140
|
+
<div><h1 class="page-title">${esc(label)}: ${esc(fieldDef.label || field)} by ${esc(groupByDef.label || groupBy)}</h1><p class="page-subtitle">${records.length} total records</p></div>
|
|
141
|
+
</div>
|
|
142
|
+
${sumBarChart(buckets, fieldDef)}`;
|
|
143
|
+
return page(user, `${label} Report | Thatcher`, null, content);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Cross-entity rollup: entity A's records are grouped by a field on related
|
|
147
|
+
// entity B, joined through A's ref field. relatedRecords must already be the
|
|
148
|
+
// CALLER's row/org-scoped list() result for B -- this function only joins
|
|
149
|
+
// and counts, it enforces no access itself (the access decision -- can this
|
|
150
|
+
// user list B at all -- is made by the caller before relatedRecords exists).
|
|
151
|
+
export function rollupByRelatedField(records, refField, relatedRecords, relatedSpec, rollupField) {
|
|
152
|
+
const relatedById = new Map(relatedRecords.map(r => [String(r.id), r]));
|
|
153
|
+
const counts = new Map();
|
|
154
|
+
let unmatched = 0;
|
|
155
|
+
for (const r of records) {
|
|
156
|
+
const refId = r[refField];
|
|
157
|
+
const related = refId != null ? relatedById.get(String(refId)) : null;
|
|
158
|
+
if (!related) { unmatched++; continue; }
|
|
159
|
+
const label = bucketLabel(relatedSpec, rollupField, related[rollupField]);
|
|
160
|
+
counts.set(label, (counts.get(label) || 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
163
|
+
return { buckets: sorted.length > MAX_BUCKETS ? sorted.slice(0, MAX_BUCKETS) : sorted, unmatched };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function renderRollupReport(user, entityName, spec, records, refField, relatedEntity, relatedSpec, relatedRecords, rollupField) {
|
|
167
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
168
|
+
const relatedLabel = getEntityLabel(relatedSpec, true) || relatedEntity;
|
|
169
|
+
const rollupFieldDef = relatedSpec?.fields?.[rollupField];
|
|
170
|
+
if (!spec.fields?.[refField] || !rollupFieldDef) {
|
|
171
|
+
return page(user, `${label} | Report`, null,
|
|
172
|
+
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
173
|
+
<div class="report-empty-state">Unknown field for this rollup.</div>`);
|
|
174
|
+
}
|
|
175
|
+
const { buckets, unmatched } = rollupByRelatedField(records, refField, relatedRecords, relatedSpec, rollupField);
|
|
176
|
+
const notice = unmatched
|
|
177
|
+
? `<div class="report-notice">${unmatched} record${unmatched === 1 ? '' : 's'} without a resolvable ${esc(relatedLabel)} excluded</div>`
|
|
178
|
+
: '';
|
|
179
|
+
const content = `<div class="page-header">
|
|
180
|
+
<div><h1 class="page-title">${esc(label)} by ${esc(relatedLabel)}.${esc(rollupFieldDef.label || rollupField)}</h1><p class="page-subtitle">${records.length} total records</p></div>
|
|
181
|
+
</div>
|
|
182
|
+
${notice}
|
|
183
|
+
${barChart(buckets)}`;
|
|
184
|
+
return page(user, `${label} Report | Thatcher`, null, content);
|
|
185
|
+
}
|
|
186
|
+
|
|
92
187
|
export function renderCountOverTimeReport(user, entityName, spec, records, dateField, granularity) {
|
|
93
188
|
const label = getEntityLabel(spec, true) || entityName;
|
|
94
189
|
const fieldDef = spec.fields?.[dateField];
|