dsh-plugin-lcsc 0.0.0-stage → 0.3.1

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/index.js ADDED
@@ -0,0 +1,2139 @@
1
+ /**
2
+ * dsh-plugin-lcsc — requirement-driven LCSC part finder and BOM cost auditor
3
+ * for the DeepSeek Harness (DSH) Host.
4
+ *
5
+ * Two LLM-callable tools, deliberately kept small so the plugin does not crowd
6
+ * the model's context when several plugins are installed:
7
+ *
8
+ * - find_lcsc_parts requirement -> ranked candidates (price / stock /
9
+ * basic-vs-extended library / parameter match)
10
+ * - audit_lcsc_bom existing BOM -> cost, stock, library-type and
11
+ * JLCPCB extended-part feeder-fee audit
12
+ *
13
+ * The HTTP/cache/normalisation layer lives in `data.js`.
14
+ *
15
+ * Design rules:
16
+ * - hand-soldering is the default (`assembly: "hand"`): most DIY users buy
17
+ * parts and solder themselves, and SMT fee rules change over time.
18
+ * - `assembly: "smt"` opts into library-type awareness: JLCPCB charges
19
+ * USD 3 per extended part number per order (feeder loading), base library
20
+ * parts are free, so the fee is amortised into the unit price.
21
+ * - prices are never converted between currencies; each price carries its
22
+ * currency and source.
23
+ */
24
+
25
+ import {
26
+ createLcscService,
27
+ normalizeConfig,
28
+ normalizeCode,
29
+ parseBomCsvDetailed,
30
+ pickTierPrice,
31
+ } from './data.js'
32
+
33
+ /** Plugin id used by the DSH loader. */
34
+ export const name = 'lcsc-parts'
35
+ /** Required services. */
36
+ export const inject = ['tools']
37
+
38
+ /** Default JLCPCB extended-part feeder loading fee, USD per unique part per order. */
39
+ export const EXTENDED_PART_FEE_USD = 3
40
+ /** Query-wide caps that keep a single tool call fast and cheap. */
41
+ const MAX_CANDIDATES = 8
42
+ const DEFAULT_LIMIT = 5
43
+ const SEARCH_PAGE_SIZE = 100
44
+ const MAX_CNY_LOOKUPS = 3
45
+ const MAX_AUDIT_CNY_LOOKUPS = 32
46
+ const MAX_ALTERNATIVE_LOOKUPS = 16
47
+
48
+ /* -------------------------------------------------------------------------- */
49
+ /* Small helpers */
50
+ /* -------------------------------------------------------------------------- */
51
+
52
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
53
+ function isRecord(value) {
54
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
55
+ }
56
+
57
+ /** @param {unknown} value @returns {string | undefined} */
58
+ function asString(value) {
59
+ if (typeof value !== 'string') return undefined
60
+ const text = value.trim()
61
+ return text === '' ? undefined : text
62
+ }
63
+
64
+ /** @param {unknown} value @returns {number | undefined} */
65
+ function asNumber(value) {
66
+ if (typeof value === 'number' && Number.isFinite(value)) return value
67
+ if (typeof value === 'string' && value.trim() !== '') {
68
+ const parsed = Number(value)
69
+ if (Number.isFinite(parsed)) return parsed
70
+ }
71
+ return undefined
72
+ }
73
+
74
+ /** @param {unknown} value @returns {number | undefined} */
75
+ function asInteger(value) {
76
+ const number = asNumber(value)
77
+ if (number === undefined || !Number.isInteger(number)) return undefined
78
+ return number
79
+ }
80
+
81
+ /** @param {unknown} value @param {number} min @param {number} max @param {number} fallback */
82
+ function clampInteger(value, min, max, fallback) {
83
+ const number = asInteger(value)
84
+ if (number === undefined) return fallback
85
+ return Math.min(max, Math.max(min, number))
86
+ }
87
+
88
+ /** Round money-like values to avoid float noise in the tool output. */
89
+ function money(value) {
90
+ return Math.round(value * 1e6) / 1e6
91
+ }
92
+
93
+ /**
94
+ * DSH validates tool results for lossless JSON: plain `undefined` properties,
95
+ * NaN and Infinity are rejected. Normalise any result before returning it.
96
+ * @param {unknown} value
97
+ * @returns {unknown}
98
+ */
99
+ function toLossless(value) {
100
+ if (value === undefined || value === null) return null
101
+ if (typeof value === 'number') {
102
+ if (!Number.isFinite(value)) return null
103
+ return Object.is(value, -0) ? 0 : value
104
+ }
105
+ if (typeof value === 'string' || typeof value === 'boolean') return value
106
+ if (Array.isArray(value)) return value.map((entry) => toLossless(entry))
107
+ if (typeof value === 'object') {
108
+ /** @type {Record<string, unknown>} */
109
+ const output = {}
110
+ for (const [key, entry] of Object.entries(value)) {
111
+ if (entry === undefined) continue
112
+ output[key] = toLossless(entry)
113
+ }
114
+ return output
115
+ }
116
+ return null
117
+ }
118
+
119
+ /**
120
+ * USD unit price of the highest-volume ladder tier (bulk / full-reel price).
121
+ * The Recording_card calibration relates the LCSC retail CNY price to this
122
+ * bulk-tier USD price (× 6.0), not to the small-quantity tier.
123
+ * @param {Record<string, unknown>} item
124
+ * @returns {number | undefined}
125
+ */
126
+ export function bulkTierUsd(item) {
127
+ const tiers = Array.isArray(item.priceUsdTiers) ? item.priceUsdTiers : []
128
+ /** @type {{ qty: number, unit: number } | undefined} */
129
+ let best
130
+ for (const tier of tiers) {
131
+ if (!isRecord(tier)) continue
132
+ const qty = asNumber(tier.qty)
133
+ const unit = asNumber(tier.unit)
134
+ if (qty === undefined || unit === undefined) continue
135
+ if (best === undefined || qty > best.qty) best = { qty, unit }
136
+ }
137
+ return best?.unit
138
+ }
139
+
140
+ /**
141
+ * Reference CNY estimate = bulk-tier USD × empirical factor.
142
+ * @param {number | undefined} bulkUsd
143
+ * @param {number | undefined} factor
144
+ * @returns {number | undefined}
145
+ */
146
+ export function estimateCnyFromUsd(bulkUsd, factor) {
147
+ const value = asNumber(bulkUsd)
148
+ const calibration = asNumber(factor)
149
+ if (value === undefined || calibration === undefined) return undefined
150
+ return money(value * calibration)
151
+ }
152
+
153
+ /** @param {number} ms @param {AbortSignal} [signal] */
154
+ function sleep(ms, signal) {
155
+ if (signal?.aborted === true) return Promise.reject(new Error('aborted'))
156
+ return new Promise((resolve, reject) => {
157
+ const timer = setTimeout(() => {
158
+ signal?.removeEventListener?.('abort', onAbort)
159
+ resolve()
160
+ }, ms)
161
+ function onAbort() {
162
+ clearTimeout(timer)
163
+ reject(new Error('aborted'))
164
+ }
165
+ signal?.addEventListener?.('abort', onAbort, { once: true })
166
+ })
167
+ }
168
+
169
+ /**
170
+ * Run `fn` over items with bounded concurrency; results keep input order.
171
+ * @template T, R
172
+ * @param {T[]} items
173
+ * @param {number} limit
174
+ * @param {(item: T, index: number) => Promise<R>} fn
175
+ * @returns {Promise<R[]>}
176
+ */
177
+ async function mapLimit(items, limit, fn) {
178
+ const results = new Array(items.length)
179
+ let cursor = 0
180
+ const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
181
+ while (cursor < items.length) {
182
+ const index = cursor
183
+ cursor += 1
184
+ results[index] = await fn(items[index], index)
185
+ }
186
+ })
187
+ await Promise.all(workers)
188
+ return results
189
+ }
190
+
191
+ /* -------------------------------------------------------------------------- */
192
+ /* Requirement parsing */
193
+ /* -------------------------------------------------------------------------- */
194
+
195
+ /** Chinese / mixed category words mapped to English search hints (longest first). */
196
+ const CATEGORY_TERMS = [
197
+ ['自恢复保险丝', ['ptc resettable fuse']],
198
+ ['贴片电容', ['capacitor']],
199
+ ['贴片电阻', ['resistor']],
200
+ ['发光二极管', ['led']],
201
+ ['锂电池充电', ['battery charger']],
202
+ ['单节锂电池', ['battery charger']],
203
+ ['充电ic', ['battery charger']],
204
+ ['充电 ic', ['battery charger']],
205
+ ['充电芯片', ['battery charger']],
206
+ ['静态保护', ['esd']],
207
+ ['静电保护', ['esd']],
208
+ ['硅麦', ['microphone']],
209
+ ['mems硅麦', ['microphone']],
210
+ ['mems 硅麦', ['microphone']],
211
+ ['mems麦克风', ['microphone']],
212
+ ['mems 麦克风', ['microphone']],
213
+ ['type-c母座', ['usb type-c receptacle']],
214
+ ['type-c 母座', ['usb type-c receptacle']],
215
+ ['type c母座', ['usb type-c receptacle']],
216
+ ['母座', ['receptacle']],
217
+ ['贴片', []],
218
+ ['电容', ['capacitor']],
219
+ ['电阻', ['resistor']],
220
+ ['电感', ['inductor']],
221
+ ['磁珠', ['ferrite bead']],
222
+ ['二极管', ['diode']],
223
+ ['肖特基', ['schottky']],
224
+ ['三极管', ['transistor']],
225
+ ['场效应管', ['mosfet']],
226
+ ['mos管', ['mosfet']],
227
+ ['稳压器', ['ldo', 'voltage regulator']],
228
+ ['ldo', ['ldo']],
229
+ ['连接器', ['connector']],
230
+ ['插座', ['connector']],
231
+ ['排针', ['pin header']],
232
+ ['排母', ['pin header']],
233
+ ['按键', ['tactile switch']],
234
+ ['轻触开关', ['tactile switch']],
235
+ ['开关', ['switch']],
236
+ ['晶振', ['crystal']],
237
+ ['谐振器', ['resonator']],
238
+ ['保险丝', ['fuse']],
239
+ ['麦克风', ['microphone']],
240
+ ['咪头', ['microphone']],
241
+ ['蜂鸣器', ['buzzer']],
242
+ ['电池座', ['battery holder']],
243
+ ['电池连接器', ['battery connector']],
244
+ ['光耦', ['optocoupler']],
245
+ ['运放', ['operational amplifier']],
246
+ ['比较器', ['comparator']],
247
+ ['单片机', ['microcontroller']],
248
+ ['存储器', ['memory']],
249
+ ['电平转换', ['level shifter']],
250
+ ['tvs', ['tvs']],
251
+ ['esd', ['esd']],
252
+ ['type-c', ['type-c']],
253
+ ['tf卡座', ['microsd']],
254
+ ['sd卡座', ['microsd']],
255
+ ['microsd', ['microsd']],
256
+ ['usb', ['usb']],
257
+ ]
258
+
259
+ /** A single token that is a value+unit (e.g. "100nF", "3.3V", "10kΩ") is not a part number. */
260
+ const VALUE_UNIT_TOKEN_RE =
261
+ /^\d+(?:\.\d+)?\s*(?:pf|nf|uf|µf|mf|f|v|kv|ma|ua|µa|a|w|mw|ω|kω|mω|ohm|kohm|mm|nh|uh|µh|mh|hz|khz|mhz|ghz|k|r|m|%|p|pin|pins|针|脚)$/i
262
+
263
+ /** Normalised model string used for exact-model matching. */
264
+ export function normalizeModelToken(text) {
265
+ return String(text ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '')
266
+ }
267
+
268
+ /**
269
+ * Detect an exact part query (LCSC code or manufacturer part number) before any
270
+ * numeric parameter parsing. This is the fix for "SMF5.0A" being read as
271
+ * current>=5A / "TP4054" as 4054: those strings are part numbers, not values.
272
+ * @param {string} text
273
+ * @returns {{ type: 'code' | 'model', value: string, token?: string } | undefined}
274
+ */
275
+ export function detectExactQuery(text) {
276
+ const raw = String(text ?? '').trim()
277
+ if (raw === '') return undefined
278
+ const wholeCode = raw.match(/^C\d{4,11}$/i)
279
+ if (wholeCode !== null) return { type: 'code', value: wholeCode[0].toUpperCase() }
280
+ const tokens = raw.split(/[\s,;,;、]+/).filter((token) => token !== '')
281
+ if (tokens.length === 0) return undefined
282
+ // Only treat the input as a part number when it is a single token, or when
283
+ // one token is a strong part number and the rest are category words. Mixed
284
+ // requirement strings ("22 ohm 0402 resistor") must keep their value parsing.
285
+ const strong = []
286
+ for (const token of tokens) {
287
+ const clean = token.replace(/^[\(("'`]+|[)'"`”)]+$/g, '')
288
+ if (clean.length < 4) continue
289
+ if (/^C\d{4,11}$/i.test(clean)) return { type: 'code', value: clean.toUpperCase() }
290
+ if (VALUE_UNIT_TOKEN_RE.test(clean)) continue
291
+ if (extractPackage(clean) === clean.toUpperCase()) continue
292
+ if (!/^[A-Za-z0-9._/+\-]+$/.test(clean)) continue
293
+ if (!/\d/.test(clean) || !/[A-Za-z]/.test(clean)) continue
294
+ const startsWithLetters = /^[A-Za-z]{2,}/.test(clean)
295
+ const hasSeparator = /[-._/+]/.test(clean)
296
+ const longPartNumber = clean.length >= 8
297
+ if (startsWithLetters || hasSeparator || longPartNumber) strong.push(clean)
298
+ }
299
+ if (strong.length === 1) return { type: 'model', value: strong[0], token: normalizeModelToken(strong[0]) }
300
+ if (strong.length > 1 && tokens.length === strong.length) {
301
+ // A single part number split by separators is already covered by one token;
302
+ // multiple independent part numbers are ambiguous, so keep parameter parsing.
303
+ return undefined
304
+ }
305
+ return undefined
306
+ }
307
+
308
+ /** Package / footprint tokens, longest first so SOT-23-5 wins over SOT-23. */
309
+ const PACKAGE_PATTERNS = [
310
+ 'SOT-23-6', 'SOT-23-5', 'SOT-23-3', 'SOT-23', 'SOD-123FL', 'SOD-123', 'SOD-323',
311
+ 'DO-214AC', 'DO-214AA', 'DO-214AB', 'ESOP-8', 'SOP-16', 'SOP-8', 'SOP-4',
312
+ 'TSSOP-16', 'TSSOP-20', 'MSOP-8', 'MSOP-10', 'QFN-16', 'QFN-20', 'QFN-24',
313
+ 'DFN-6', 'DFN-8', 'SMA', 'SMB', 'SMC', 'TO-220', 'TO-92', 'TO-252', 'TO-263',
314
+ '0201', '0402', '0603', '0805', '1206', '1210', '1812', '2010', '2512', '3225', '5032',
315
+ ]
316
+
317
+ const VOLTAGE_UNIT = /(kv|v)/i
318
+ const CURRENT_UNIT = /(ma|ua|µa|a)/i
319
+ const CAP_UNIT = /(mf|uf|µf|nf|pf|f)/i
320
+ const RES_UNIT = /(mω|kω|mohm|kohm|ω|ohm|meg|m|k|r)/i
321
+
322
+ /** Normalise a comparator token to one of '>=', '<=', '='. */
323
+ function normalizeComparator(raw) {
324
+ if (raw === undefined) return undefined
325
+ const text = String(raw).toLowerCase()
326
+ if (['>=', '≥', '>', '至少', '不小于', '大于', 'min', 'minimum'].includes(text)) return '>='
327
+ if (['<=', '≤', '<', '最多', '不超过', '小于', 'max', 'maximum'].includes(text)) return '<='
328
+ return '='
329
+ }
330
+
331
+ /** @param {string} text @returns {string | undefined} */
332
+ function extractPackage(text) {
333
+ const upper = text.toUpperCase()
334
+ for (const pattern of PACKAGE_PATTERNS) {
335
+ if (upper.includes(pattern)) return pattern
336
+ }
337
+ const inch = upper.match(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/)
338
+ return inch === null ? undefined : inch[1]
339
+ }
340
+
341
+ /**
342
+ * Extract numeric requirements from free text.
343
+ * @param {string} text
344
+ * @returns {{ kind: string, value: number, unit: string, comparator: string | undefined, raw: string }[]}
345
+ */
346
+ export function extractRequirements(text) {
347
+ /** @type {{ kind: string, value: number, unit: string, comparator: string | undefined, raw: string }[]} */
348
+ const requirements = []
349
+ const cleaned = text
350
+ .replace(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/g, ' ')
351
+ .replace(/\b(x7r|x5r|x6s|x7s|c0g|np0|y5v|z5u)\b/gi, ' ')
352
+ const push = (kind, value, unit, comparator, raw) => {
353
+ if (!Number.isFinite(value)) return
354
+ requirements.push({ kind, value, unit, comparator: normalizeComparator(comparator), raw })
355
+ }
356
+
357
+ // Capacitance
358
+ for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(pf|nf|uf|µf|mf|f)\b/gi)) {
359
+ const unit = match[3].toLowerCase()
360
+ const scale = { pf: 1, nf: 1e3, uf: 1e6, 'µf': 1e6, mf: 1e9, f: 1e12 }[unit]
361
+ push('capacitance', Number(match[2]) * scale, unit, match[1], match[0].trim())
362
+ }
363
+ // Resistance ("10kΩ", "5.1k", "100Ω", "0R")
364
+ for (const match of cleaned.matchAll(/(?<![A-Za-z0-9])(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(kω|kohm|mω|mohm|meg|mω|ω|ohm|k|m|r)(?![A-Za-z0-9])/gi)) {
365
+ const unitRaw = match[3]
366
+ const unit = unitRaw === undefined ? undefined : unitRaw.toLowerCase()
367
+ if (unit === undefined) continue
368
+ const value = Number(match[2])
369
+ const scale = { 'ω': 1, ohm: 1, r: 1, k: 1e3, 'kω': 1e3, kohm: 1e3, m: 1e6, meg: 1e6, 'mω': 1e-3, mohm: 1e-3 }[unit]
370
+ if (scale === undefined) continue
371
+ push('resistance', value * scale, unit, match[1], match[0].trim())
372
+ }
373
+ // Inductance
374
+ for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(nh|uh|µh|mh|h)\b/gi)) {
375
+ const unit = match[3].toLowerCase()
376
+ const scale = { nh: 1, uh: 1e3, 'µh': 1e3, mh: 1e6, h: 1e9 }[unit]
377
+ push('inductance', Number(match[2]) * scale, unit, match[1], match[0].trim())
378
+ }
379
+ // Voltage
380
+ for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(kv|v)\b/gi)) {
381
+ const unit = match[3].toLowerCase()
382
+ push('voltage', Number(match[2]) * (unit === 'kv' ? 1000 : 1), unit, match[1], match[0].trim())
383
+ }
384
+ // Current
385
+ for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(ma|ua|µa|a)\b/gi)) {
386
+ const unit = match[3].toLowerCase()
387
+ const scale = { ma: 1, ua: 1e-3, 'µa': 1e-3, a: 1e3 }[unit]
388
+ push('current', Number(match[2]) * scale, unit, match[1], match[0].trim())
389
+ }
390
+ // Power
391
+ for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(mw|w)\b/gi)) {
392
+ const unit = match[3].toLowerCase()
393
+ push('power', Number(match[2]) * (unit === 'mw' ? 1 : 1000), unit, match[1], match[0].trim())
394
+ }
395
+ // Tolerance (percent) - tighter is better, so default comparator is <=
396
+ for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*±?\s*(\d+(?:\.\d+)?)\s*%/g)) {
397
+ push('tolerance', Number(match[2]), '%', match[1], match[0].trim())
398
+ }
399
+ // Frequency
400
+ for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(khz|mhz|ghz|hz)\b/gi)) {
401
+ const unit = match[3].toLowerCase()
402
+ const scale = { hz: 1, khz: 1e3, mhz: 1e6, ghz: 1e9 }[unit]
403
+ push('frequency', Number(match[2]) * scale, unit, match[1], match[0].trim())
404
+ }
405
+ // Footprint dimensions like 2.0x1.2mm
406
+ for (const match of cleaned.matchAll(/(\d+(?:\.\d+)?)\s*(?:x|×|\*)\s*(\d+(?:\.\d+)?)\s*mm/gi)) {
407
+ requirements.push({ kind: 'size', value: Number(match[1]), unit: 'mm', comparator: '=', raw: match[0], extra: Number(match[2]) })
408
+ }
409
+ // Pin / contact count ("16P", "16 pin", "16针")
410
+ for (const match of cleaned.matchAll(/(?<![A-Za-z0-9])(\d{1,3})\s*(?:p|pin|pins|针|脚)(?![A-Za-z0-9])/gi)) {
411
+ push('pins', Number(match[1]), 'pins', '=', match[0].trim())
412
+ }
413
+ return requirements
414
+ }
415
+
416
+ /**
417
+ * Parse a free-form requirement (Chinese or English) into a structured need.
418
+ * @param {string} raw
419
+ * @returns {{ raw: string, searchKeyword: string, package?: string, directCode?: string, modelGuess: boolean, categories: string[], dielectric?: string, requirements: ReturnType<typeof extractRequirements> }}
420
+ */
421
+ export function parseNeed(raw) {
422
+ const text = String(raw ?? '').trim()
423
+ const exact = detectExactQuery(text)
424
+ if (exact !== undefined) {
425
+ // Exact part query: return the code/model directly and DO NOT parse numeric
426
+ // requirements. Part numbers such as SMF5.0A / AO3401A / TP4054 contain
427
+ // digit+letter patterns that would otherwise be misread as value/unit.
428
+ return {
429
+ raw: text,
430
+ searchKeyword: exact.value,
431
+ package: extractPackage(text),
432
+ directCode: exact.type === 'code' ? exact.value : undefined,
433
+ modelGuess: exact.type === 'model',
434
+ modelToken: exact.type === 'model' ? exact.token : undefined,
435
+ categories: [],
436
+ dielectric: undefined,
437
+ requirements: [],
438
+ exact: true,
439
+ exactValue: exact.value,
440
+ upper: text.toUpperCase(),
441
+ }
442
+ }
443
+
444
+ const packageName = extractPackage(text)
445
+ const categories = []
446
+ const dielectricMatch = text.match(/\b(X7R|X5R|X6S|X7S|C0G|NP0|Y5V|Z5U)\b/i)
447
+ const dielectric = dielectricMatch === null ? undefined : dielectricMatch[1].toUpperCase()
448
+ for (const [word, hints] of CATEGORY_TERMS) {
449
+ if (text.toLowerCase().includes(word)) {
450
+ for (const hint of hints) if (!categories.includes(hint)) categories.push(hint)
451
+ }
452
+ }
453
+ // English category words are used as-is by the JLCPCB search, so keep them.
454
+ const ENGLISH_CATEGORIES = [
455
+ 'capacitor', 'resistor', 'inductor', 'ferrite bead', 'diode', 'schottky', 'led',
456
+ 'transistor', 'mosfet', 'ldo', 'voltage regulator', 'connector', 'pin header',
457
+ 'tactile switch', 'switch', 'crystal', 'resonator', 'fuse', 'ptc resettable fuse',
458
+ 'microphone', 'buzzer', 'battery holder', 'battery connector', 'optocoupler',
459
+ 'operational amplifier', 'comparator', 'microcontroller', 'memory', 'level shifter',
460
+ 'tvs', 'esd', 'type-c', 'microsd', 'usb', 'relay', 'sensor', 'motor driver',
461
+ 'adc', 'dac', 'interface', 'antenna', 'receptacle', 'battery charger', 'pin header',
462
+ ]
463
+ for (const word of ENGLISH_CATEGORIES) {
464
+ const re = new RegExp(`(^|[^a-z0-9])${word.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i')
465
+ if (re.test(text) && !categories.includes(word)) categories.push(word)
466
+ }
467
+
468
+ // Translate Chinese category words into English so the JLCPCB keyword search
469
+ // (which is English-oriented) gets a useful query.
470
+ let keyword = text
471
+ for (const [word, hints] of CATEGORY_TERMS) {
472
+ if (hints.length === 0) continue
473
+ const re = new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')
474
+ keyword = keyword.replace(re, ` ${hints[0]} `)
475
+ }
476
+ keyword = keyword
477
+ .replace(/[,。;、,;::!!??]/g, ' ')
478
+ .replace(/需要|一个|帮我|找一下|寻找|找|用来|用于|的|料号|器件|元件|请|帮我选|推荐/g, ' ')
479
+ .replace(/[\u4e00-\u9fff]+/g, ' ')
480
+ .replace(/\s+/g, ' ')
481
+ .trim()
482
+ if (keyword === '') keyword = [packageName, dielectric, ...categories].filter(Boolean).join(' ')
483
+ if (keyword === '') keyword = text
484
+
485
+ const requirements = extractRequirements(text)
486
+ // Category-aware comparator defaults.
487
+ const isRegulator = categories.some((category) => category === 'ldo' || category === 'voltage regulator' || category === 'battery charger')
488
+ for (const requirement of requirements) {
489
+ if (requirement.comparator !== undefined) continue
490
+ if (requirement.kind === 'voltage' && isRegulator) requirement.comparator = '='
491
+ else if (requirement.kind === 'tolerance') requirement.comparator = '<='
492
+ else if (requirement.kind === 'size') requirement.comparator = '='
493
+ else if (requirement.kind === 'capacitance' || requirement.kind === 'resistance' || requirement.kind === 'inductance' || requirement.kind === 'frequency' || requirement.kind === 'pins') requirement.comparator = '='
494
+ else requirement.comparator = '>='
495
+ }
496
+ return {
497
+ raw: text,
498
+ searchKeyword: keyword,
499
+ package: packageName,
500
+ directCode: undefined,
501
+ modelGuess: false,
502
+ modelToken: undefined,
503
+ categories,
504
+ dielectric,
505
+ requirements,
506
+ exact: false,
507
+ exactValue: undefined,
508
+ upper: text.toUpperCase(),
509
+ }
510
+ }
511
+
512
+ /* -------------------------------------------------------------------------- */
513
+ /* Candidate extraction / matching */
514
+ /* -------------------------------------------------------------------------- */
515
+
516
+ /** @param {Record<string, unknown>} item */
517
+ function attributesOf(item) {
518
+ return Array.isArray(item.attributes)
519
+ ? item.attributes.filter(isRecord).map((attribute) => ({
520
+ name: asString(attribute.name) ?? '',
521
+ value: asString(attribute.value) ?? '',
522
+ }))
523
+ : []
524
+ }
525
+
526
+ /** Full searchable text of a JLCPCB candidate row. */
527
+ function candidateText(item) {
528
+ const parts = [
529
+ asString(item.model),
530
+ asString(item.package),
531
+ asString(item.category),
532
+ asString(item.description),
533
+ ]
534
+ for (const attribute of attributesOf(item)) parts.push(`${attribute.name} ${attribute.value}`)
535
+ return parts.filter(Boolean).join(' ').toLowerCase()
536
+ }
537
+
538
+ /** Infer quantity kinds from an attribute name. */
539
+ function kindsFromAttributeName(name) {
540
+ const kinds = []
541
+ if (/capacitance|^cap\b/i.test(name)) kinds.push('capacitance')
542
+ if (/resistance|resistor/i.test(name)) kinds.push('resistance')
543
+ if (/inductance/i.test(name)) kinds.push('inductance')
544
+ if (/voltage|vds|vgs|reverse|vrm|vrr|supply/i.test(name)) kinds.push('voltage')
545
+ if (/current|amp/i.test(name)) kinds.push('current')
546
+ if (/\bpower\b/i.test(name)) kinds.push('power')
547
+ if (/tolerance/i.test(name)) kinds.push('tolerance')
548
+ if (/frequency/i.test(name)) kinds.push('frequency')
549
+ return kinds
550
+ }
551
+
552
+ /**
553
+ * Extract candidate numeric values for matching.
554
+ * @param {Record<string, unknown>} item
555
+ * @returns {{ kind: string, value: number }[]}
556
+ */
557
+ export function extractCandidateQuantities(item) {
558
+ /** @type {{ kind: string, value: number }[]} */
559
+ const result = []
560
+ const add = (kind, value) => {
561
+ if (Number.isFinite(value)) result.push({ kind, value })
562
+ }
563
+ for (const attribute of attributesOf(item)) {
564
+ if (/number of (contacts|positions|pins)|contacts|positions|pin count|number of pins|pin number/i.test(attribute.name)) {
565
+ const pinMatch = attribute.value.match(/\d{1,3}/)
566
+ if (pinMatch !== null) add('pins', Number(pinMatch[0]))
567
+ }
568
+ const kinds = kindsFromAttributeName(attribute.name)
569
+ const valueText = `${attribute.value}`
570
+ if (kinds.length === 0) continue
571
+ for (const kind of kinds) {
572
+ for (const parsed of parseQuantitiesOfKind(valueText, kind)) add(kind, parsed)
573
+ }
574
+ // "10k" / "0R" style resistance values without a unit letter.
575
+ if (kinds.includes('resistance')) {
576
+ const bare = valueText.trim().match(/^(\d+(?:\.\d+)?)\s*(k|m)?$/i)
577
+ if (bare !== null) {
578
+ const multiplier = bare[2] === undefined ? 1 : bare[2].toLowerCase() === 'k' ? 1e3 : 1e6
579
+ add('resistance', Number(bare[1]) * multiplier)
580
+ }
581
+ }
582
+ }
583
+ // Generic scan over the description as a fallback.
584
+ const description = asString(item.description)
585
+ if (description !== undefined) {
586
+ for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(pf|nf|uf|µf|mf|f)\b/gi)) {
587
+ add('capacitance', Number(match[1]) * ({ pf: 1, nf: 1e3, uf: 1e6, 'µf': 1e6, mf: 1e9, f: 1e12 }[match[2].toLowerCase()] ?? 0))
588
+ }
589
+ for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(kv|v)\b/gi)) {
590
+ add('voltage', Number(match[1]) * (match[2].toLowerCase() === 'kv' ? 1000 : 1))
591
+ }
592
+ for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(ma|ua|µa|a)\b/gi)) {
593
+ add('current', Number(match[1]) * ({ ma: 1, ua: 1e-3, 'µa': 1e-3, a: 1e3 }[match[2].toLowerCase()] ?? 0))
594
+ }
595
+ for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(mw|w)\b/gi)) {
596
+ add('power', Number(match[1]) * (match[2].toLowerCase() === 'mw' ? 1 : 1000))
597
+ }
598
+ for (const match of description.matchAll(/±\s*(\d+(?:\.\d+)?)\s*%/g)) add('tolerance', Number(match[1]))
599
+ for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(nh|uh|µh|mh|h)\b/gi)) {
600
+ add('inductance', Number(match[1]) * ({ nh: 1, uh: 1e3, 'µh': 1e3, mh: 1e6, h: 1e9 }[match[2].toLowerCase()] ?? 0))
601
+ }
602
+ for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(?:x|×|\*)\s*(\d+(?:\.\d+)?)\s*mm/gi)) {
603
+ add('size', Number(match[1]))
604
+ add('size', Number(match[2]))
605
+ }
606
+ }
607
+ return result
608
+ }
609
+
610
+ /** Parse values of one kind from a text fragment. */
611
+ function parseQuantitiesOfKind(text, kind) {
612
+ const values = []
613
+ const add = (value) => {
614
+ if (Number.isFinite(value)) values.push(value)
615
+ }
616
+ if (kind === 'capacitance') {
617
+ for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(pf|nf|uf|µf|mf|f)\b/gi)) {
618
+ const scale = { pf: 1, nf: 1e3, uf: 1e6, 'µf': 1e6, mf: 1e9, f: 1e12 }[match[2].toLowerCase()]
619
+ if (scale !== undefined) add(Number(match[1]) * scale)
620
+ }
621
+ } else if (kind === 'resistance') {
622
+ for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(kω|kohm|mω|mohm|meg|ω|ohm|m|k|r)?\b/gi)) {
623
+ const unit = match[2] === undefined ? undefined : match[2].toLowerCase()
624
+ const scale = unit === undefined
625
+ ? 1
626
+ : { 'ω': 1, ohm: 1, r: 1, k: 1e3, 'kω': 1e3, kohm: 1e3, m: 1e6, meg: 1e6, 'mω': 1e-3, mohm: 1e-3 }[unit]
627
+ if (scale !== undefined) add(Number(match[1]) * scale)
628
+ }
629
+ } else if (kind === 'inductance') {
630
+ for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(nh|uh|µh|mh|h)\b/gi)) {
631
+ const scale = { nh: 1, uh: 1e3, 'µh': 1e3, mh: 1e6, h: 1e9 }[match[2].toLowerCase()]
632
+ if (scale !== undefined) add(Number(match[1]) * scale)
633
+ }
634
+ } else if (kind === 'voltage') {
635
+ for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(kv|v)\b/gi)) {
636
+ add(Number(match[1]) * (match[2].toLowerCase() === 'kv' ? 1000 : 1))
637
+ }
638
+ } else if (kind === 'current') {
639
+ for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(ma|ua|µa|a)\b/gi)) {
640
+ const scale = { ma: 1, ua: 1e-3, 'µa': 1e-3, a: 1e3 }[match[2].toLowerCase()]
641
+ if (scale !== undefined) add(Number(match[1]) * scale)
642
+ }
643
+ } else if (kind === 'power') {
644
+ for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(mw|w)\b/gi)) {
645
+ add(Number(match[1]) * (match[2].toLowerCase() === 'mw' ? 1 : 1000))
646
+ }
647
+ } else if (kind === 'tolerance') {
648
+ for (const match of text.matchAll(/±?\s*(\d+(?:\.\d+)?)\s*%/g)) add(Number(match[1]))
649
+ } else if (kind === 'frequency') {
650
+ for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(khz|mhz|ghz|hz)\b/gi)) {
651
+ const scale = { hz: 1, khz: 1e3, mhz: 1e6, ghz: 1e9 }[match[2].toLowerCase()]
652
+ if (scale !== undefined) add(Number(match[1]) * scale)
653
+ }
654
+ } else if (kind === 'size') {
655
+ for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(?:x|×|\*)\s*(\d+(?:\.\d+)?)\s*mm/gi)) {
656
+ add(Number(match[1]))
657
+ add(Number(match[2]))
658
+ }
659
+ }
660
+ return values
661
+ }
662
+
663
+ /** Does a candidate value satisfy a parsed requirement? */
664
+ function satisfies(value, requirement) {
665
+ const target = requirement.value
666
+ const comparator = requirement.comparator ?? '>='
667
+ const tolerance = Math.max(Math.abs(target) * 0.05, 1e-9)
668
+ if (comparator === '=') return Math.abs(value - target) <= tolerance
669
+ if (comparator === '>=') return value >= target - tolerance
670
+ if (comparator === '<=') return value <= target + tolerance
671
+ return false
672
+ }
673
+
674
+ /** Package comparison tolerant to spacing/case differences. */
675
+ function packageMatches(candidatePackage, wantedPackage) {
676
+ if (candidatePackage === undefined) return true
677
+ const normal = (value) => String(value).toUpperCase().replace(/[\s_]/g, '')
678
+ return normal(candidatePackage) === normal(wantedPackage)
679
+ }
680
+
681
+ /**
682
+ * Check whether a JLCPCB candidate satisfies a parsed requirement.
683
+ * @param {Record<string, unknown>} item
684
+ * @param {ReturnType<typeof parseNeed>} need
685
+ * @returns {{ ok: boolean, matched: string[], missing?: string }}
686
+ */
687
+ export function candidateMatches(item, need) {
688
+ if (need.directCode !== undefined && String(item.code).toUpperCase() !== need.directCode) {
689
+ return { ok: false, matched: [] }
690
+ }
691
+ if (need.modelGuess === true) {
692
+ const candidate = normalizeModelToken(item.model)
693
+ if (need.modelToken === undefined || !candidate.includes(need.modelToken)) {
694
+ return { ok: false, matched: [] }
695
+ }
696
+ return { ok: true, matched: [`model=${item.model ?? need.exactValue}`] }
697
+ }
698
+ if (need.package !== undefined && item.package !== undefined && !packageMatches(item.package, need.package)) {
699
+ return { ok: false, matched: [] }
700
+ }
701
+ const text = candidateText(item)
702
+ if (need.dielectric !== undefined && !text.includes(need.dielectric.toLowerCase())) {
703
+ return { ok: false, matched: [] }
704
+ }
705
+ if (need.categories.length > 0) {
706
+ const hit = need.categories.some((category) => text.includes(category.toLowerCase()))
707
+ if (!hit) return { ok: false, matched: [] }
708
+ }
709
+ const quantities = extractCandidateQuantities(item)
710
+ const matched = []
711
+ for (const requirement of need.requirements) {
712
+ if (requirement.kind === 'size') {
713
+ const values = quantities.filter((quantity) => quantity.kind === 'size').map((quantity) => quantity.value)
714
+ if (values.length >= 1) {
715
+ const wanted = requirement.value
716
+ const wantedExtra = Number(requirement.extra ?? wanted)
717
+ const pairMatch = values.some((value, index) => {
718
+ if (index + 1 >= values.length) return false
719
+ const other = values[index + 1]
720
+ return (Math.abs(value - wanted) <= wanted * 0.2 && Math.abs(other - wantedExtra) <= wantedExtra * 0.25) ||
721
+ (Math.abs(value - wantedExtra) <= wantedExtra * 0.2 && Math.abs(other - wanted) <= wanted * 0.25)
722
+ })
723
+ if (!pairMatch) return { ok: false, matched }
724
+ }
725
+ matched.push(`size=${requirement.raw}`)
726
+ continue
727
+ }
728
+ const values = quantities.filter((quantity) => quantity.kind === requirement.kind).map((quantity) => quantity.value)
729
+ if (values.length === 0) return { ok: false, matched }
730
+ if (!values.some((value) => satisfies(value, requirement))) return { ok: false, matched }
731
+ matched.push(`${requirement.kind}=${requirement.raw}`)
732
+ }
733
+ return { ok: true, matched }
734
+ }
735
+
736
+ /**
737
+ * Explain why a candidate fails the parsed requirement (used by the relaxed
738
+ * fallback, so the agent can tell the user which constraint could not be met).
739
+ * @param {Record<string, unknown>} item
740
+ * @param {ReturnType<typeof parseNeed>} need
741
+ * @returns {string[]}
742
+ */
743
+ export function mismatchReasons(item, need) {
744
+ const reasons = []
745
+ if (need.directCode !== undefined && String(item.code).toUpperCase() !== need.directCode) {
746
+ reasons.push(`LCSC 编号为 ${item.code},不是 ${need.directCode}`)
747
+ }
748
+ if (need.modelGuess === true && !normalizeModelToken(item.model).includes(need.modelToken ?? '')) {
749
+ reasons.push(`型号 ${item.model ?? '?'} 与 ${need.exactValue ?? '?'} 不完全一致`)
750
+ }
751
+ if (need.package !== undefined && item.package !== undefined && !packageMatches(item.package, need.package)) {
752
+ reasons.push(`封装 ${item.package} ≠ ${need.package}`)
753
+ }
754
+ const text = candidateText(item)
755
+ if (need.dielectric !== undefined && !text.includes(need.dielectric.toLowerCase())) {
756
+ reasons.push(`介质/温度特性不含 ${need.dielectric}`)
757
+ }
758
+ if (need.categories.length > 0 && !need.categories.some((category) => text.includes(category.toLowerCase()))) {
759
+ reasons.push(`品类不匹配(期望 ${need.categories.slice(0, 2).join(' / ')})`)
760
+ }
761
+ const quantities = extractCandidateQuantities(item)
762
+ for (const requirement of need.requirements) {
763
+ if (requirement.kind === 'size') continue
764
+ const values = quantities.filter((quantity) => quantity.kind === requirement.kind).map((quantity) => quantity.value)
765
+ if (values.length === 0) {
766
+ reasons.push(`缺少参数 ${requirement.kind}`)
767
+ } else if (!values.some((value) => satisfies(value, requirement))) {
768
+ reasons.push(`参数 ${requirement.kind} 不满足 ${requirement.comparator ?? '='} ${requirement.raw ?? requirement.value}`)
769
+ }
770
+ if (reasons.length >= 4) break
771
+ }
772
+ return reasons
773
+ }
774
+
775
+ /** Pick the ladder tier for `qty`, returning both the used threshold and price. */
776
+ export function pickTier(tiers, qty) {
777
+ if (!Array.isArray(tiers) || tiers.length === 0) return undefined
778
+ const sorted = tiers
779
+ .filter((tier) => isRecord(tier) && Number.isFinite(asNumber(tier.qty)) && Number.isFinite(asNumber(tier.unit)))
780
+ .map((tier) => ({ qty: Number(tier.qty), unit: Number(tier.unit) }))
781
+ .sort((left, right) => left.qty - right.qty)
782
+ if (sorted.length === 0) return undefined
783
+ let chosen = sorted[0]
784
+ for (const tier of sorted) {
785
+ if (qty >= tier.qty) chosen = tier
786
+ else break
787
+ }
788
+ return { qty: chosen.qty, unit: chosen.unit, label: `${chosen.qty}+` }
789
+ }
790
+
791
+ /**
792
+ * Compare footprints for a substitution: same package = drop-in, otherwise the
793
+ * PCB needs a change.
794
+ * @param {string | undefined} left
795
+ * @param {string | undefined} right
796
+ */
797
+ export function footprintCompatible(left, right) {
798
+ if (left === undefined || right === undefined) return undefined
799
+ return packageMatches(left, right)
800
+ }
801
+
802
+ /* -------------------------------------------------------------------------- */
803
+ /* Scoring */
804
+ /* -------------------------------------------------------------------------- */
805
+
806
+ /**
807
+ * @typedef {object} ScoreContext
808
+ * @property {'hand' | 'smt'} assembly
809
+ * @property {number} quantity components per board
810
+ * @property {number} boards board count
811
+ * @property {number} orderQty total parts to buy
812
+ * @property {'balanced' | 'cost' | 'stock'} sort
813
+ */
814
+
815
+ /**
816
+ * Score one candidate.
817
+ * @param {Record<string, unknown>} item
818
+ * @param {ReturnType<typeof parseNeed>} need
819
+ * @param {ScoreContext} context
820
+ */
821
+ export function scoreCandidate(item, need, context) {
822
+ const match = candidateMatches(item, need)
823
+ const tiers = Array.isArray(item.priceUsdTiers) ? item.priceUsdTiers : []
824
+ const unitUsd = pickTierPrice(tiers, context.orderQty)
825
+ const expand = String(item.libraryType ?? '').toLowerCase() !== 'base'
826
+ const modelExact =
827
+ context.modelToken === undefined
828
+ ? false
829
+ : String(item.model ?? '').replace(/[^A-Za-z0-9]/g, '').toUpperCase() === context.modelToken
830
+ const extendedFeeUsd = context.assembly === 'smt' && expand ? EXTENDED_PART_FEE_USD : 0
831
+ const feePerBoardUsd = extendedFeeUsd === 0 ? 0 : extendedFeeUsd / Math.max(1, context.boards)
832
+ const unitEffectiveUsd =
833
+ unitUsd === undefined
834
+ ? undefined
835
+ : unitUsd + (context.assembly === 'smt' ? feePerBoardUsd / Math.max(1, context.quantity) : 0)
836
+ return { item, match, unitUsd, unitEffectiveUsd, extendedFeeUsd, feePerBoardUsd, expand, modelExact }
837
+ }
838
+
839
+ /** @param {number} stock */
840
+ function stockScore(stock) {
841
+ if (!Number.isFinite(stock) || stock <= 0) return 0
842
+ return Math.min(1, Math.max(0, Math.log10(stock + 1) / 7))
843
+ }
844
+
845
+ /**
846
+ * Rank scored candidates (already filtered by `match.ok`).
847
+ * @param {ReturnType<typeof scoreCandidate>[]} candidates
848
+ * @param {ScoreContext} context
849
+ * @param {number} neededQty
850
+ */
851
+ export function rankCandidates(candidates, context, neededQty) {
852
+ const priced = candidates.filter((candidate) => candidate.unitEffectiveUsd !== undefined)
853
+ const unitValues = priced.map((candidate) => candidate.unitEffectiveUsd)
854
+ const minUnit = unitValues.length > 0 ? Math.min(...unitValues) : 0
855
+ const maxUnit = unitValues.length > 0 ? Math.max(...unitValues) : 0
856
+ const weights =
857
+ context.sort === 'cost'
858
+ ? { cost: 0.75, stock: 0.1, cover: 0.05, library: 0.05, params: 0.05 }
859
+ : context.sort === 'stock'
860
+ ? { cost: 0.2, stock: 0.55, cover: 0.15, library: 0.05, params: 0.05 }
861
+ : context.assembly === 'smt'
862
+ ? { cost: 0.4, stock: 0.25, cover: 0.15, library: 0.1, params: 0.1 }
863
+ : { cost: 0.5, stock: 0.3, cover: 0.1, library: 0.0, params: 0.1 }
864
+ const scored = candidates.map((candidate) => {
865
+ const unit = candidate.unitEffectiveUsd
866
+ const costScore = unit === undefined
867
+ ? 0.2
868
+ : maxUnit === minUnit
869
+ ? 1
870
+ : 1 - (unit - minUnit) / (maxUnit - minUnit)
871
+ const stock = asInteger(candidate.item.stock) ?? 0
872
+ const cover = neededQty <= 0 ? 1 : Math.min(1, stock / Math.max(1, neededQty * 1.5))
873
+ const library =
874
+ context.assembly === 'smt' ? (candidate.expand ? 0 : 1) : 0.5
875
+ const params = needParamScore(candidate.match)
876
+ const score =
877
+ weights.cost * costScore +
878
+ weights.stock * stockScore(stock) +
879
+ weights.cover * cover +
880
+ weights.library * library +
881
+ weights.params * params
882
+ return { ...candidate, score: Math.round(score * 1000) / 1000, stock, cover }
883
+ })
884
+ scored.sort((left, right) => {
885
+ if (left.modelExact !== right.modelExact) return left.modelExact ? -1 : 1
886
+ if (right.score !== left.score) return right.score - left.score
887
+ const leftUnit = left.unitEffectiveUsd ?? Number.POSITIVE_INFINITY
888
+ const rightUnit = right.unitEffectiveUsd ?? Number.POSITIVE_INFINITY
889
+ if (leftUnit !== rightUnit) return leftUnit - rightUnit
890
+ return right.stock - left.stock
891
+ })
892
+ return scored
893
+ }
894
+
895
+ /** @param {{ matched: string[] }} match */
896
+ function needParamScore(match) {
897
+ return match.matched !== undefined && match.matched.length > 0 ? 1 : 0.5
898
+ }
899
+
900
+ /* -------------------------------------------------------------------------- */
901
+ /* Suggestion / output helpers */
902
+ /* -------------------------------------------------------------------------- */
903
+
904
+ /** Pick the attributes worth showing to the model (max 6). */
905
+ function pickSpecs(item) {
906
+ const preferred = /capacitance|resistance|inductance|voltage rating|output voltage|output current|current - output|tolerance|temperature coefficient|drain-source|forward current|power|package|size|dimension/i
907
+ const specs = []
908
+ for (const attribute of attributesOf(item)) {
909
+ if (!preferred.test(attribute.name)) continue
910
+ specs.push({ name: attribute.name, value: attribute.value })
911
+ if (specs.length >= 6) break
912
+ }
913
+ return specs
914
+ }
915
+
916
+ /** @param {ReturnType<typeof scoreCandidate>} candidate @param {ScoreContext} context */
917
+ function reasonsFor(candidate, context) {
918
+ const reasons = []
919
+ if (context.assembly === 'smt') {
920
+ reasons.push(
921
+ candidate.expand
922
+ ? `扩展库:每片板摊 $${money(candidate.feePerBoardUsd)} 上料费(每料号 $${EXTENDED_PART_FEE_USD}/单)`
923
+ : '基础库:无上料费',
924
+ )
925
+ }
926
+ if (candidate.unitUsd !== undefined) {
927
+ reasons.push(`阶梯价 $${candidate.unitUsd}(按采购量 ${context.orderQty})`)
928
+ }
929
+ reasons.push(`库存 ${candidate.stock}`)
930
+ if (candidate.cover >= 1) reasons.push(`库存覆盖需求(${Math.round(candidate.cover * 100)}%)`)
931
+ else if (candidate.cover < 1) reasons.push(`库存仅覆盖需求的 ${Math.round(candidate.cover * 100)}%`)
932
+ if (candidate.modelExact === true) reasons.push('型号精确匹配')
933
+ if (candidate.match.matched.length > 0) reasons.push(`参数匹配:${candidate.match.matched.slice(0, 4).join(', ')}`)
934
+ return reasons
935
+ }
936
+
937
+ /**
938
+ * Find requirement-matching parts.
939
+ * @param {ReturnType<typeof createLcscService>} service
940
+ * @param {Record<string, unknown>} args
941
+ * @param {{ signal?: AbortSignal }} [options]
942
+ */
943
+ /** Identity constraints that must not be relaxed when looking for substitutes. */
944
+ const IDENTITY_REQUIREMENT_KINDS = new Set(['capacitance', 'resistance', 'inductance', 'pins'])
945
+
946
+ /** Pin/contact count reported by the JLCPCB attributes, if available. */
947
+ function candidatePinCount(item) {
948
+ for (const attribute of attributesOf(item)) {
949
+ if (/number of (contacts|positions|pins)|contacts|positions|pin count|number of pins/i.test(attribute.name)) {
950
+ const match = attribute.value.match(/\d{1,3}/)
951
+ if (match !== null) return Number(match[0])
952
+ }
953
+ }
954
+ const pins = extractCandidateQuantities(item).find((quantity) => quantity.kind === 'pins')
955
+ return pins === undefined ? undefined : pins.value
956
+ }
957
+
958
+ /**
959
+ * Find requirement-matching parts.
960
+ * @param {ReturnType<typeof createLcscService>} service
961
+ * @param {Record<string, unknown>} args
962
+ * @param {{ signal?: AbortSignal }} [options]
963
+ */
964
+ export async function findParts(service, args, options = {}) {
965
+ const needText = asString(args.need)
966
+ if (needText === undefined) throw new Error('find_lcsc_parts requires a non-empty `need` string')
967
+ const assembly = args.assembly === 'smt' ? 'smt' : 'hand'
968
+ const quantity = clampInteger(args.quantity, 1, 100000, 1)
969
+ const boards = clampInteger(args.boards, 1, 100000, 1)
970
+ const limit = clampInteger(args.limit, 1, MAX_CANDIDATES, DEFAULT_LIMIT)
971
+ const sort = ['cost', 'stock'].includes(args.sort) ? args.sort : 'balanced'
972
+ const baseOnly = args.base_only === true
973
+ const includeCny = args.include_cny === true || (args.include_cny === undefined && assembly === 'hand')
974
+ const exclude = new Set(
975
+ (Array.isArray(args.exclude_lcsc) ? args.exclude_lcsc : [])
976
+ .map((code) => normalizeCode(code))
977
+ .filter(Boolean),
978
+ )
979
+ const need = parseNeed(needText)
980
+ const orderQty = quantity * boards
981
+ const feeUsd = asNumber(service.settings?.extendedPartFeeUsd) ?? EXTENDED_PART_FEE_USD
982
+ const usdToCnyFactor = asNumber(service.settings?.usdToCnyFactor) ?? 6.0
983
+ const context = {
984
+ assembly,
985
+ quantity,
986
+ boards,
987
+ orderQty,
988
+ sort,
989
+ modelToken: need.modelGuess ? need.modelToken : undefined,
990
+ feeUsd,
991
+ }
992
+ const notes = []
993
+ const hints = []
994
+
995
+ /** @type {Map<string, Record<string, unknown>>} */
996
+ const pool = new Map()
997
+ /** @param {{ total: number, items: Record<string, unknown>[] }} result */
998
+ const absorb = (result) => {
999
+ for (const item of result.items) {
1000
+ if (!pool.has(String(item.code))) pool.set(String(item.code), item)
1001
+ }
1002
+ return pool.size
1003
+ }
1004
+
1005
+ // ---- Phase 1: candidate recall -------------------------------------------
1006
+ const queries = []
1007
+ if (need.exact) {
1008
+ queries.push({ keyword: need.searchKeyword })
1009
+ } else if (assembly === 'smt') {
1010
+ // Search the base library first so free-to-load parts are not drowned out.
1011
+ queries.push({ keyword: need.searchKeyword, libraryType: 'base' })
1012
+ queries.push({ keyword: need.searchKeyword })
1013
+ } else {
1014
+ queries.push({ keyword: need.searchKeyword })
1015
+ }
1016
+ for (const query of queries) {
1017
+ const result = await service.searchRaw(query.keyword, {
1018
+ pageSize: SEARCH_PAGE_SIZE,
1019
+ libraryType: query.libraryType,
1020
+ signal: options.signal,
1021
+ })
1022
+ absorb(result)
1023
+ }
1024
+ // Degradation chain for recall: category+package, then the untouched raw text.
1025
+ if (pool.size === 0 && !need.exact) {
1026
+ const fallbackKeyword = [need.package, ...need.categories].filter(Boolean).join(' ')
1027
+ if (fallbackKeyword !== '' && fallbackKeyword !== need.searchKeyword) {
1028
+ notes.push(`关键词「${need.searchKeyword}」无结果,已回退到品类/封装关键词「${fallbackKeyword}」。`)
1029
+ absorb(await service.searchRaw(fallbackKeyword, { pageSize: SEARCH_PAGE_SIZE, signal: options.signal }))
1030
+ }
1031
+ if (pool.size === 0 && need.searchKeyword !== need.raw) {
1032
+ absorb(await service.searchRaw(need.raw, { pageSize: SEARCH_PAGE_SIZE, signal: options.signal }))
1033
+ }
1034
+ }
1035
+ if (pool.size === 0 && need.exact) {
1036
+ // The code/model search found nothing; the text may still be a model with
1037
+ // punctuation that the API search does not match verbatim.
1038
+ absorb(await service.searchRaw(need.raw, { pageSize: SEARCH_PAGE_SIZE, signal: options.signal }))
1039
+ }
1040
+
1041
+ // ---- Phase 2: strict filter, then staged relaxation ----------------------
1042
+ const usablePool = () =>
1043
+ [...pool.values()]
1044
+ .filter((item) => !exclude.has(String(item.code)))
1045
+ .filter((item) => (asInteger(item.stock) ?? 0) > 0)
1046
+ const scoreWith = (needForFilter) =>
1047
+ usablePool()
1048
+ .map((item) => scoreCandidate(item, needForFilter, context))
1049
+ .filter((candidate) => candidate.match.ok)
1050
+
1051
+ let relaxed = false
1052
+ let relaxStage = 0
1053
+ let droppedRequirements = []
1054
+ let scored = scoreWith(need)
1055
+ if (scored.length === 0 && !need.exact && need.requirements.length > 0) {
1056
+ // Stage 1: keep identity (value/package) constraints, relax ratings.
1057
+ const identity = need.requirements.filter((requirement) => IDENTITY_REQUIREMENT_KINDS.has(requirement.kind))
1058
+ if (identity.length !== need.requirements.length) {
1059
+ const stageScored = scoreWith({ ...need, requirements: identity })
1060
+ if (stageScored.length > 0) {
1061
+ scored = stageScored
1062
+ relaxed = true
1063
+ relaxStage = 1
1064
+ droppedRequirements = need.requirements
1065
+ .filter((requirement) => !IDENTITY_REQUIREMENT_KINDS.has(requirement.kind))
1066
+ .map((requirement) => requirement.raw ?? requirement.kind)
1067
+ notes.push(`严格匹配无结果:已放宽非核心参数(${droppedRequirements.join('、')}),保留容值/阻值/感值/引脚数等身份参数。`)
1068
+ }
1069
+ }
1070
+ // Stage 2: drop the remaining requirements, keep package/category.
1071
+ if (scored.length === 0) {
1072
+ const stageScored = scoreWith({ ...need, requirements: [] })
1073
+ if (stageScored.length > 0) {
1074
+ scored = stageScored
1075
+ relaxed = true
1076
+ relaxStage = 2
1077
+ droppedRequirements = need.requirements.map((requirement) => requirement.raw ?? requirement.kind)
1078
+ notes.push('严格匹配无结果:已仅按封装/品类筛候选,参数需人工核对(见每行 mismatch_reasons)。')
1079
+ }
1080
+ }
1081
+ // Stage 3: last resort, everything in the recall pool.
1082
+ if (scored.length === 0) {
1083
+ scored = usablePool().map((item) => ({
1084
+ ...scoreCandidate(item, need, context),
1085
+ match: { ok: true, matched: [] },
1086
+ }))
1087
+ if (scored.length > 0) {
1088
+ relaxed = true
1089
+ relaxStage = 3
1090
+ notes.push('没有满足条件的候选;以下为最接近的候选,不匹配原因见 mismatch_reasons。')
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ if (baseOnly) {
1096
+ const baseCandidates = scored.filter((candidate) => !candidate.expand)
1097
+ if (baseCandidates.length === 0) {
1098
+ hints.push('该品类没有找到基础库(base library)候选;可去掉 base_only 或接受扩展库(SMT 每料号有上料费)。')
1099
+ } else {
1100
+ scored = baseCandidates
1101
+ }
1102
+ }
1103
+ if (assembly === 'smt' && !baseOnly) {
1104
+ const hasBase = scored.some((candidate) => !candidate.expand) ||
1105
+ [...pool.values()].some((item) => String(item.libraryType).toLowerCase() === 'base')
1106
+ if (!hasBase && scored.length > 0) {
1107
+ hints.push(`该品类未找到基础库候选(均为扩展库,$${feeUsd}/料号上料费);请到下单页确认优选扩展库分类。`)
1108
+ }
1109
+ }
1110
+
1111
+ let ranked = rankCandidates(scored, context, orderQty)
1112
+
1113
+ // ---- Phase 3: real CNY prices (domestic store via Chrome) ----------------
1114
+ /** @type {Map<string, Record<string, unknown>>} */
1115
+ const cnyByCode = new Map()
1116
+ if (includeCny && ranked.length > 0) {
1117
+ const targets = ranked.slice(0, Math.min(MAX_CNY_LOOKUPS, ranked.length))
1118
+ const results = await mapLimit(targets, 2, async (candidate) => {
1119
+ const code = String(candidate.item.code)
1120
+ try {
1121
+ if (typeof service.cn?.query === 'function') {
1122
+ const cn = await service.cn.query(code)
1123
+ if (cn?.ok === true) return cn
1124
+ }
1125
+ if (typeof service.cny === 'function') return await service.cny(code, options.signal)
1126
+ return { ok: false, reason: 'cny-adapter-unavailable' }
1127
+ } catch (error) {
1128
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
1129
+ }
1130
+ })
1131
+ for (let index = 0; index < targets.length; index += 1) {
1132
+ cnyByCode.set(String(targets[index].item.code), results[index])
1133
+ }
1134
+ const covered = [...cnyByCode.values()].filter((result) => result?.ok === true).length
1135
+ if (covered > 0) {
1136
+ notes.push(`人民币价来自立创商城国内站(Chrome 渲染,${covered}/${targets.length} 个候选取到 CNY,24h 缓存)。USD 与 CNY 并列,不混算。`)
1137
+ } else {
1138
+ hints.push('国内站人民币价本次不可用(Chrome/WAF/限流),CNY 列已回退为「批量档 USD×系数」的参考估算。')
1139
+ }
1140
+ }
1141
+
1142
+ // ---- Phase 4: build output ----------------------------------------------
1143
+ const candidates = ranked.slice(0, limit).map((candidate) => {
1144
+ const code = String(candidate.item.code)
1145
+ const cny = cnyByCode.get(code)
1146
+ const cnyUnit = cny?.ok === true ? asNumber(cny.unit) : undefined
1147
+ const cnyStock = cny?.ok === true ? asInteger(cny.stock) : undefined
1148
+ const tier = pickTier(candidate.item.priceUsdTiers, orderQty)
1149
+ const bulkUsd = bulkTierUsd(candidate.item)
1150
+ const cnyEstimate = cnyUnit === undefined ? estimateCnyFromUsd(bulkUsd, usdToCnyFactor) : undefined
1151
+ const stockSmt = asInteger(candidate.item.stock) ?? 0
1152
+ const stockLcsc = cnyStock ?? null
1153
+ const stock = assembly === 'smt' ? stockSmt : (stockLcsc ?? stockSmt)
1154
+ const priceUsd = tier === undefined
1155
+ ? null
1156
+ : {
1157
+ currency: 'USD',
1158
+ unit: money(tier.unit),
1159
+ tier: tier.label,
1160
+ source: 'jlcpcb-smt',
1161
+ tiers: (candidate.item.priceUsdTiers ?? []).slice(0, 8).map((entry) => ({ qty: entry.qty, unit: money(entry.unit) })),
1162
+ }
1163
+ const priceCny = cnyUnit !== undefined
1164
+ ? {
1165
+ currency: 'CNY',
1166
+ unit: money(cnyUnit),
1167
+ source: cny.source ?? 'lcsc_cn_render',
1168
+ estimated: false,
1169
+ cached: cny.cached === true,
1170
+ fetched_at: cny.fetched_at,
1171
+ }
1172
+ : cnyEstimate === undefined
1173
+ ? null
1174
+ : {
1175
+ currency: 'CNY',
1176
+ unit: cnyEstimate,
1177
+ source: 'usd_x_factor',
1178
+ estimated: true,
1179
+ factor: usdToCnyFactor,
1180
+ note: '参考估算(批量档 USD × 系数),非实际报价',
1181
+ }
1182
+ // The primary `price` field is the actual reference price. In hand mode a
1183
+ // real CNY price wins; when CNY is only an estimate, USD stays primary (the
1184
+ // estimate is still exposed in price_cny/cny_estimate and clearly labelled).
1185
+ const primaryPrice = assembly === 'smt'
1186
+ ? priceUsd
1187
+ : (priceCny !== null && priceCny.estimated !== true ? priceCny : (priceUsd ?? priceCny))
1188
+ return {
1189
+ lcsc: candidate.item.code,
1190
+ model: candidate.item.model,
1191
+ brand: candidate.item.brand,
1192
+ package: candidate.item.package,
1193
+ category: candidate.item.category,
1194
+ pin_count: candidatePinCount(candidate.item),
1195
+ library: candidate.expand ? 'expand' : 'base',
1196
+ preferred: candidate.item.preferred === true ? true : null,
1197
+ stock,
1198
+ stock_smt: stockSmt,
1199
+ stock_lcsc: stockLcsc,
1200
+ coverage: orderQty > 0 ? Math.min(1, Math.round((stock / orderQty) * 100) / 100) : undefined,
1201
+ price: primaryPrice === null ? undefined : { currency: primaryPrice.currency, unit: primaryPrice.unit, source: primaryPrice.source },
1202
+ price_usd: priceUsd,
1203
+ price_cny: priceCny,
1204
+ used_tier: tier?.label ?? null,
1205
+ effective_unit_usd: candidate.unitEffectiveUsd === undefined ? undefined : money(candidate.unitEffectiveUsd),
1206
+ fee_per_board_usd: assembly === 'smt' ? money(candidate.feePerBoardUsd) : 0,
1207
+ cny_price: cnyUnit === undefined ? null : money(cnyUnit),
1208
+ cny_estimate: priceCny === null || priceCny.estimated !== true
1209
+ ? null
1210
+ : { unit: priceCny.unit, factor: usdToCnyFactor, basis: 'highest-volume USD tier × factor', note: '参考估算,非实时汇率/非实际报价' },
1211
+ specs: pickSpecs(candidate.item),
1212
+ score: candidate.score,
1213
+ reasons: reasonsFor(candidate, context),
1214
+ mismatch_reasons: relaxStage >= 2 && need.requirements.length > 0 ? mismatchReasons(candidate.item, need) : undefined,
1215
+ url: asString(candidate.item.url) ?? `https://www.lcsc.com/product-detail/${candidate.item.code}.html`,
1216
+ }
1217
+ })
1218
+
1219
+ if (candidates.length === 0) {
1220
+ hints.push(
1221
+ '没有找到候选。建议:改用型号/LCSC 编号(如 SMF5.0A / TP4054 / C307331);或减少参数;或把中文品类换成英文(capacitor / resistor / LDO / connector)。',
1222
+ )
1223
+ }
1224
+ if (need.exact) {
1225
+ notes.push(`按${need.directCode !== undefined ? ' LCSC 编号' : '型号'}精确查询(${need.exactValue}),未做参数解析。`)
1226
+ } else if (need.requirements.length > 0) {
1227
+ notes.push(`解析到参数条件:${need.requirements.map((requirement) => `${requirement.kind}${requirement.comparator ?? '='}${requirement.value}`).join(', ')}`)
1228
+ }
1229
+ if (assembly === 'hand') {
1230
+ notes.push('assembly=hand:不考虑嘉立创上料费与基础库偏好;价格优先展示立创商城人民币零售价,USD 并列参考。')
1231
+ } else {
1232
+ notes.push(`assembly=smt:扩展库按每料号 $${feeUsd}/单上料费摊销;基础库优先度体现在排序里。`)
1233
+ }
1234
+
1235
+ return {
1236
+ need: needText,
1237
+ exact: need.exact,
1238
+ assembly,
1239
+ quantity,
1240
+ boards,
1241
+ order_qty: orderQty,
1242
+ currency: includeCny ? 'USD + CNY(并列,不混算)' : 'USD(CNY 为参考估算)',
1243
+ relaxed,
1244
+ relax_stage: relaxStage,
1245
+ candidates,
1246
+ hints,
1247
+ notes,
1248
+ fetched_at: new Date().toISOString(),
1249
+ }
1250
+ }
1251
+
1252
+ /* -------------------------------------------------------------------------- */
1253
+ /* BOM audit */
1254
+ /* -------------------------------------------------------------------------- */
1255
+
1256
+ /** @param {unknown} input @returns {{ line: number, code: string, qty: number, designators?: string, comment?: string }[]} */
1257
+ /** Normalise a per-line placement value to `smt` | `hand` | `dnp`. */
1258
+ export function normalizePlace(value) {
1259
+ const text = String(value ?? '').trim().toLowerCase()
1260
+ if (text === '') return 'smt'
1261
+ if (/dnp|do ?not|不贴|未贴|不焊|no ?populate|不上件/.test(text)) return 'dnp'
1262
+ if (/hand|手焊|手工|烙铁|manual/.test(text)) return 'hand'
1263
+ if (/smt|贴片|机贴|回流|reflow/.test(text)) return 'smt'
1264
+ return 'smt'
1265
+ }
1266
+
1267
+ /** @param {unknown} input @returns {{ rows: { line: number, code: string, qty: number, designators?: string, comment?: string, place?: string }[], skipped: { line: number, raw?: string, comment?: string, designators?: string }[] }} */
1268
+ function parseBomInput(input) {
1269
+ if (typeof input === 'string') {
1270
+ const head = input.slice(0, 8)
1271
+ if (head.startsWith('PK\u0003\u0004') || head.startsWith('PK\u0005\u0006') || head.startsWith('<?xml')) {
1272
+ throw new Error(
1273
+ 'audit_lcsc_bom 收到的是 xlsx/二进制内容而不是 BOM 文本:请先在 Excel 里「另存为」CSV/TSV(或转成 CSV 文本)再传入',
1274
+ )
1275
+ }
1276
+ const text = input.trim()
1277
+ if (text === '') throw new Error('audit_lcsc_bom requires a non-empty BOM string')
1278
+ if (text.startsWith('[') || text.startsWith('{')) {
1279
+ let parsed
1280
+ try {
1281
+ parsed = JSON.parse(text)
1282
+ } catch {
1283
+ parsed = undefined
1284
+ }
1285
+ if (Array.isArray(parsed)) return { rows: parseBomRows(parsed), skipped: [] }
1286
+ if (isRecord(parsed) && Array.isArray(parsed.rows)) return { rows: parseBomRows(parsed.rows), skipped: [] }
1287
+ }
1288
+ const detailed = parseBomCsvDetailed(text)
1289
+ return {
1290
+ skipped: detailed.skipped,
1291
+ rows: detailed.rows.map((row) => ({
1292
+ line: row.line,
1293
+ code: normalizeCode(row.code) ?? '',
1294
+ qty: Math.max(1, asInteger(row.qty) ?? 1),
1295
+ designators: row.designators,
1296
+ comment: row.comment,
1297
+ place: row.place,
1298
+ })),
1299
+ }
1300
+ }
1301
+ if (Array.isArray(input)) return { rows: parseBomRows(input), skipped: [] }
1302
+ throw new Error('audit_lcsc_bom requires `bom` as CSV text or an array of {lcsc/code, qty, place} rows')
1303
+ }
1304
+
1305
+ /** @param {unknown[]} rows */
1306
+ function parseBomRows(rows) {
1307
+ const result = []
1308
+ for (const row of rows) {
1309
+ if (!isRecord(row)) continue
1310
+ const code = normalizeCode(row.lcsc ?? row.code ?? row.LCSC)
1311
+ const qty = Math.max(1, asInteger(row.qty ?? row.quantity ?? 1) ?? 1)
1312
+ result.push({
1313
+ line: result.length + 1,
1314
+ code: code ?? '',
1315
+ qty,
1316
+ designators: asString(row.designators ?? row.refs ?? row.ref),
1317
+ comment: asString(row.comment ?? row.description ?? row.model),
1318
+ place: asString(row.place ?? row.mount ?? row.assembly),
1319
+ })
1320
+ }
1321
+ return result
1322
+ }
1323
+
1324
+ /**
1325
+ * Merge BOM rows that reference the same LCSC code: quantities and designators
1326
+ * are combined, the feeder fee is charged once per unique part, and the price
1327
+ * tier is taken at the combined order quantity.
1328
+ */
1329
+ function mergeBomRows(rows) {
1330
+ /** @type {Map<string, { code: string, qty: number, designators: string[], comments: string[], places: string[], lines: { line: number, qty: number, designators?: string, place: string }[] }>} */
1331
+ const byCode = new Map()
1332
+ for (const row of rows) {
1333
+ const place = normalizePlace(row.place)
1334
+ const key = `${row.code}|${place}`
1335
+ const entry = byCode.get(key)
1336
+ if (entry === undefined) {
1337
+ byCode.set(key, {
1338
+ code: row.code,
1339
+ qty: row.qty,
1340
+ designators: row.designators === undefined ? [] : [row.designators],
1341
+ comments: row.comment === undefined ? [] : [row.comment],
1342
+ places: [place],
1343
+ lines: [{ line: row.line, qty: row.qty, designators: row.designators, place }],
1344
+ })
1345
+ continue
1346
+ }
1347
+ entry.qty += row.qty
1348
+ if (row.designators !== undefined) entry.designators.push(row.designators)
1349
+ if (row.comment !== undefined) entry.comments.push(row.comment)
1350
+ entry.places.push(place)
1351
+ entry.lines.push({ line: row.line, qty: row.qty, designators: row.designators, place })
1352
+ }
1353
+ for (const entry of byCode.values()) {
1354
+ // groups are keyed by code|place, so every entry has a single place
1355
+ entry.place = entry.places[0] ?? 'smt'
1356
+ }
1357
+ return [...byCode.values()]
1358
+ }
1359
+
1360
+ /**
1361
+ * Build a synthetic need from an existing part so we can search for a base
1362
+ * library alternative with the same identity parameters.
1363
+ * @param {Record<string, unknown>} item
1364
+ * @param {ReturnType<typeof parseNeed>} originalNeed
1365
+ */
1366
+ function needFromItem(item, originalNeed) {
1367
+ const requirements = []
1368
+ for (const attribute of attributesOf(item)) {
1369
+ const name = attribute.name
1370
+ const value = attribute.value
1371
+ const pushParsed = (kind, comparator) => {
1372
+ for (const parsed of parseQuantitiesOfKind(value, kind)) {
1373
+ requirements.push({ kind, value: parsed, unit: '', comparator, raw: `${name}=${value}` })
1374
+ }
1375
+ }
1376
+ if (/^capacitance$/i.test(name)) pushParsed('capacitance', '=')
1377
+ else if (/^resistance$/i.test(name)) pushParsed('resistance', '=')
1378
+ else if (/^inductance$/i.test(name)) pushParsed('inductance', '=')
1379
+ else if (/^output voltage$/i.test(name)) pushParsed('voltage', '=')
1380
+ else if (/voltage rating|reverse voltage|drain-source voltage|maximum input voltage/i.test(name)) pushParsed('voltage', '>=')
1381
+ else if (/output current|forward current|current - output/i.test(name)) pushParsed('current', '>=')
1382
+ }
1383
+ return {
1384
+ ...originalNeed,
1385
+ requirements,
1386
+ directCode: undefined,
1387
+ modelGuess: false,
1388
+ categories: originalNeed.categories,
1389
+ package: originalNeed.package ?? asString(item.package),
1390
+ searchKeyword: [
1391
+ asString(item.package),
1392
+ ...attributesOf(item)
1393
+ .filter((attribute) => /capacitance|resistance|voltage rating|output voltage|output current|inductance/i.test(attribute.name))
1394
+ .slice(0, 3)
1395
+ .map((attribute) => attribute.value),
1396
+ ].filter(Boolean).join(' ') || String(item.model ?? item.code ?? ''),
1397
+ }
1398
+ }
1399
+
1400
+ /**
1401
+ * Find one basic-library substitute for an extended part, with the net saving
1402
+ * (feeder fee avoided minus component price delta at the order quantity).
1403
+ * "换成基础库不一定更便宜" is why the caller needs the net number, not just
1404
+ * "free feeder loading".
1405
+ * @param {ReturnType<typeof createLcscService>} service
1406
+ * @param {Record<string, unknown>} item
1407
+ * @param {ReturnType<typeof parseNeed>} originalNeed
1408
+ * @param {{ signal?: AbortSignal, orderQty?: number, feeUsd?: number }} [options]
1409
+ */
1410
+ export async function findAlternative(service, item, originalNeed, options = {}) {
1411
+ const packageName = asString(item.package)
1412
+ const orderQty = Math.max(1, asInteger(options.orderQty) ?? 1)
1413
+ const feeUsd = asNumber(options.feeUsd) ?? EXTENDED_PART_FEE_USD
1414
+ const originalTier = pickTier(item.priceUsdTiers, orderQty)
1415
+ const originalUnit = originalTier?.unit
1416
+ const tryCodes = []
1417
+ const alternatives = asString(item.alternatives)
1418
+ if (alternatives !== undefined) {
1419
+ for (const match of alternatives.matchAll(/C\d{4,11}/gi)) tryCodes.push(match[0].toUpperCase())
1420
+ }
1421
+ const candidates = []
1422
+ for (const code of tryCodes.slice(0, 3)) {
1423
+ const result = await service.searchRaw(code, { pageSize: 5, signal: options.signal })
1424
+ const hit = result.items.find((candidate) => String(candidate.code) === code)
1425
+ if (hit !== undefined && String(hit.libraryType).toLowerCase() === 'base') candidates.push(hit)
1426
+ }
1427
+ if (candidates.length === 0) {
1428
+ const need = needFromItem(item, originalNeed)
1429
+ const categoryName = asString(item.category)
1430
+ /** @param {Record<string, unknown>[]} items */
1431
+ const collect = (items) => {
1432
+ for (const candidate of items) {
1433
+ if (String(candidate.libraryType).toLowerCase() !== 'base') continue
1434
+ // A substitute must be the same kind of device; a different package is
1435
+ // allowed but flagged (footprint_compatible=false means a PCB respin).
1436
+ if (categoryName !== undefined) {
1437
+ const candidateCategory = asString(candidate.category)
1438
+ if (candidateCategory === undefined || candidateCategory.toLowerCase() !== categoryName.toLowerCase()) continue
1439
+ }
1440
+ if ((asInteger(candidate.stock) ?? 0) <= 0) continue
1441
+ if (candidateMatches(candidate, need).ok) candidates.push(candidate)
1442
+ }
1443
+ }
1444
+ // First try same package + identity values, then relax the package prefix so
1445
+ // e.g. 0603 ferrite beads can surface an 0805 base part (flagged as needing
1446
+ // a footprint change) instead of reporting "no alternative" wrongly.
1447
+ const withPackage = await service.searchRaw(need.searchKeyword, {
1448
+ pageSize: 50,
1449
+ libraryType: 'base',
1450
+ signal: options.signal,
1451
+ })
1452
+ collect(withPackage.items)
1453
+ if (candidates.length === 0) {
1454
+ const withoutPackageKeyword = attributesOf(item)
1455
+ .filter((attribute) => /capacitance|resistance|voltage rating|output voltage|output current|inductance|impedance/i.test(attribute.name))
1456
+ .slice(0, 3)
1457
+ .map((attribute) => attribute.value)
1458
+ .filter(Boolean)
1459
+ .join(' ')
1460
+ if (withoutPackageKeyword !== '' && withoutPackageKeyword !== need.searchKeyword) {
1461
+ const withoutPackage = await service.searchRaw(withoutPackageKeyword, {
1462
+ pageSize: 50,
1463
+ libraryType: 'base',
1464
+ signal: options.signal,
1465
+ })
1466
+ collect(withoutPackage.items)
1467
+ }
1468
+ }
1469
+ }
1470
+ if (candidates.length === 0) return undefined
1471
+ const scored = candidates.map((candidate) => {
1472
+ const context = { assembly: 'smt', quantity: 1, boards: 1, orderQty, sort: 'cost', feeUsd }
1473
+ return scoreCandidate(candidate, originalNeed, context)
1474
+ })
1475
+ /** @type {{ score: ReturnType<typeof scoreCandidate>, net: number, componentDelta: number }[]} */
1476
+ const evaluated = []
1477
+ for (const candidate of scored) {
1478
+ const tier = pickTier(candidate.item.priceUsdTiers, orderQty)
1479
+ const unit = tier?.unit
1480
+ const componentDelta = originalUnit !== undefined && unit !== undefined ? (unit - originalUnit) * orderQty : 0
1481
+ const net = feeUsd - componentDelta
1482
+ evaluated.push({ score: candidate, net, componentDelta })
1483
+ }
1484
+ evaluated.sort((left, right) => right.net - left.net)
1485
+ const best = evaluated[0]
1486
+ return {
1487
+ lcsc: best.score.item.code,
1488
+ model: best.score.item.model,
1489
+ package: best.score.item.package,
1490
+ library: 'base',
1491
+ stock: asInteger(best.score.item.stock) ?? 0,
1492
+ unit_usd: best.score.unitUsd === undefined ? undefined : money(best.score.unitUsd),
1493
+ fee_saved_usd: money(feeUsd),
1494
+ component_delta_usd: money(best.componentDelta),
1495
+ net_saving_usd: money(best.net),
1496
+ footprint_compatible: footprintCompatible(packageName, asString(best.score.item.package)),
1497
+ }
1498
+ }
1499
+
1500
+ /**
1501
+ * Audit an existing BOM.
1502
+ * @param {ReturnType<typeof createLcscService>} service
1503
+ * @param {Record<string, unknown>} args
1504
+ * @param {{ signal?: AbortSignal }} [options]
1505
+ */
1506
+ export async function auditBom(service, args, options = {}) {
1507
+ const { rows, skipped } = parseBomInput(args.bom)
1508
+ if (rows.length === 0) {
1509
+ throw new Error(
1510
+ `audit_lcsc_bom parsed 0 rows from the BOM input${skipped.length > 0 ? `(其中 ${skipped.length} 行缺少可识别的 LCSC 编号)` : ''}`,
1511
+ )
1512
+ }
1513
+ const assembly = args.assembly === 'smt' ? 'smt' : 'hand'
1514
+ const boards = clampInteger(args.boards, 1, 100000, 1)
1515
+ let includeCny = args.include_cny === true
1516
+ const suggestAlternatives = args.suggest_alternatives !== false
1517
+ const exportBom = args.export_bom === true
1518
+ const region = args.region === 'cn' ? 'cn' : 'global'
1519
+ const settings = service.settings ?? {}
1520
+ const feeUsd = asNumber(settings.extendedPartFeeUsd) ?? EXTENDED_PART_FEE_USD
1521
+ const setupFeeUsd = asNumber(settings.setupFeeUsd) ?? 0
1522
+ const stencilFeeUsd = asNumber(settings.stencilFeeUsd) ?? 0
1523
+ const usdToCnyFactor = asNumber(settings.usdToCnyFactor) ?? 6.0
1524
+ const notes = []
1525
+ const hints = []
1526
+ const errors = []
1527
+
1528
+ const merged = mergeBomRows(rows)
1529
+ const codes = [...new Set(merged.map((entry) => entry.code).filter((code) => code !== ''))]
1530
+ if (args.include_cny === undefined && assembly === 'hand' && merged.length <= 8) includeCny = true
1531
+ if (args.include_cny === undefined && assembly === 'hand' && merged.length > 8) {
1532
+ hints.push('BOM 较大,默认跳过 Chrome 逐料取人民币价(较慢):设 include_cny=true 可强制获取真实 CNY(有 24h 缓存)。')
1533
+ }
1534
+
1535
+ // ---- JLCPCB library data (one exact lookup per unique code) --------------
1536
+ const jlcEntries = await mapLimit(codes, 3, async (code) => {
1537
+ try {
1538
+ const result = await service.searchRaw(code, { pageSize: 5, signal: options.signal })
1539
+ const hit = result.items.find((candidate) => String(candidate.code).toUpperCase() === code)
1540
+ return [code, hit, hit === undefined ? 'not found in JLCPCB library' : undefined]
1541
+ } catch (error) {
1542
+ return [code, undefined, error instanceof Error ? error.message : String(error)]
1543
+ }
1544
+ })
1545
+ /** @type {Map<string, Record<string, unknown> | undefined>} */
1546
+ const jlcByCode = new Map()
1547
+ for (const entry of jlcEntries) {
1548
+ const [code, hit, error] = entry
1549
+ jlcByCode.set(code, hit)
1550
+ if (hit === undefined && error !== undefined) errors.push(`${code}: ${error}`)
1551
+ }
1552
+
1553
+ // ---- LCSC domestic CNY data (Chrome adapter, capped) ----------------------
1554
+ /** @type {Map<string, Record<string, unknown>>} */
1555
+ const cnyByCode = new Map()
1556
+ if (includeCny && codes.length > 0) {
1557
+ const targets = codes.slice(0, MAX_AUDIT_CNY_LOOKUPS)
1558
+ const budgetMs = asNumber(settings.cnQueryBudgetMs) ?? 90000
1559
+ const deadline = budgetMs > 0 ? Date.now() + budgetMs : Number.POSITIVE_INFINITY
1560
+ const results = await mapLimit(targets, 2, async (code) => {
1561
+ if (Date.now() > deadline) return { ok: false, reason: 'cn-budget-exceeded' }
1562
+ try {
1563
+ if (typeof service.cn?.query === 'function') {
1564
+ const cn = await service.cn.query(code)
1565
+ if (cn?.ok === true) return cn
1566
+ }
1567
+ if (typeof service.cny === 'function') return await service.cny(code, options.signal)
1568
+ return { ok: false, reason: 'cny-adapter-unavailable' }
1569
+ } catch (error) {
1570
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
1571
+ }
1572
+ })
1573
+ results.forEach((result, index) => cnyByCode.set(targets[index], result))
1574
+ const covered = [...cnyByCode.values()].filter((result) => result?.ok === true).length
1575
+ const budgetHit = results.some((result) => result?.reason === 'cn-budget-exceeded')
1576
+ if (budgetHit) {
1577
+ hints.push(`人民币价查询达到时间预算(${Math.round(budgetMs / 1000)}s)后提前返回;已取到的结果已写入 24h 缓存,直接再调用一次会更快补齐剩余料号。`)
1578
+ }
1579
+ notes.push(
1580
+ `国内站人民币价:${covered}/${targets.length} 个料取到真实 CNY(Chrome 渲染 + 24h 缓存);未取到的行回退为批量档 USD×${usdToCnyFactor} 估算并标 estimated=true。`,
1581
+ )
1582
+ if (targets.length < codes.length) {
1583
+ notes.push(`CNY 查询上限 ${MAX_AUDIT_CNY_LOOKUPS} 个料号,其余行只有 USD 参考;可用 include_cny 配合缓存分批复核。`)
1584
+ }
1585
+ }
1586
+
1587
+ // ---- Build rows -----------------------------------------------------------
1588
+ const auditRows = merged.map((entry) => {
1589
+ const jlc = jlcByCode.get(entry.code)
1590
+ const cn = cnyByCode.get(entry.code)
1591
+ const place = entry.place
1592
+ const qty = entry.qty
1593
+ const orderQty = qty * boards
1594
+ const rawLibrary = jlc === undefined ? undefined : String(jlc.libraryType ?? '').toLowerCase()
1595
+ const library = rawLibrary === 'base' ? 'base' : rawLibrary === 'expand' ? 'expand' : undefined
1596
+ const tier = jlc === undefined ? undefined : pickTier(jlc.priceUsdTiers, orderQty)
1597
+ const unitUsd = tier?.unit
1598
+ const cnOk = cn?.ok === true
1599
+ const unitCny = cnOk ? asNumber(cn.unit) : undefined
1600
+ const stockSmt = jlc === undefined ? undefined : asInteger(jlc.stock)
1601
+ const stockLcsc = cnOk ? asInteger(cn.stock) : undefined
1602
+ const bulkUsd = jlc === undefined ? undefined : bulkTierUsd(jlc)
1603
+ const unitCnyEstimate = unitCny === undefined ? estimateCnyFromUsd(bulkUsd, usdToCnyFactor) : undefined
1604
+ const billableSmt = assembly === 'smt' && place === 'smt' && jlc !== undefined
1605
+ const billableHand = place !== 'dnp' && (assembly === 'hand' || place === 'hand')
1606
+ const countsForUsd = assembly === 'hand' ? billableHand : billableSmt
1607
+ const subtotalUsd = countsForUsd && unitUsd !== undefined ? unitUsd * orderQty : undefined
1608
+ const subtotalCny = unitCny !== undefined && (billableSmt || billableHand) ? unitCny * orderQty : undefined
1609
+ const flags = []
1610
+ if (place === 'dnp') flags.push('DNP:不贴/不计元件与上料费')
1611
+ else if (assembly === 'smt' && place === 'hand') flags.push('手焊:不计入 SMT 贴片与上料费')
1612
+ if (jlc === undefined && cnOk) flags.push('不在嘉立创 SMT 库,但立创商城有货(手焊可买)')
1613
+ if (library === 'expand' && billableSmt) flags.push(`扩展库:$${feeUsd}/料号上料费`)
1614
+ if (library === 'expand' && !billableSmt) flags.push('扩展库(手焊/DNP 无上料费)')
1615
+ const stockForMode = assembly === 'smt' ? stockSmt : (stockLcsc ?? stockSmt)
1616
+ if (place !== 'dnp' && stockForMode !== undefined) {
1617
+ if (stockForMode <= 0) flags.push('无库存')
1618
+ else if (stockForMode < orderQty) flags.push(`库存不足(需 ${orderQty})`)
1619
+ }
1620
+ if (tier === undefined && jlc !== undefined) flags.push('无价格数据')
1621
+ if (place !== 'dnp' && jlc === undefined && !cnOk) flags.push('嘉立创/立创均未查到')
1622
+ let status = 'verified'
1623
+ if (place === 'dnp') status = 'DNP'
1624
+ else if (jlc === undefined && !cnOk) status = 'NOT-FOUND'
1625
+ else if (assembly === 'smt' && place !== 'smt') status = place === 'hand' ? 'HAND' : 'DNP'
1626
+ else if (stockForMode === 0) status = 'NO-STOCK'
1627
+ return {
1628
+ lcsc: entry.code || '(missing)',
1629
+ designators: entry.designators.length > 0 ? entry.designators : undefined,
1630
+ qty,
1631
+ lines: entry.lines,
1632
+ place,
1633
+ status,
1634
+ model: jlc === undefined ? (cnOk ? cn.model : undefined) : jlc.model,
1635
+ brand: jlc === undefined ? (cnOk ? cn.brand : undefined) : jlc.brand,
1636
+ package: jlc === undefined ? (cnOk ? cn.package : undefined) : jlc.package,
1637
+ library,
1638
+ billable_smt: billableSmt,
1639
+ billable: countsForUsd,
1640
+ stock: stockForMode,
1641
+ stock_smt: stockSmt,
1642
+ stock_lcsc: stockLcsc,
1643
+ stock_source: assembly === 'smt' ? 'jlcpcb-smt' : (stockLcsc !== undefined ? 'lcsc_cn_render' : 'jlcpcb-smt'),
1644
+ coverage: orderQty > 0 && stockForMode !== undefined ? Math.min(1, Math.round((stockForMode / orderQty) * 100) / 100) : undefined,
1645
+ price_source: jlc === undefined ? (cnOk ? 'lcsc_cn_render' : undefined) : 'jlcpcb-smt',
1646
+ unit_usd: unitUsd === undefined ? undefined : money(unitUsd),
1647
+ price_usd_tier: tier?.label,
1648
+ price_usd_ladder: jlc === undefined ? undefined : (jlc.priceUsdTiers ?? []).slice(0, 8).map((item) => ({ qty: item.qty, unit: money(item.unit) })),
1649
+ subtotal_usd: subtotalUsd === undefined ? undefined : money(subtotalUsd),
1650
+ unit_cny: unitCny === undefined ? undefined : money(unitCny),
1651
+ price_cny_source: unitCny === undefined ? undefined : (cn.source ?? 'lcsc_cn_render'),
1652
+ price_cny_estimated: false,
1653
+ subtotal_cny: subtotalCny === undefined ? undefined : money(subtotalCny),
1654
+ unit_cny_estimate: unitCnyEstimate === undefined ? undefined : money(unitCnyEstimate),
1655
+ subtotal_cny_estimate: unitCnyEstimate === undefined ? undefined : money(unitCnyEstimate * orderQty),
1656
+ fetched_at: new Date().toISOString(),
1657
+ flags,
1658
+ }
1659
+ })
1660
+
1661
+ // ---- Summary --------------------------------------------------------------
1662
+ const smtRows = auditRows.filter((row) => row.billable_smt)
1663
+ const handRows = auditRows.filter((row) => row.billable_smt !== true && row.place !== 'dnp' && row.status !== 'NOT-FOUND')
1664
+ const dnpRows = auditRows.filter((row) => row.place === 'dnp')
1665
+ const extendedUnique = new Set(smtRows.filter((row) => row.library === 'expand').map((row) => row.lcsc))
1666
+ const costRows = assembly === 'hand' ? handRows : smtRows
1667
+ const componentUsd = costRows.reduce((sum, row) => sum + (row.subtotal_usd ?? 0), 0)
1668
+ const componentCny = costRows.reduce((sum, row) => sum + (row.subtotal_cny ?? 0), 0)
1669
+ const componentCnyEstimate = costRows.reduce((sum, row) => sum + (row.subtotal_cny_estimate ?? 0), 0)
1670
+ const handUsd = handRows.reduce((sum, row) => sum + (row.unit_usd ?? 0) * row.qty * boards, 0)
1671
+ const handCny = handRows.reduce((sum, row) => sum + (row.subtotal_cny ?? 0), 0)
1672
+ const dnpUsd = dnpRows.reduce((sum, row) => sum + (row.unit_usd ?? 0) * (row.qty * boards), 0)
1673
+ const extendedFee = assembly === 'smt' ? extendedUnique.size * feeUsd : 0
1674
+ const setupFees = setupFeeUsd + stencilFeeUsd
1675
+ const totalUsd = componentUsd + extendedFee + setupFees
1676
+ const cnyCovered = auditRows.filter((row) => row.unit_cny !== undefined).length
1677
+
1678
+ // Batch amortisation table: price tiers are re-evaluated per scenario.
1679
+ const scenarioBoards = [...new Set([20, 100, 300, 1000, boards])].filter((value) => value <= 100000).sort((left, right) => left - right)
1680
+ const costTable = scenarioBoards.map((scenario) => {
1681
+ let scenarioComponents = 0
1682
+ for (const row of costRows) {
1683
+ const jlc = jlcByCode.get(row.lcsc)
1684
+ if (jlc === undefined) continue
1685
+ const tier = pickTier(jlc.priceUsdTiers, row.qty * scenario)
1686
+ if (tier !== undefined) scenarioComponents += tier.unit * row.qty * scenario
1687
+ }
1688
+ const scenarioFee = extendedUnique.size * feeUsd
1689
+ const scenarioTotal = scenarioComponents + scenarioFee + setupFees
1690
+ return {
1691
+ boards: scenario,
1692
+ components_usd: money(scenarioComponents),
1693
+ extended_fee_usd: money(scenarioFee),
1694
+ setup_fees_usd: money(setupFees),
1695
+ total_usd: money(scenarioTotal),
1696
+ per_board_usd: money(scenarioTotal / scenario),
1697
+ extended_fee_per_board_usd: money(scenarioFee / scenario),
1698
+ }
1699
+ })
1700
+
1701
+ // ---- Alternatives & net-saving optimisation ------------------------------
1702
+ const optimize = {
1703
+ extended_parts_before: extendedUnique.size,
1704
+ extended_parts_after: extendedUnique.size,
1705
+ fee_before_usd: money(assembly === 'smt' ? extendedUnique.size * feeUsd : 0),
1706
+ fee_after_usd: money(assembly === 'smt' ? extendedUnique.size * feeUsd : 0),
1707
+ saving_usd_per_order: 0,
1708
+ actions: [],
1709
+ alternatives: [],
1710
+ }
1711
+ if (suggestAlternatives && assembly === 'smt') {
1712
+ const originalNeed = parseNeed('')
1713
+ const expandCodes = [...extendedUnique].slice(0, MAX_ALTERNATIVE_LOOKUPS)
1714
+ const pairs = await mapLimit(expandCodes, 2, async (code) => {
1715
+ const item = jlcByCode.get(code)
1716
+ const entry = smtRows.find((candidate) => candidate.lcsc === code)
1717
+ if (item === undefined || entry === undefined) return [code, undefined]
1718
+ const orderQty = entry.qty * boards
1719
+ try {
1720
+ return [code, await findAlternative(service, item, originalNeed, { signal: options.signal, orderQty, feeUsd })]
1721
+ } catch {
1722
+ return [code, undefined]
1723
+ }
1724
+ })
1725
+ let saving = 0
1726
+ let replaced = 0
1727
+ const alternativesByCode = new Map()
1728
+ for (const [code, alternative] of pairs) {
1729
+ if (alternative === undefined) continue
1730
+ alternativesByCode.set(code, alternative)
1731
+ optimize.alternatives.push({ from: code, ...alternative })
1732
+ if (alternative.net_saving_usd > 0) {
1733
+ saving += alternative.net_saving_usd
1734
+ replaced += 1
1735
+ const packageNote = alternative.footprint_compatible === false
1736
+ ? `封装不同(${jlcByCode.get(code)?.package ?? '?'}→${alternative.package ?? '?'},需改 PCB)`
1737
+ : '封装兼容'
1738
+ optimize.actions.push(
1739
+ `${code}→${alternative.lcsc}:${packageNote},上料费省 $${feeUsd},元件成本变化 $${alternative.component_delta_usd},净省 $${alternative.net_saving_usd}`,
1740
+ )
1741
+ }
1742
+ }
1743
+ optimize.extended_parts_after = extendedUnique.size - replaced
1744
+ optimize.fee_after_usd = money(Math.max(0, optimize.extended_parts_after * feeUsd))
1745
+ optimize.fee_before_usd = money(extendedUnique.size * feeUsd)
1746
+ optimize.saving_usd_per_order = money(saving)
1747
+ for (const row of auditRows) {
1748
+ if (row.place === 'hand' && assembly === 'smt') optimize.actions.push(`${row.lcsc} place=hand:不占上料费(已从 SMT 费用中剔除)`)
1749
+ }
1750
+ for (const row of dnpRows) optimize.actions.push(`${row.lcsc} DNP:不贴片、不计元件与上料费`)
1751
+ if (expandCodes.length < extendedUnique.size) {
1752
+ notes.push(`替代料建议仅检查了 ${expandCodes.length}/${extendedUnique.size} 个扩展库料号(每次最多 ${MAX_ALTERNATIVE_LOOKUPS} 个)。`)
1753
+ }
1754
+ if (alternativesByCode.size > 0) {
1755
+ for (const row of auditRows) {
1756
+ const alternative = alternativesByCode.get(row.lcsc)
1757
+ if (alternative !== undefined) row.alternative = alternative
1758
+ }
1759
+ }
1760
+ }
1761
+
1762
+ // ---- Optional JLCPCB BOM CSV export ---------------------------------------
1763
+ let bomCsv
1764
+ if (exportBom) {
1765
+ const lines = ['Comment,Designator,Footprint,LCSC Part #']
1766
+ for (const row of smtRows) {
1767
+ if (row.lcsc === '(missing)') continue
1768
+ const designators = Array.isArray(row.designators) ? row.designators.join(',') : ''
1769
+ const comment = row.model ?? row.lcsc
1770
+ const footprint = row.package ?? ''
1771
+ lines.push([comment, designators, footprint, row.lcsc].map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(','))
1772
+ }
1773
+ bomCsv = lines.join('\n')
1774
+ }
1775
+
1776
+ // ---- Notes ---------------------------------------------------------------
1777
+ notes.push(
1778
+ assembly === 'smt'
1779
+ ? 'assembly=smt:价格与库存取嘉立创 SMT 元件库(USD 阶梯价按采购数量取档);上料费只计 smt 且为扩展库的唯一料号。'
1780
+ : 'assembly=hand:不计嘉立创上料费;价格优先取立创商城国内站人民币价(Chrome 渲染),未取到的行给 estimated 估算。',
1781
+ )
1782
+ notes.push('USD 与 CNY 并列展示、不互相换算;每行带 price_source / fetched_at / estimated。')
1783
+ notes.push(
1784
+ assembly === 'smt'
1785
+ ? `上料费按每唯一扩展库料号 $${feeUsd}/单估算;工程费 $${setupFeeUsd}、钢网费 $${stencilFeeUsd} 已单列(默认 0,可在插件 config 配置)。`
1786
+ : `上料费 0;工程费 $${setupFeeUsd}、钢网费 $${stencilFeeUsd} 可配置。`,
1787
+ )
1788
+ if (region === 'cn') {
1789
+ notes.push('region=cn:国内嘉立创 SMT 的费用规则以官方计价页为准;本工具仍按 USD 上料费估算并显式标注。')
1790
+ }
1791
+ notes.push('优选扩展库(Preferred Extended)分类未在公开接口暴露,若需免上料费请到嘉立创 SMT 下单页确认。')
1792
+ if (skipped.length > 0) {
1793
+ const list = skipped
1794
+ .slice(0, 30)
1795
+ .map((row) => {
1796
+ const label = asString(row.comment) ?? asString(row.raw) ?? ''
1797
+ return `${row.designators === undefined ? '' : `${row.designators} `}${label}`.trim()
1798
+ })
1799
+ .filter((text) => text !== '')
1800
+ .join(';')
1801
+ notes.push(`BOM 中有 ${skipped.length} 行没有可识别的 LCSC 编号,未计入成本:${list}${skipped.length > 30 ? ' 等' : ''}`)
1802
+ hints.push('以上行需先选型(补上 LCSC 编号)再重新审计,否则单板成本会偏低。')
1803
+ }
1804
+ if (costTable.length > 0) {
1805
+ const scenario = costTable.find((row) => row.boards === 100) ?? costTable[costTable.length - 1]
1806
+ notes.push(`批量摊销参考:${scenario.boards} 片时约 $${scenario.per_board_usd}/片(含上料费 $${scenario.extended_fee_per_board_usd}/片 + 工程/钢网 $${money(setupFees / scenario.boards)}/片)。`)
1807
+ }
1808
+
1809
+ const summary = {
1810
+ lines: auditRows.length,
1811
+ lines_in_bom: rows.length,
1812
+ skipped_lines: skipped.length,
1813
+ smt_lines: smtRows.length,
1814
+ hand_lines: handRows.length,
1815
+ dnp_lines: dnpRows.length,
1816
+ unique_parts: codes.length,
1817
+ verified: auditRows.filter((row) => row.status === 'verified').length,
1818
+ no_stock: auditRows.filter((row) => row.status === 'NO-STOCK').length,
1819
+ not_found: auditRows.filter((row) => row.status === 'NOT-FOUND').length,
1820
+ base_parts: smtRows.filter((row) => row.library === 'base').length,
1821
+ expand_parts: extendedUnique.size,
1822
+ library_unknown: smtRows.filter((row) => row.library === undefined).length,
1823
+ extended_fee_per_order_usd: money(extendedFee),
1824
+ setup_fee_usd: money(setupFeeUsd),
1825
+ stencil_fee_usd: money(stencilFeeUsd),
1826
+ components_usd: money(componentUsd),
1827
+ components_per_board_usd: money(componentUsd / Math.max(1, boards)),
1828
+ total_usd: money(totalUsd),
1829
+ total_per_board_usd: money(totalUsd / Math.max(1, boards)),
1830
+ hand_components_usd_reference: money(handUsd),
1831
+ hand_components_cny: money(handCny),
1832
+ dnp_components_usd_excluded: money(dnpUsd),
1833
+ cny_covered_lines: cnyCovered,
1834
+ components_cny: money(componentCny),
1835
+ cny_estimated_lines: auditRows.filter((row) => row.unit_cny_estimate !== undefined).length,
1836
+ components_cny_estimate: money(componentCnyEstimate),
1837
+ cost_table: costTable,
1838
+ }
1839
+
1840
+ return {
1841
+ assembly,
1842
+ boards,
1843
+ region,
1844
+ currency: includeCny ? 'USD + CNY(并列,不混算)' : 'USD(CNY 为参考估算)',
1845
+ summary,
1846
+ rows: auditRows,
1847
+ optimize,
1848
+ bom_csv: bomCsv,
1849
+ skipped: skipped.slice(0, 50),
1850
+ errors,
1851
+ hints,
1852
+ notes,
1853
+ fetched_at: new Date().toISOString(),
1854
+ }
1855
+ }
1856
+
1857
+ /* -------------------------------------------------------------------------- */
1858
+ /* Rendering */
1859
+ /* -------------------------------------------------------------------------- */
1860
+
1861
+ /** @param {Record<string, unknown>} value */
1862
+ export function renderFindText(value) {
1863
+ const candidates = Array.isArray(value.candidates) ? value.candidates : []
1864
+ const header =
1865
+ `Find "${value.need}" [${value.assembly}] qty ${value.quantity}/board x ${value.boards} board(s)` +
1866
+ (value.exact === true ? ' (exact part query)' : '') +
1867
+ (value.relaxed === true ? ` (relaxed stage ${value.relax_stage})` : '') +
1868
+ ` => ${candidates.length} candidate(s)`
1869
+ const lines = [header]
1870
+ for (const candidate of candidates) {
1871
+ if (!isRecord(candidate)) continue
1872
+ const priceUsd = isRecord(candidate.price_usd) ? `USD ${candidate.price_usd.unit} @${candidate.price_usd.tier}` : 'USD n/a'
1873
+ const priceCny = isRecord(candidate.price_cny)
1874
+ ? `CNY ${candidate.price_cny.unit}${candidate.price_cny.estimated === true ? '(est.)' : ''}`
1875
+ : 'CNY n/a'
1876
+ lines.push(
1877
+ `- ${candidate.lcsc} | ${candidate.model ?? '?'} | ${candidate.package ?? '?'} | lib ${candidate.library} | ` +
1878
+ `stock smt ${candidate.stock_smt ?? '?'} / lcsc ${candidate.stock_lcsc ?? '?'} | ${priceUsd} | ${priceCny} | score ${candidate.score}`,
1879
+ )
1880
+ if (Array.isArray(candidate.reasons) && candidate.reasons.length > 0) lines.push(` ${candidate.reasons.join('; ')}`)
1881
+ if (Array.isArray(candidate.mismatch_reasons) && candidate.mismatch_reasons.length > 0) {
1882
+ lines.push(` 不匹配原因: ${candidate.mismatch_reasons.join('; ')}`)
1883
+ }
1884
+ }
1885
+ for (const hint of Array.isArray(value.hints) ? value.hints : []) lines.push(`hint: ${hint}`)
1886
+ for (const note of Array.isArray(value.notes) ? value.notes : []) lines.push(`note: ${note}`)
1887
+ return lines.join('\n')
1888
+ }
1889
+
1890
+ /** @param {Record<string, unknown>} value */
1891
+ export function renderAuditText(value) {
1892
+ const summary = isRecord(value.summary) ? value.summary : {}
1893
+ const rows = Array.isArray(value.rows) ? value.rows : []
1894
+ const optimize = isRecord(value.optimize) ? value.optimize : {}
1895
+ const lines = [
1896
+ `BOM audit [${value.assembly}] region=${value.region ?? 'global'} boards=${value.boards}: ` +
1897
+ `${summary.lines ?? 0} lines (${summary.smt_lines ?? 0} smt / ${summary.hand_lines ?? 0} hand / ${summary.dnp_lines ?? 0} dnp), ` +
1898
+ `${summary.unique_parts ?? 0} unique | base ${summary.base_parts ?? 0} / expand ${summary.expand_parts ?? 0}`,
1899
+ `components USD ${summary.components_usd ?? 0} + extended fee USD ${summary.extended_fee_per_order_usd ?? 0}` +
1900
+ ` + setup USD ${money((summary.setup_fee_usd ?? 0) + (summary.stencil_fee_usd ?? 0))} = USD ${summary.total_usd ?? 0}` +
1901
+ ` (USD ${summary.total_per_board_usd ?? 0}/board)` +
1902
+ (summary.cny_covered_lines > 0 ? ` | CNY ${summary.components_cny ?? 0} (${summary.cny_covered_lines} lines)` : '') +
1903
+ (summary.components_cny_estimate > 0 ? ` | CNY est. ≈ ${summary.components_cny_estimate}` : ''),
1904
+ ]
1905
+ for (const row of rows) {
1906
+ if (!isRecord(row)) continue
1907
+ const flags = Array.isArray(row.flags) && row.flags.length > 0 ? ` [${row.flags.join('; ')}]` : ''
1908
+ const designators = Array.isArray(row.designators) && row.designators.length > 0 ? ` {${row.designators.join(',')}}` : ''
1909
+ const priceUsd = row.unit_usd === undefined ? 'USD -' : `USD ${row.unit_usd}@${row.price_usd_tier ?? '?'}`
1910
+ const priceCny = row.unit_cny !== undefined
1911
+ ? `CNY ${row.unit_cny}`
1912
+ : row.unit_cny_estimate !== undefined ? `CNY≈${row.unit_cny_estimate}(est.)` : 'CNY -'
1913
+ const alternative = isRecord(row.alternative) && typeof row.alternative.lcsc === 'string'
1914
+ ? ` -> base alt: ${row.alternative.lcsc} (${row.alternative.model ?? '?'}, net $${row.alternative.net_saving_usd ?? '?'})`
1915
+ : ''
1916
+ lines.push(
1917
+ `${row.lcsc} x${row.qty}${designators} | ${row.place ?? '?'} | ${row.status} | lib ${row.library ?? '?'} | ` +
1918
+ `stock smt ${row.stock_smt ?? '?'} / lcsc ${row.stock_lcsc ?? '?'} | ${priceUsd} | ${priceCny}${flags}${alternative}`,
1919
+ )
1920
+ }
1921
+ const costTable = Array.isArray(summary.cost_table) ? summary.cost_table : []
1922
+ if (costTable.length > 0) {
1923
+ lines.push('批量摊销: ' + costTable.map((row) => `${row.boards}片≈$${row.per_board_usd}/片`).join(' | '))
1924
+ }
1925
+ if (Array.isArray(optimize.actions) && optimize.actions.length > 0) {
1926
+ lines.push(`优化建议(净省 $${optimize.saving_usd_per_order ?? 0}/单,扩展库 ${optimize.extended_parts_before}→${optimize.extended_parts_after}):`)
1927
+ for (const action of optimize.actions) lines.push(` * ${action}`)
1928
+ }
1929
+ for (const error of Array.isArray(value.errors) ? value.errors : []) lines.push(`error: ${error}`)
1930
+ for (const hint of Array.isArray(value.hints) ? value.hints : []) lines.push(`hint: ${hint}`)
1931
+ for (const note of Array.isArray(value.notes) ? value.notes : []) lines.push(`note: ${note}`)
1932
+ if (typeof value.bom_csv === 'string' && value.bom_csv !== '') lines.push('BOM CSV: 已生成(bom_csv 字段,可直接用于嘉立创 BOM 下单)')
1933
+ return lines.join('\n')
1934
+ }
1935
+
1936
+ /* -------------------------------------------------------------------------- */
1937
+ /* Tool definitions */
1938
+ /* -------------------------------------------------------------------------- */
1939
+
1940
+ const FIND_DESCRIPTION =
1941
+ 'Find LCSC / JLCPCB components that match a real requirement (package, capacitance, voltage rating, current, resistance, tolerance, dimensions, …) ' +
1942
+ 'and return up to 8 candidates ranked by practical cost-effectiveness: unit price at the actual order quantity, stock coverage, parameter match, and ' +
1943
+ 'basic-vs-extended JLCPCB library. ' +
1944
+ 'Part numbers and LCSC codes are detected first and queried exactly (e.g. "SMF5.0A", "AO3401A", "TP4054", "C307331"), so digit-letter patterns are never misread as values; ' +
1945
+ 'otherwise the requirement is parsed, filtered, and relaxed in stages when nothing matches (each relaxed candidate carries mismatch reasons). ' +
1946
+ 'Default assembly="hand" for DIY parts you buy and solder yourself (library type ignored; LCSC domestic CNY retail price via Chrome render when reachable, USD reference otherwise). ' +
1947
+ 'Set assembly="smt" only when the board will be assembled by JLCPCB: extended-library parts then add a USD 3 feeder fee per part number per order, amortized per board. ' +
1948
+ 'Every candidate reports stock_smt AND stock_lcsc, price_usd AND price_cny side by side (never mixed), the used price tier, and package/pin count. ' +
1949
+ 'When the real CNY price is unavailable, a clearly-labelled reference CNY estimate (bulk-tier USD × configurable factor, default 6.0) is included; USD stays the actual reference. ' +
1950
+ 'This is NOT a generic keyword search tool: it parses requirements, filters candidates and explains the ranking.'
1951
+
1952
+ const AUDIT_DESCRIPTION =
1953
+ 'Audit an existing BOM against the JLCPCB parts library and the LCSC domestic store: per line USD price (tier shown, taken at qty x boards), stock_smt, stock_lcsc, ' +
1954
+ 'basic-vs-extended library, placement (smt / hand / dnp), and for assembly="smt" the USD 3 extended-part feeder fee, plus basic-library alternative suggestions with NET saving. ' +
1955
+ 'Input `bom` as CSV/TSV text (LCSC codes required; header optional; optional place/mount column) or a JSON array like [{"lcsc":"C1581","qty":5,"place":"smt"}]. ' +
1956
+ 'Duplicate LCSC lines are merged (feeder fee charged once, tier taken at the combined quantity). DNP and hand lines are excluded from the SMT component/fee totals and reported separately. ' +
1957
+ 'Default assembly="hand" (no feeder fees); set assembly="smt" for JLCPCB assembly cost planning. ' +
1958
+ 'Outputs a batch amortisation table (20/100/300/1000 boards), an optimisation summary of net savings, and optionally a JLCPCB BOM CSV for ordering (export_bom=true). ' +
1959
+ 'Real CNY retail prices are fetched via headless Chrome (24h cache) when include_cny=true; otherwise a reference estimate from bulk-tier USD × the configurable factor is reported and marked estimated=true. ' +
1960
+ 'USD and CNY are never mixed into one total.'
1961
+
1962
+ /** Build the two DSH tool definitions bound to a service instance. */
1963
+ export function createToolDefinitions(service) {
1964
+ return [
1965
+ {
1966
+ name: 'find_lcsc_parts',
1967
+ description: FIND_DESCRIPTION,
1968
+ parameters: {
1969
+ type: 'object',
1970
+ additionalProperties: false,
1971
+ required: ['need'],
1972
+ properties: {
1973
+ need: {
1974
+ type: 'string',
1975
+ description: 'Requirement in natural language (Chinese or English), e.g. "0402 100nF 50V X7R capacitor", "3.3V LDO SOT-23-5 500mA", "10kΩ 0402 resistor", "TP4054", "C1581".',
1976
+ },
1977
+ assembly: {
1978
+ type: 'string',
1979
+ enum: ['hand', 'smt'],
1980
+ description: 'hand = you buy parts and solder yourself (default; library type ignored); smt = JLCPCB assembly (basic library preferred, USD 3 extended-part feeder fee amortized).',
1981
+ },
1982
+ quantity: { type: 'integer', minimum: 1, maximum: 100000, description: 'Parts needed per board (default 1).' },
1983
+ boards: { type: 'integer', minimum: 1, maximum: 100000, description: 'Number of boards to build (default 1; used for price tiers and SMT fee amortization).' },
1984
+ sort: { type: 'string', enum: ['balanced', 'cost', 'stock'], description: 'Ranking preference (default balanced).' },
1985
+ limit: { type: 'integer', minimum: 1, maximum: 8, description: 'Candidates to return (default 5, max 8).' },
1986
+ include_cny: { type: 'boolean', description: 'Fetch real LCSC domestic CNY retail prices for the top candidates via headless Chrome (default true in hand mode, false in smt mode; 24h cache). When unavailable, a marked reference estimate is shown.' },
1987
+ exclude_lcsc: { type: 'array', items: { type: 'string' }, description: 'LCSC codes to exclude, e.g. when looking for an alternative to a part you already have.' },
1988
+ base_only: { type: 'boolean', description: 'Only return JLCPCB basic-library parts (no feeder fee). If none exist the tool says so instead of silently returning extended parts.' },
1989
+ },
1990
+ },
1991
+ output: {
1992
+ schema: {
1993
+ type: 'object',
1994
+ additionalProperties: false,
1995
+ required: ['need', 'assembly', 'candidates', 'notes'],
1996
+ properties: {
1997
+ need: { type: 'string' },
1998
+ assembly: { type: 'string' },
1999
+ quantity: { type: 'integer' },
2000
+ boards: { type: 'integer' },
2001
+ order_qty: { type: 'integer' },
2002
+ currency: { type: 'string' },
2003
+ candidates: { type: 'array', items: { type: 'object' } },
2004
+ hints: { type: 'array', items: { type: 'string' } },
2005
+ notes: { type: 'array', items: { type: 'string' } },
2006
+ relaxed: { type: 'boolean' },
2007
+ relax_stage: { type: 'integer' },
2008
+ exact: { type: 'boolean' },
2009
+ fetched_at: { type: 'string' },
2010
+ },
2011
+ },
2012
+ render: (_args, value) => [{ type: 'text', text: renderFindText(value) }],
2013
+ },
2014
+ presentCall: (args) => ({ card: 'generic', title: 'Find LCSC parts', kind: 'read', rawInput: args }),
2015
+ async execute(args, exec) {
2016
+ return toLossless(await findParts(service, args, { signal: exec?.signal }))
2017
+ },
2018
+ },
2019
+ {
2020
+ name: 'audit_lcsc_bom',
2021
+ description: AUDIT_DESCRIPTION,
2022
+ parameters: {
2023
+ type: 'object',
2024
+ additionalProperties: false,
2025
+ required: ['bom'],
2026
+ properties: {
2027
+ bom: {
2028
+ type: 'string',
2029
+ description: 'BOM as CSV/TSV text with LCSC codes and quantities (header optional), or a JSON array string like [{"lcsc":"C1581","qty":5}].',
2030
+ },
2031
+ assembly: {
2032
+ type: 'string',
2033
+ enum: ['hand', 'smt'],
2034
+ description: 'hand = no JLCPCB feeder fees (default); smt = include USD 3 per extended part number per order.',
2035
+ },
2036
+ boards: { type: 'integer', minimum: 1, maximum: 100000, description: 'Number of boards the BOM is for (default 1).' },
2037
+ include_cny: { type: 'boolean', description: 'Fetch real LCSC domestic CNY prices via headless Chrome (24h cache, capped; default true in hand mode for small BOMs, false for large BOMs — pass true to force).' },
2038
+ suggest_alternatives: { type: 'boolean', description: 'For extended-library parts, suggest a basic-library substitute with net saving (default true; only in smt mode).' },
2039
+ export_bom: { type: 'boolean', description: 'Also return a JLCPCB BOM CSV (Comment/Designator/Footprint/LCSC Part) for the smt-placed lines (default false).' },
2040
+ region: { type: 'string', enum: ['global', 'cn'], description: 'Fee/currency context label. global = JLCPCB international (USD); cn = 嘉立创国内 (fee rules shown as assumptions, always labelled). Default global.' },
2041
+ },
2042
+ },
2043
+ output: {
2044
+ schema: {
2045
+ type: 'object',
2046
+ additionalProperties: false,
2047
+ required: ['assembly', 'summary', 'rows', 'notes'],
2048
+ properties: {
2049
+ assembly: { type: 'string' },
2050
+ boards: { type: 'integer' },
2051
+ region: { type: 'string' },
2052
+ currency: { type: 'string' },
2053
+ summary: { type: 'object' },
2054
+ optimize: { type: 'object' },
2055
+ bom_csv: { type: 'string' },
2056
+ rows: { type: 'array', items: { type: 'object' } },
2057
+ skipped: { type: 'array', items: { type: 'object' } },
2058
+ errors: { type: 'array', items: { type: 'string' } },
2059
+ hints: { type: 'array', items: { type: 'string' } },
2060
+ notes: { type: 'array', items: { type: 'string' } },
2061
+ fetched_at: { type: 'string' },
2062
+ },
2063
+ },
2064
+ render: (_args, value) => [{ type: 'text', text: renderAuditText(value) }],
2065
+ },
2066
+ presentCall: (args) => ({ card: 'generic', title: 'Audit LCSC BOM', kind: 'read', rawInput: args }),
2067
+ async execute(args, exec) {
2068
+ return toLossless(await auditBom(service, args, { signal: exec?.signal }))
2069
+ },
2070
+ },
2071
+ ]
2072
+ }
2073
+
2074
+ /* -------------------------------------------------------------------------- */
2075
+ /* Plugin entry */
2076
+ /* -------------------------------------------------------------------------- */
2077
+
2078
+ /**
2079
+ * DSH plugin entry: called by the Loader once the `tools` service is available.
2080
+ * @param {unknown} ctx
2081
+ * @param {unknown} [config] row `config` from cordis.patch.yml
2082
+ */
2083
+ export function apply(ctx, config) {
2084
+ const warnings = []
2085
+ const settings = normalizeConfig(config, { onWarning: (message) => warnings.push(message) })
2086
+ const logger = safeLogger(ctx)
2087
+ for (const warning of warnings) logger?.warn?.(`[lcsc] ${warning}`)
2088
+ const service = createLcscService(settings)
2089
+ // Domestic CNY adapter: headless Chrome + 24h cache. Kept optional so the
2090
+ // plugin still works on hosts without Chrome (falls back to USD estimates).
2091
+ let cnAdapter
2092
+ if (settings.cnRender !== false) {
2093
+ try {
2094
+ cnAdapter = createCnAdapter({
2095
+ chromePath: settings.chromePath ?? undefined,
2096
+ cacheDir: settings.cnCacheDir ?? undefined,
2097
+ cacheTtlMs: settings.cnCacheTtlMs,
2098
+ timeoutMs: settings.cnTimeoutMs,
2099
+ concurrency: settings.cnConcurrency,
2100
+ browserIdleTtlMs: settings.cnBrowserIdleTtlMs,
2101
+ cacheMaxEntries: settings.cnCacheMaxEntries,
2102
+ verbose: settings.verbose,
2103
+ logger,
2104
+ })
2105
+ service.cn = cnAdapter
2106
+ if (settings.verbose) {
2107
+ logger?.info?.(cnAdapter.chromePath === undefined
2108
+ ? '[lcsc] Chrome not found: CNY prices will fall back to USD estimates'
2109
+ : `[lcsc] CNY adapter ready (chrome: ${cnAdapter.chromePath})`)
2110
+ }
2111
+ } catch (error) {
2112
+ logger?.warn?.(`[lcsc] CNY adapter init failed: ${error instanceof Error ? error.message : String(error)}`)
2113
+ }
2114
+ }
2115
+ /** @type {{ effect?: (fn: () => (() => void)) => void, tools?: { register: (definition: unknown) => void } }} */
2116
+ const context = isRecord(ctx) ? ctx : {}
2117
+ context.effect?.(() => () => {
2118
+ service.clearCache()
2119
+ cnAdapter?.dispose?.()
2120
+ cnAdapter?.clearMemory?.()
2121
+ })
2122
+ for (const definition of createToolDefinitions(service)) {
2123
+ context.tools?.register(definition)
2124
+ }
2125
+ if (settings.verbose) logger?.info?.('[lcsc] tools registered: find_lcsc_parts, audit_lcsc_bom')
2126
+ }
2127
+
2128
+ /** @param {unknown} ctx */
2129
+ function safeLogger(ctx) {
2130
+ try {
2131
+ if (!isRecord(ctx)) return undefined
2132
+ const logger = ctx.logger
2133
+ if (typeof logger !== 'function') return undefined
2134
+ const named = logger('lcsc')
2135
+ return isRecord(named) ? named : undefined
2136
+ } catch {
2137
+ return undefined
2138
+ }
2139
+ }