dsh-plugin-lcsc 0.3.4 → 0.3.5

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