formlab-mcp 0.6.21 → 0.6.23

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/README.md CHANGED
@@ -35,6 +35,8 @@ Ask Claude (or another MCP client) things like:
35
35
  | `list_ingredients` | Filtered list of raw materials. Filters: `family`, `supplier`, `name_contains`, `in_stock_only`, `ingredient_class` (small-molecule / surfactant / polymer / extract / fragrance / pigment / sequence / mixture), `sequence_contains` (e.g. `KTTKS` → Matrixyl), `taxon_contains` (e.g. `Centella`) |
36
36
  | `get_ingredient` | Full record + supplier / **cost ($/kg)** / stock / chemical name / molecular weight / storage / formulations using it, plus **GHS safety** (pictograms, H/P codes, signal word), **per-jurisdiction regulatory status**, **approved sources** (the suppliers / manufacturers qualified to supply this material — ASL/AML), and **inventory lots** (balances + expiry + the source each was received from). Returns every class-specific sub-object when present: `sequence` (peptide / oligo), `taxon` (NCBI ID + scientific name), `ingredientClass`, `extractDetails`, `sequenceDetails`, `polymerDetails`, `surfactantDetails`, `pigmentDetails`, `fragranceDetails` |
37
37
  | `list_lots` | Inventory lots across ingredients (each a received batch with its own remaining balance, supplier, expiry, **unit cost**, **status** — `active` / `hold` / `quarantine` / `rejected`; held lots are blocked from consumption, with a `holdReason` — and the **approved source** it was received from: `source.supplier` + `source.manufacturer`, where recalls hinge on the manufacturer). Filters: `ingredient_id`, `expiring_within_days`, `status` (e.g. `hold` to find lots held for a recall) |
38
+ | `list_suppliers` | Supplier / manufacturer records with roll-ups — ingredients approved, lots received / on hand (+ value at paid cost), qualified / trial / disqualified source counts, last receipt; lots count by supplier **and** by manufacturer |
39
+ | `get_supplier` | One supplier in full: record, every ingredient it's an approved source for, every lot from it, and the **recall trace** (batches that consumed those lots → products, shipments, samples, sub-batches; lots on hold flagged) |
38
40
  | `list_inventory` | Portfolio stock rollup — one row per ingredient with on-hand qty, summed lot balance, nearest expiry, and a low-stock flag (on-hand ≤ `reorderThreshold`, or zero). Filters: `low_stock_only`, `expiring_within_days`, `family` |
39
41
  | `list_batches` | Filtered list of production / lab-prep events |
40
42
  | `get_batch` | Full record + actual composition, measured/derived **actual volume** (mass ÷ density, never a sum of per-ingredient volumes) and **actual mass** (yield, else summed inputs, else measured volume × density), the **process** method + any **process-factor values** (DOE inputs), samples + blend lineage, and — as a finished-goods lot — its storage **`location`** + finished-goods **`stockLedger`** (shipments out + adjustments; `shippedQty` total) |
@@ -54,13 +56,13 @@ Ask Claude (or another MCP client) things like:
54
56
  | `get_doe_design` | One design in full: constraints in plain language, the model it was optimised for, generation settings (run budget, replicates, centre points, seed) and the complete run matrix with the formulation each run became |
55
57
  | `find_by_smarts` | SMARTS-pattern substructure search across every ingredient with a SMILES. Requires `@rdkit/rdkit` (optional dependency — install with `npm install @rdkit/rdkit` in `mcp/` if you get a "not installed" error). Examples: `c1ccccc1` (any aromatic 6-ring), `[OX2H1]` (any hydroxyl), `C(=O)O` (carboxylic acid), `[F,Cl,Br,I]` (any halogen). |
56
58
  | `list_equipment` | Filtered list of the Equipment registry (mixers, ovens, viscometers, balances…). Filters: `category`, `status`, `manufacturer`, `name_contains` |
57
- | `get_equipment` | Full equipment record + a summary of where it's used (panels, step presets, test methods, formulas, batches) |
59
+ | `get_equipment` | Full equipment record + `usedIn` (panels, step presets, test methods, formula steps, batches, testReports resolved to it), `usage` (reports / formulas / batches / samples / pass rate / top formulas) and `measured` (per parameter: mean here vs mean on other instruments, biasPct) |
58
60
  | `list_test_methods` | Filtered list of Test Methods (the parameter library). Filter by `analyte` (the property measured, e.g. `Viscosity` — finds every method for it), `category`, `name_contains`, `include_retired` |
59
- | `get_test_method` | Full method definition (SOP / sample prep / equipment / calculation / acceptance spec) + sibling methods sharing its analyte |
61
+ | `get_test_method` | One Test Method: full definition, run conditions, analyte siblings, using panels, plus `usage`, `valueSummary` (mean / sd / min / max) and `byInstrument` |
60
62
  | `list_step_presets` | Filtered list of Step Presets (reusable procedure steps: duration / temp / RPM / equipment). Filters: `industry`, `name_contains` |
61
63
  | `get_step_preset` | Full step-preset record incl. the multi-value equipment list (`{name, equipmentId}`) |
62
64
  | `list_test_panels` | Filtered list of Test Panels (reusable column-sets — the parameters measured together on a sample). Filters: `industry`, `name_contains` |
63
- | `get_test_panel` | Full panel record — ordered parameter list (each with resolved Test Method UID, unit, spec) + instruments |
65
+ | `get_test_panel` | One Test Panel: ordered parameters with resolved method UIDs + specs, plus `usage` (reports created from it) and `performanceByParameter` |
64
66
  | `list_projects` | Filtered list of projects (the buckets formulations are filed under) + per-project formulation count. Filters: `status`, `name_contains` |
65
67
  | `get_project` | Full project record + the formulations filed under it |
66
68
  | `list_notebook_entries` | ELN feed — dated authored notes attached to records. Filters: `entity_type`, `entity_id`, `author`, `text_contains`, `since`, `until` |
@@ -149,7 +151,7 @@ Or for local-dev (from the repo):
149
151
  }
150
152
  ```
151
153
 
152
- Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 37 tools become callable in any conversation.
154
+ Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 39 tools become callable in any conversation.
153
155
 
154
156
  ## Wire it up to Claude Code
155
157
 
package/index.js CHANGED
@@ -38,6 +38,7 @@ import * as library from './tools/library.js';
38
38
  import * as projects from './tools/projects.js';
39
39
  import * as notebook from './tools/notebook.js';
40
40
  import * as batchAnalyze from './tools/batch-analyze.js';
41
+ import * as suppliers from './tools/suppliers.js';
41
42
 
42
43
  // ----- Data source: LIVE cloud workspace OR a static export file -----
43
44
  // Cloud mode kicks in when a dedicated read-only MCP token is present (mint it
@@ -117,6 +118,7 @@ const TOOLS = [
117
118
  ...Object.values(library.tools),
118
119
  ...Object.values(projects.tools),
119
120
  ...Object.values(notebook.tools),
121
+ ...Object.values(suppliers.tools),
120
122
  ...Object.values(batchAnalyze.tools),
121
123
  ];
122
124
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.21",
3
+ "version": "0.6.23",
4
4
  "mcpName": "io.github.juliu1980/formlab-mcp",
5
- "description": "Read-only Model Context Protocol server for FormLab — lets Claude (and other MCP clients) read and analyze your FormLab data, from a local export file OR your live cloud workspace.",
5
+ "description": "Read-only Model Context Protocol server for FormLab \u2014 lets Claude (and other MCP clients) read and analyze your FormLab data, from a local export file OR your live cloud workspace.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "formlab-mcp": "./index.js"
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.juliu1980/formlab-mcp",
4
4
  "description": "Read-only MCP for FormLab — let Claude query your formulation lab. Free reads a local export; Pro connects to your live cloud workspace with a dedicated read-only token.",
5
- "version": "0.6.21",
5
+ "version": "0.6.22",
6
6
  "websiteUrl": "https://formvix.com/mcp",
7
7
  "repository": {
8
8
  "url": "https://github.com/juliu1980/FormLab",
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "formlab-mcp",
16
- "version": "0.6.21",
16
+ "version": "0.6.22",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -0,0 +1 @@
1
+ {"ref":"ufizhypibwqqpavhxdwt","name":"FormLab","organization_id":"pbplgvokbayhflgrvygl","organization_slug":"pbplgvokbayhflgrvygl"}
@@ -913,6 +913,7 @@ const get_stability = {
913
913
  },
914
914
  };
915
915
 
916
+ export { _evalPassFail };
916
917
  export const tools = {
917
918
  list_test_results,
918
919
  get_test_result,
@@ -79,6 +79,7 @@ function _trimIngredient(i) {
79
79
  // the recall/equivalence-relevant party; supplier is who you buy from.
80
80
  ...(Array.isArray(i.sources) && i.sources.length ? { sources: i.sources.map(s => ({
81
81
  id: s.id, supplier: s.supplier || '', manufacturer: s.manufacturer || '',
82
+ supplierId: s.supplierId || null, manufacturerId: s.manufacturerId || null, // db.suppliers ids (get_supplier)
82
83
  partNo: s.partNo || '', grade: s.grade || '', status: s.status || 'qualified',
83
84
  primary: !!s.primary,
84
85
  })) } : {}),
@@ -96,7 +97,7 @@ function _lotSourceInfo(l) {
96
97
  const ing = (db.ingredients || []).find(i => i && i.id === l.ingredientId);
97
98
  const s = (ing && Array.isArray(ing.sources)) ? ing.sources.find(x => x.id === l.sourceId) : null;
98
99
  if (!s) return null;
99
- return { sourceId: s.id, supplier: s.supplier || '', manufacturer: s.manufacturer || '', status: s.status || 'qualified' };
100
+ return { sourceId: s.id, supplier: s.supplier || '', manufacturer: s.manufacturer || '', supplierId: s.supplierId || null, manufacturerId: s.manufacturerId || null, status: s.status || 'qualified' };
100
101
  }
101
102
 
102
103
  const list_ingredients = {
@@ -197,7 +198,7 @@ const get_ingredient = {
197
198
  id: l.id, uid: l.uid, lotNumber: l.lotNumber || '',
198
199
  qtyRemaining: l.qtyRemaining, qtyReceived: l.qtyReceived, unit: l.unit || '',
199
200
  receivedDate: l.receivedDate || null, expiryDate: l.expiryDate || null,
200
- supplier: l.supplier || '', status: l.status || 'active',
201
+ supplier: l.supplier || '', supplierId: l.supplierId || null, status: l.status || 'active',
201
202
  sourceId: l.sourceId || null, source: _lotSourceInfo(l),
202
203
  }));
203
204
  return {
@@ -354,7 +355,7 @@ const list_lots = {
354
355
  ingredientId: l.ingredientId, ingredientName: nameById.get(l.ingredientId) || '',
355
356
  qtyRemaining: l.qtyRemaining, qtyReceived: l.qtyReceived, unit: l.unit || '',
356
357
  receivedDate: l.receivedDate || null, expiryDate: l.expiryDate || null,
357
- supplier: l.supplier || '', status: l.status || 'active',
358
+ supplier: l.supplier || '', supplierId: l.supplierId || null, status: l.status || 'active',
358
359
  // Approved source this lot was received from (supplier + manufacturer);
359
360
  // null for lots received before sources existed. Recalls hinge on the
360
361
  // manufacturer, so it is surfaced explicitly here.
package/tools/library.js CHANGED
@@ -12,6 +12,55 @@
12
12
  // ============================================================
13
13
 
14
14
  import { getStore, resolveById } from '../data.js';
15
+ import { _evalPassFail } from './analytics.js';
16
+
17
+ // ── Usage roll-ups (mirror the Usage tabs on the Panel / Method / Equipment
18
+ // detail pages) ────────────────────────────────────────────────────────
19
+ // Resolve a measurement's instrument the way the app does: named on the row →
20
+ // the parameter's Test Method equipment → the report's run default.
21
+ function _resolveInstrument(m, t, db) {
22
+ if (m.equipmentId || m.instrument) return { equipmentId: m.equipmentId || '', name: m.instrument || '', source: 'row' };
23
+ const pm = m.parameterId ? (db.parameters || []).find(p => p.id === m.parameterId)
24
+ : (db.parameters || []).find(p => _norm(p.name) === _norm(m.parameter));
25
+ if (pm && (pm.equipmentId || pm.equipment)) return { equipmentId: pm.equipmentId || '', name: pm.equipment || '', source: 'method' };
26
+ if (t && (t.equipmentId || t.instrument)) return { equipmentId: t.equipmentId || '', name: t.instrument || '', source: 'run' };
27
+ return { equipmentId: '', name: '', source: '' };
28
+ }
29
+ function _num(m) { const v = parseFloat(m && m.value); return Number.isFinite(v) ? v : null; }
30
+ function _mean(a) { return a.length ? a.reduce((x, y) => x + y, 0) / a.length : null; }
31
+ function _sd(a) { if (a.length < 2) return null; const mu = _mean(a); return Math.sqrt(a.reduce((acc, x) => acc + (x - mu) ** 2, 0) / (a.length - 1)); }
32
+ function _r(v, d = 3) { return v == null ? null : +v.toFixed(d); }
33
+ // Where a set of reports (optionally restricted to measurements passing
34
+ // `measFilter`) landed: totals + by-formula breakdown (top 10). `fails` uses the
35
+ // measurement's own acceptance criteria (panel spec inheritance is app-side).
36
+ function _usageRollup(db, tests, measFilter) {
37
+ const sById = new Map((db.samples || []).map(s => [s.id, s]));
38
+ const bById = new Map((db.batches || []).map(b => [b.id, b]));
39
+ const fById = new Map((db.formulations || []).map(f => [f.id, f]));
40
+ const date = t => String(t.testDate || (t.createdAt || '').slice(0, 10) || '');
41
+ const byF = new Map(); const forms = new Set(), batches = new Set(), samples = new Set(), labs = new Set();
42
+ let readings = 0, fails = 0, withSpec = 0, last = '';
43
+ tests.forEach(t => {
44
+ const s = sById.get(t.sampleId) || null; const b = s ? bById.get(s.batchId) : null;
45
+ const f = (s && s.formulationId) ? fById.get(s.formulationId) : (b ? fById.get(b.formulationId) : null);
46
+ if (s) samples.add(s.id); if (b) batches.add(b.id); if (f) forms.add(f.id); if (t.lab) labs.add(t.lab);
47
+ const d = date(t); if (d > last) last = d;
48
+ let tf = 0;
49
+ (t.measurements || []).filter(m => !measFilter || measFilter(m, t)).forEach(m => {
50
+ readings++;
51
+ const pf = _evalPassFail(m); if (pf !== 'no-criteria' && pf !== 'untested') withSpec++; if (pf === 'fail') { fails++; tf++; }
52
+ });
53
+ const key = f ? f.id : '(no formula)';
54
+ if (!byF.has(key)) byF.set(key, { formula: f ? (f.uid || f.name) : null, formulaName: f ? f.name : null, reports: 0, lastRun: '', fails: 0 });
55
+ const g = byF.get(key); g.reports++; if (d > g.lastRun) g.lastRun = d; g.fails += tf;
56
+ });
57
+ return {
58
+ reports: tests.length, readings, lastUsed: last || null,
59
+ formulas: forms.size, batches: batches.size, samples: samples.size, labs: labs.size,
60
+ fails, passRate: withSpec ? _r(100 * (withSpec - fails) / withSpec, 0) : null,
61
+ byFormula: [...byF.values()].sort((a, b) => b.reports - a.reports).slice(0, 10),
62
+ };
63
+ }
15
64
 
16
65
  const _norm = (s) => String(s == null ? '' : s).toLowerCase();
17
66
  const _clampLimit = (n) => Math.min(1000, Math.max(1, n || 100));
@@ -99,7 +148,7 @@ const list_equipment = {
99
148
  const get_equipment = {
100
149
  definition: {
101
150
  name: 'get_equipment',
102
- description: 'Get one equipment record by UID (e.g. EQP-001) or id, including a summary of everywhere it is referenced (test panels, step presets, test methods, formulas, batches).',
151
+ description: 'Get one equipment record by UID (e.g. EQP-001) or id, including `usedIn` (everywhere its DEFINITION is referenced: test panels, step presets, test methods, formula steps, batches, plus testReports = reports with a measurement resolved to it), `usage` (those reports: totals, last used, distinct formulas/batches/samples, pass rate, top formulas) and `measured` (per parameter: readings and mean on this unit vs readings/mean on all OTHER instruments, with biasPct — the calibration question). Instrument resolution follows the app: named on the measurement → the Test Method\'s equipment → the report\'s run default.',
103
152
  inputSchema: {
104
153
  type: 'object',
105
154
  properties: { id: { type: 'string', description: 'Equipment UID or internal id.' } },
@@ -110,7 +159,25 @@ const get_equipment = {
110
159
  const { db } = getStore();
111
160
  const e = resolveById('equipment', args.id);
112
161
  if (!e) return { error: `No equipment found for "${args.id}".` };
113
- return { ...(_trimEquipment(e)), usedIn: _equipUsage(e, db) };
162
+ // Measurements whose RESOLVED instrument is this unit (row → method → run).
163
+ const nm = _norm(e.name);
164
+ const mine = (m, t) => { const r = _resolveInstrument(m, t, db); return r.equipmentId ? r.equipmentId === e.id : (!!nm && _norm(r.name) === nm); };
165
+ const tests = (db.testResults || []).filter(t => (t.measurements || []).some(m => mine(m, t)));
166
+ const here = new Map(), fleet = new Map();
167
+ (db.testResults || []).forEach(t => (t.measurements || []).forEach(m => {
168
+ const pn = (m.parameter || '').trim(); if (!pn) return;
169
+ const v = _num(m); const isMine = mine(m, t); const bucket = isMine ? here : fleet;
170
+ if (!bucket.has(pn)) bucket.set(pn, { parameter: pn, readings: 0, vals: [], fails: 0 });
171
+ const g = bucket.get(pn); g.readings++; if (v != null) g.vals.push(v); if (isMine && _evalPassFail(m) === 'fail') g.fails++;
172
+ }));
173
+ const measured = [...here.values()].map(g => {
174
+ const f = fleet.get(g.parameter); const mh = _mean(g.vals), me = f ? _mean(f.vals) : null;
175
+ return { parameter: g.parameter, readings: g.readings, mean: _r(mh), fails: g.fails, readingsElsewhere: f ? f.readings : 0, meanElsewhere: _r(me),
176
+ biasPct: (mh != null && me != null && me !== 0) ? _r((mh - me) / Math.abs(me) * 100, 1) : null };
177
+ }).sort((a, b) => b.readings - a.readings);
178
+ const usedIn = _equipUsage(e, db);
179
+ usedIn.testReports = tests.length;
180
+ return { ...(_trimEquipment(e)), usedIn, usage: _usageRollup(db, tests, mine), measured };
114
181
  },
115
182
  };
116
183
 
@@ -187,7 +254,7 @@ const list_test_methods = {
187
254
  const get_test_method = {
188
255
  definition: {
189
256
  name: 'get_test_method',
190
- description: 'Get one Test Method by UID (e.g. PARAM-001) or id — full definition including SOP / sample prep / equipment / calculation / acceptance spec, the declared runConditions this method varies by (the vocabulary — e.g. Storage temp/RH, spindle RPM; each measurement captures a value per condition), plus sibling methods that share its analyte (the same property measured at other conditions) and the Test Panels that use this method.',
257
+ description: 'Get one Test Method by UID (e.g. PARAM-001) or id — full definition including SOP / sample prep / equipment / calculation / acceptance spec, the declared runConditions this method varies by (the vocabulary — e.g. Storage temp/RH, spindle RPM; each measurement captures a value per condition), sibling methods that share its analyte, the Test Panels that use it, plus `usage` (reports / readings linked to this method: totals, last used, distinct formulas/batches/samples, pass rate, top formulas), `valueSummary` (mean / sd / min / max over numeric readings) and `byInstrument` (readings, mean and fails per resolved instrument — a mean that differs between units is a calibration question).',
191
258
  inputSchema: {
192
259
  type: 'object',
193
260
  properties: { id: { type: 'string', description: 'Test Method UID or internal id.' } },
@@ -208,7 +275,19 @@ const get_test_method = {
208
275
  (row.parameterId && row.parameterId === p.id) ||
209
276
  (!row.parameterId && _norm(row.parameter || row.name) === nm)
210
277
  )).map(t => t.uid || t.name).slice(0, 40);
211
- return { ...(_trimMethod(p, { full: true })), analyteSiblings: siblings, usingPanels };
278
+ // Usage = every measurement linked to this method (by id, else by exact name).
279
+ const match = (m) => (m.parameterId && m.parameterId === p.id) || (!m.parameterId && _norm(m.parameter) === nm);
280
+ const tests = (db.testResults || []).filter(r => (r.measurements || []).some(match));
281
+ const vals = [], byInst = new Map();
282
+ tests.forEach(r => (r.measurements || []).filter(match).forEach(m => {
283
+ const v = _num(m); if (v != null) vals.push(v);
284
+ const inst = _resolveInstrument(m, r, db); const key = inst.name || inst.equipmentId || '(unresolved)';
285
+ if (!byInst.has(key)) byInst.set(key, { instrument: key, readings: 0, vals: [], fails: 0 });
286
+ const g = byInst.get(key); g.readings++; if (v != null) g.vals.push(v); if (_evalPassFail(m) === 'fail') g.fails++;
287
+ }));
288
+ const byInstrument = [...byInst.values()].map(g => ({ instrument: g.instrument, readings: g.readings, mean: _r(_mean(g.vals)), fails: g.fails })).sort((a, b) => b.readings - a.readings);
289
+ const valueSummary = { readings: vals.length, mean: _r(_mean(vals)), sd: _r(_sd(vals)), min: vals.length ? Math.min(...vals) : null, max: vals.length ? Math.max(...vals) : null, unit: p.defaultUnit || '' };
290
+ return { ...(_trimMethod(p, { full: true })), analyteSiblings: siblings, usingPanels, usage: _usageRollup(db, tests, match), valueSummary, byInstrument };
212
291
  },
213
292
  };
214
293
 
@@ -356,7 +435,7 @@ const list_test_panels = {
356
435
  const get_test_panel = {
357
436
  definition: {
358
437
  name: 'get_test_panel',
359
- description: 'Get one Test Panel by UID (e.g. TMPL-001) or id — full ordered parameter list (each with its resolved Test Method UID, unit, and spec), instruments, and notes.',
438
+ description: 'Get one Test Panel by UID (e.g. TMPL-001) or id — full ordered parameter list (each with its resolved Test Method UID, unit, and spec), instruments, notes, plus `usage` (reports created from this panel: totals, last used, distinct formulas/batches/samples, pass rate, top formulas) and `performanceByParameter` (readings / fails per parameter). Usage counts reports created from the panel, not parameter overlap.',
360
439
  inputSchema: {
361
440
  type: 'object',
362
441
  properties: { id: { type: 'string', description: 'Test Panel UID or internal id.' } },
@@ -367,7 +446,19 @@ const get_test_panel = {
367
446
  const { db } = getStore();
368
447
  const t = resolveById('templates', args.id);
369
448
  if (!t) return { error: `No test panel found for "${args.id}".` };
370
- return _trimPanel(t, db, { full: true });
449
+ // Usage = reports created FROM this panel (templateId) — not parameter overlap.
450
+ const tests = (db.testResults || []).filter(r => r.templateId === t.id);
451
+ const perf = (t.parameters || []).map(row => {
452
+ const nm = _norm(row.parameter || row.name), pid = row.parameterId || '';
453
+ let n = 0, fails = 0, withSpec = 0;
454
+ tests.forEach(r => (r.measurements || []).forEach(m => {
455
+ const hit = (pid && m.parameterId === pid) || ((!m.parameterId || !pid) && _norm(m.parameter) === nm);
456
+ if (!hit) return; n++;
457
+ const pf = _evalPassFail(m); if (pf !== 'no-criteria' && pf !== 'untested') withSpec++; if (pf === 'fail') fails++;
458
+ }));
459
+ return { parameter: row.parameter || row.name, readings: n, fails, failRatePct: withSpec ? _r(100 * fails / withSpec, 0) : null };
460
+ });
461
+ return { ...(_trimPanel(t, db, { full: true })), usage: _usageRollup(db, tests, null), performanceByParameter: perf };
371
462
  },
372
463
  };
373
464
 
@@ -0,0 +1,187 @@
1
+ // =====================================================================
2
+ // SUPPLIERS — list_suppliers / get_supplier (traceability Phase 2d).
3
+ // Mirrors js/views/suppliers.js: one record per supplier / manufacturer;
4
+ // references are matched by supplierId / manufacturerId, falling back to a
5
+ // normalised name match for records stamped before entities existed. Lots
6
+ // count for a supplier both when bought from it AND when the approved source
7
+ // names it as the manufacturer — a maker's recall must reach lots bought
8
+ // through a distributor. Read-only, like every tool here.
9
+ // =====================================================================
10
+ import { getStore, resolveById } from '../data.js';
11
+
12
+ const SUFFIX_RE = /\b(inc|incorporated|ltd|limited|llc|gmbh|co|corp|corporation|company|ag|sa|plc|bv|nv|srl|s\.?a\.?|pty)\b\.?$/i;
13
+ function norm(name) {
14
+ let s = String(name == null ? '' : name).toLowerCase().replace(/\s+/g, ' ').trim().replace(/[\s,.\-]+$/g, '');
15
+ const stripped = s.replace(SUFFIX_RE, '').replace(/[\s,.\-]+$/g, '').trim();
16
+ return stripped || s;
17
+ }
18
+ function live(x) { return x && !x._trashed && !x.deletedAt; }
19
+ function resolveLotSource(lot, ing) {
20
+ if (!lot || !ing) return null;
21
+ const list = Array.isArray(ing.sources) ? ing.sources : [];
22
+ if (lot.sourceId) { const s = list.find(x => x && x.id === lot.sourceId); if (s) return s; }
23
+ const k = norm(lot.supplier); if (!k) return null;
24
+ return list.find(s => s && (norm(s.supplier) === k || norm(s.manufacturer) === k)) || null;
25
+ }
26
+ // Everything that points at a supplier record.
27
+ function refsOf(db, sup) {
28
+ const id = sup.id, key = norm(sup.name);
29
+ const hits = (sid, name) => (sid && sid === id) || (!sid && key && norm(name) === key);
30
+ const ingredients = [], sources = [], lots = [];
31
+ (db.ingredients || []).forEach(ing => {
32
+ if (!live(ing)) return;
33
+ let hit = hits(ing.supplierId, ing.supplier);
34
+ (ing.sources || []).forEach(s => {
35
+ if (!s) return;
36
+ const asSup = hits(s.supplierId, s.supplier), asMan = hits(s.manufacturerId, s.manufacturer);
37
+ if (asSup || asMan) { sources.push({ ing, src: s, role: asSup && asMan ? 'both' : (asSup ? 'supplier' : 'manufacturer') }); hit = true; }
38
+ });
39
+ if (hit) ingredients.push(ing);
40
+ });
41
+ (db.lots || []).forEach(lot => {
42
+ if (!live(lot)) return;
43
+ const ing = (db.ingredients || []).find(i => i.id === lot.ingredientId);
44
+ let hit = hits(lot.supplierId, lot.supplier);
45
+ if (!hit && lot.sourceId && ing) { const s = resolveLotSource(lot, ing); if (s && (hits(s.supplierId, s.supplier) || hits(s.manufacturerId, s.manufacturer))) hit = true; }
46
+ if (hit) lots.push({ lot, ing });
47
+ });
48
+ return { ingredients, sources, lots };
49
+ }
50
+ function consumersOfLot(moves, lotId) {
51
+ const map = Object.create(null);
52
+ for (const m of (moves || [])) {
53
+ if (!m || m.lotId !== lotId) continue;
54
+ const d = parseFloat(m.delta); if (!isFinite(d) || d >= 0) continue;
55
+ const key = m.batchId ? ('batch ' + m.batchId) : m.sampleId ? ('sample ' + m.sampleId) : 'other ';
56
+ map[key] = +((map[key] || 0) + (-d)).toFixed(6);
57
+ }
58
+ return Object.keys(map).map(k => { const i = k.indexOf(' '); return { kind: k.slice(0, i), id: k.slice(i + 1) || null, qty: map[k] }; });
59
+ }
60
+ function rowOf(db, sup) {
61
+ const refs = refsOf(db, sup);
62
+ const qs = { qualified: 0, trial: 0, disqualified: 0 };
63
+ refs.sources.forEach(x => { const st = x.src.status || 'qualified'; if (qs[st] != null) qs[st]++; });
64
+ let onHandLots = 0, onHandValue = null, valueCcy = null, lastReceived = '';
65
+ refs.lots.forEach(({ lot }) => {
66
+ const rem = parseFloat(lot.qtyRemaining), cost = parseFloat(lot.unitCost);
67
+ if (rem > 0) onHandLots++;
68
+ if (rem > 0 && isFinite(cost)) { const ccy = lot.unitCostCcy || 'USD'; if (!valueCcy || valueCcy === ccy) { valueCcy = ccy; onHandValue = (onHandValue || 0) + rem * cost; } }
69
+ if (lot.receivedDate && lot.receivedDate > lastReceived) lastReceived = lot.receivedDate;
70
+ });
71
+ return {
72
+ id: sup.id, uid: sup.uid || '', name: sup.name || '', kind: sup.kind || 'supplier',
73
+ country: sup.country || '', contact: sup.contact || '', email: sup.email || '', phone: sup.phone || '', website: sup.website || '',
74
+ ingredientCount: refs.ingredients.length, lotCount: refs.lots.length, lotsOnHand: onHandLots,
75
+ onHandValue: onHandValue == null ? null : +onHandValue.toFixed(2), onHandValueCcy: onHandValue == null ? null : valueCcy,
76
+ qualifiedSources: qs.qualified, trialSources: qs.trial, disqualifiedSources: qs.disqualified,
77
+ lastReceived: lastReceived ? lastReceived.slice(0, 10) : null,
78
+ _refs: refs,
79
+ };
80
+ }
81
+
82
+ const list_suppliers = {
83
+ definition: {
84
+ name: 'list_suppliers',
85
+ description: 'List supplier / manufacturer records with roll-ups: how many ingredients name each one as an approved source, lots received (and still on hand, with on-hand value at paid cost), qualified / trial / disqualified source counts, and last receipt date. Lots count for a supplier both when bought from it and when it is the MANUFACTURER behind a distributor. Filter by kind, country, stock on hand, open trials, or a name/ingredient search.',
86
+ inputSchema: {
87
+ type: 'object',
88
+ properties: {
89
+ kind: { type: 'string', description: '"supplier", "manufacturer" or "both".' },
90
+ country: { type: 'string', description: 'Case-insensitive country match.' },
91
+ with_stock: { type: 'boolean', description: 'Only suppliers with at least one lot still on hand.' },
92
+ has_trial: { type: 'boolean', description: 'Only suppliers with a source still on trial (qualification pending).' },
93
+ has_disqualified: { type: 'boolean', description: 'Only suppliers with a disqualified source.' },
94
+ search: { type: 'string', description: 'Substring match on supplier name, country, contact — or the name of an ingredient it supplies.' },
95
+ limit: { type: 'number', description: 'Max rows (default 200, max 1000).' },
96
+ },
97
+ },
98
+ },
99
+ handler: async (args) => {
100
+ const { db } = getStore();
101
+ const limit = Math.min(1000, Math.max(1, args.limit || 200));
102
+ const q = (args.search || '').trim().toLowerCase();
103
+ const rows = (db.suppliers || []).filter(live).map(s => rowOf(db, s)).filter(r => {
104
+ if (args.kind && String(r.kind).toLowerCase() !== String(args.kind).toLowerCase()) return false;
105
+ if (args.country && String(r.country).toLowerCase() !== String(args.country).toLowerCase()) return false;
106
+ if (args.with_stock && !(r.lotsOnHand > 0)) return false;
107
+ if (args.has_trial && !(r.trialSources > 0)) return false;
108
+ if (args.has_disqualified && !(r.disqualifiedSources > 0)) return false;
109
+ if (q) {
110
+ const hay = [r.name, r.uid, r.country, r.contact, r.email, ...r._refs.ingredients.map(i => i.name || '')].join(' ').toLowerCase();
111
+ if (!hay.includes(q)) return false;
112
+ }
113
+ return true;
114
+ }).sort((a, b) => a.name.localeCompare(b.name));
115
+ return { totalMatching: rows.length, returned: Math.min(rows.length, limit), suppliers: rows.slice(0, limit).map(({ _refs, ...r }) => r) };
116
+ },
117
+ };
118
+
119
+ const get_supplier = {
120
+ definition: {
121
+ name: 'get_supplier',
122
+ description: 'One supplier / manufacturer in full: the record (kind, country, address, contact), every ingredient it is an approved source for (role supplier/manufacturer, part #, grade, qualification status, primary), every lot received from it (matched by supplier AND by manufacturer), and — with include_trace — the recall trace: the batches that consumed those lots, the finished goods, shipments, samples and sub-batches downstream, plus any lots on hold. Use for "what did we make from supplier X?" and recall questions.',
123
+ inputSchema: {
124
+ type: 'object',
125
+ properties: {
126
+ id: { type: 'string', description: 'Supplier internal id, UID (e.g. SUP-0003), or exact name.' },
127
+ include_trace: { type: 'boolean', description: 'Add the downstream recall trace (batches, products, shipments, samples). Default true.' },
128
+ },
129
+ required: ['id'],
130
+ },
131
+ },
132
+ handler: async (args) => {
133
+ const { db } = getStore();
134
+ let sup = resolveById('suppliers', args.id);
135
+ if (!sup) { const k = norm(args.id); sup = (db.suppliers || []).find(s => live(s) && norm(s.name) === k) || null; }
136
+ if (!sup) return { error: `No supplier found for "${args.id}".` };
137
+ const row = rowOf(db, sup); const refs = row._refs; delete row._refs;
138
+ const nowMs = Date.now();
139
+ const sources = refs.sources.map(x => ({ ingredientId: x.ing.id, ingredientUid: x.ing.uid || '', ingredient: x.ing.name || '', role: x.role,
140
+ partNo: x.src.partNo || '', grade: x.src.grade || '', status: x.src.status || 'qualified', primary: !!x.src.primary, qualifiedDate: x.src.qualifiedDate || null }));
141
+ const legacyOnly = refs.ingredients.filter(i => !refs.sources.some(x => x.ing.id === i.id)).map(i => ({ ingredientId: i.id, ingredientUid: i.uid || '', ingredient: i.name || '', role: 'supplier', note: 'ingredient supplier field only — no approved-source row' }));
142
+ const lots = refs.lots.map(({ lot, ing }) => {
143
+ const days = lot.expiryDate ? Math.floor((Date.parse(lot.expiryDate) - nowMs) / 86400000) : null;
144
+ return { id: lot.id, uid: lot.uid || '', lotNumber: lot.lotNumber || '', ingredientId: lot.ingredientId, ingredient: ing ? ing.name : '',
145
+ qtyRemaining: lot.qtyRemaining, qtyReceived: lot.qtyReceived, unit: lot.unit || '', receivedDate: lot.receivedDate || null, expiryDate: lot.expiryDate || null,
146
+ daysToExpiry: isFinite(days) ? days : null, status: lot.status || 'active', holdReason: lot.holdReason || '', unitCost: (lot.unitCost != null && lot.unitCost !== '') ? lot.unitCost : null, unitCostCcy: lot.unitCostCcy || null };
147
+ });
148
+ const out = { supplier: { ...row, address: sup.address || '', notes: sup.notes || '', createdAt: sup.createdAt || null, updatedAt: sup.updatedAt || null }, approvedFor: sources.concat(legacyOnly), lots };
149
+ if (args.include_trace === false) return out;
150
+ // Recall trace
151
+ const moves = db.stockMovements || [];
152
+ const batchMap = new Map(); const directSamples = [];
153
+ refs.lots.forEach(({ lot, ing }) => {
154
+ consumersOfLot(moves, lot.id).forEach(c => {
155
+ if (c.kind === 'batch' && c.id) {
156
+ const b = (db.batches || []).find(x => x.id === c.id) || null;
157
+ if (!batchMap.has(c.id)) {
158
+ const f = b ? ((db.formulations || []).find(x => x.id === b.formulationId) || null) : null;
159
+ const ledger = (b && Array.isArray(b.stockLedger)) ? b.stockLedger : [];
160
+ batchMap.set(c.id, { id: c.id, uid: b ? (b.uid || '') : '', deleted: !b, product: f ? f.name : '', formulationId: b ? b.formulationId : null,
161
+ preparedDate: b ? (b.preparedDate || null) : null, status: b ? (b.status || '') : '', lotsConsumed: [],
162
+ shipments: ledger.filter(e => e.kind === 'shipment').map(e => ({ date: e.ts || null, qty: Math.abs(parseFloat(e.delta) || 0), unit: e.unit || (b && b.unit) || '', note: e.note || '' })),
163
+ samples: b ? (db.samples || []).filter(sm => live(sm) && sm.batchId === b.id).map(sm => ({ id: sm.id, uid: sm.uid || '', name: sm.name || '', status: sm.status || '' })) : [],
164
+ subBatches: b ? (db.batches || []).filter(x => live(x) && x.parentBatchId === b.id).map(x => ({ id: x.id, uid: x.uid || '', status: x.status || '' })) : [] });
165
+ }
166
+ batchMap.get(c.id).lotsConsumed.push({ lotId: lot.id, lotNumber: lot.lotNumber || lot.uid || '', ingredient: ing ? ing.name : '', qty: c.qty, unit: lot.unit || '' });
167
+ } else if (c.kind === 'sample' && c.id) {
168
+ const sm = (db.samples || []).find(x => x.id === c.id) || null;
169
+ directSamples.push({ id: c.id, uid: sm ? (sm.uid || '') : '', name: sm ? (sm.name || '') : '', deleted: !sm, fromLot: lot.lotNumber || lot.uid || '', qty: c.qty, unit: lot.unit || '' });
170
+ }
171
+ });
172
+ });
173
+ const batches = [...batchMap.values()].sort((a, b) => String(b.preparedDate || '').localeCompare(String(a.preparedDate || '')));
174
+ const products = []; batches.forEach(x => { if (x.formulationId && !products.some(p => p.formulationId === x.formulationId)) products.push({ formulationId: x.formulationId, product: x.product, batches: batches.filter(y => y.formulationId === x.formulationId).length }); });
175
+ const held = lots.filter(l => ['hold', 'quarantine', 'rejected'].includes(l.status));
176
+ out.trace = {
177
+ summary: { lots: lots.length, lotsOnHand: lots.filter(l => parseFloat(l.qtyRemaining) > 0).length, batches: batches.length, products: products.length,
178
+ shipments: batches.reduce((n, x) => n + x.shipments.length, 0), samples: batches.reduce((n, x) => n + x.samples.length, 0) + directSamples.length,
179
+ subBatches: batches.reduce((n, x) => n + x.subBatches.length, 0), lotsOnHold: held.length },
180
+ recallSignal: held.length ? `${held.length} lot(s) from this supplier are on hold / blocked: ${held.map(l => l.lotNumber || l.uid).join(', ')} — every batch below that consumed them is implicated.` : null,
181
+ batches, products, directSamples,
182
+ };
183
+ return out;
184
+ },
185
+ };
186
+
187
+ export const tools = { list_suppliers, get_supplier };