formlab-mcp 0.6.20 → 0.6.22
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 +3 -1
- package/index.js +2 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/supabase/.temp/linked-project.json +1 -0
- package/tools/ingredients.js +8 -3
- package/tools/suppliers.js +187 -0
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) |
|
|
@@ -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
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.22",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
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.",
|
|
6
6
|
"type": "module",
|
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.
|
|
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.
|
|
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"}
|
package/tools/ingredients.js
CHANGED
|
@@ -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
|
})) } : {}),
|
|
@@ -89,10 +90,14 @@ function _trimIngredient(i) {
|
|
|
89
90
|
// ingredient's ing.sources[]). Null when the lot predates sources or is unlinked.
|
|
90
91
|
function _lotSourceInfo(l) {
|
|
91
92
|
if (!l || !l.sourceId) return null;
|
|
93
|
+
// `db` is NOT module-scoped here — handlers pull it from getStore() — so do the
|
|
94
|
+
// same. (0.6.20 shipped a bare `db` reference; it was masked by the guard above
|
|
95
|
+
// until the first lot with a sourceId existed, then threw "db is not defined".)
|
|
96
|
+
const { db } = getStore();
|
|
92
97
|
const ing = (db.ingredients || []).find(i => i && i.id === l.ingredientId);
|
|
93
98
|
const s = (ing && Array.isArray(ing.sources)) ? ing.sources.find(x => x.id === l.sourceId) : null;
|
|
94
99
|
if (!s) return null;
|
|
95
|
-
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' };
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
const list_ingredients = {
|
|
@@ -193,7 +198,7 @@ const get_ingredient = {
|
|
|
193
198
|
id: l.id, uid: l.uid, lotNumber: l.lotNumber || '',
|
|
194
199
|
qtyRemaining: l.qtyRemaining, qtyReceived: l.qtyReceived, unit: l.unit || '',
|
|
195
200
|
receivedDate: l.receivedDate || null, expiryDate: l.expiryDate || null,
|
|
196
|
-
supplier: l.supplier || '', status: l.status || 'active',
|
|
201
|
+
supplier: l.supplier || '', supplierId: l.supplierId || null, status: l.status || 'active',
|
|
197
202
|
sourceId: l.sourceId || null, source: _lotSourceInfo(l),
|
|
198
203
|
}));
|
|
199
204
|
return {
|
|
@@ -350,7 +355,7 @@ const list_lots = {
|
|
|
350
355
|
ingredientId: l.ingredientId, ingredientName: nameById.get(l.ingredientId) || '',
|
|
351
356
|
qtyRemaining: l.qtyRemaining, qtyReceived: l.qtyReceived, unit: l.unit || '',
|
|
352
357
|
receivedDate: l.receivedDate || null, expiryDate: l.expiryDate || null,
|
|
353
|
-
supplier: l.supplier || '', status: l.status || 'active',
|
|
358
|
+
supplier: l.supplier || '', supplierId: l.supplierId || null, status: l.status || 'active',
|
|
354
359
|
// Approved source this lot was received from (supplier + manufacturer);
|
|
355
360
|
// null for lots received before sources existed. Recalls hinge on the
|
|
356
361
|
// manufacturer, so it is surfaced explicitly here.
|
|
@@ -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 };
|