dsh-plugin-lcsc 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -3
- package/cordis.patch.yml +11 -0
- package/data.js +1765 -0
- package/icon.svg +9 -0
- package/index.js +1601 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +52 -6
package/index.js
ADDED
|
@@ -0,0 +1,1601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-lcsc — requirement-driven LCSC part finder and BOM cost auditor
|
|
3
|
+
* for the DeepSeek Harness (DSH) Host.
|
|
4
|
+
*
|
|
5
|
+
* Two LLM-callable tools, deliberately kept small so the plugin does not crowd
|
|
6
|
+
* the model's context when several plugins are installed:
|
|
7
|
+
*
|
|
8
|
+
* - find_lcsc_parts requirement -> ranked candidates (price / stock /
|
|
9
|
+
* basic-vs-extended library / parameter match)
|
|
10
|
+
* - audit_lcsc_bom existing BOM -> cost, stock, library-type and
|
|
11
|
+
* JLCPCB extended-part feeder-fee audit
|
|
12
|
+
*
|
|
13
|
+
* The HTTP/cache/normalisation layer lives in `data.js`.
|
|
14
|
+
*
|
|
15
|
+
* Design rules:
|
|
16
|
+
* - hand-soldering is the default (`assembly: "hand"`): most DIY users buy
|
|
17
|
+
* parts and solder themselves, and SMT fee rules change over time.
|
|
18
|
+
* - `assembly: "smt"` opts into library-type awareness: JLCPCB charges
|
|
19
|
+
* USD 3 per extended part number per order (feeder loading), base library
|
|
20
|
+
* parts are free, so the fee is amortised into the unit price.
|
|
21
|
+
* - prices are never converted between currencies; each price carries its
|
|
22
|
+
* currency and source.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
createLcscService,
|
|
27
|
+
normalizeConfig,
|
|
28
|
+
normalizeCode,
|
|
29
|
+
parseBomCsvDetailed,
|
|
30
|
+
pickTierPrice,
|
|
31
|
+
} from './data.js'
|
|
32
|
+
|
|
33
|
+
/** Plugin id used by the DSH loader. */
|
|
34
|
+
export const name = 'lcsc-parts'
|
|
35
|
+
/** Required services. */
|
|
36
|
+
export const inject = ['tools']
|
|
37
|
+
|
|
38
|
+
/** Default JLCPCB extended-part feeder loading fee, USD per unique part per order. */
|
|
39
|
+
export const EXTENDED_PART_FEE_USD = 3
|
|
40
|
+
/** Query-wide caps that keep a single tool call fast and cheap. */
|
|
41
|
+
const MAX_CANDIDATES = 8
|
|
42
|
+
const DEFAULT_LIMIT = 5
|
|
43
|
+
const SEARCH_PAGE_SIZE = 100
|
|
44
|
+
const MAX_CNY_LOOKUPS = 3
|
|
45
|
+
const MAX_AUDIT_CNY_LOOKUPS = 10
|
|
46
|
+
const MAX_ALTERNATIVE_LOOKUPS = 16
|
|
47
|
+
|
|
48
|
+
/* -------------------------------------------------------------------------- */
|
|
49
|
+
/* Small helpers */
|
|
50
|
+
/* -------------------------------------------------------------------------- */
|
|
51
|
+
|
|
52
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
53
|
+
function isRecord(value) {
|
|
54
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {unknown} value @returns {string | undefined} */
|
|
58
|
+
function asString(value) {
|
|
59
|
+
if (typeof value !== 'string') return undefined
|
|
60
|
+
const text = value.trim()
|
|
61
|
+
return text === '' ? undefined : text
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param {unknown} value @returns {number | undefined} */
|
|
65
|
+
function asNumber(value) {
|
|
66
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
67
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
68
|
+
const parsed = Number(value)
|
|
69
|
+
if (Number.isFinite(parsed)) return parsed
|
|
70
|
+
}
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @param {unknown} value @returns {number | undefined} */
|
|
75
|
+
function asInteger(value) {
|
|
76
|
+
const number = asNumber(value)
|
|
77
|
+
if (number === undefined || !Number.isInteger(number)) return undefined
|
|
78
|
+
return number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {unknown} value @param {number} min @param {number} max @param {number} fallback */
|
|
82
|
+
function clampInteger(value, min, max, fallback) {
|
|
83
|
+
const number = asInteger(value)
|
|
84
|
+
if (number === undefined) return fallback
|
|
85
|
+
return Math.min(max, Math.max(min, number))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Round money-like values to avoid float noise in the tool output. */
|
|
89
|
+
function money(value) {
|
|
90
|
+
return Math.round(value * 1e6) / 1e6
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* DSH validates tool results for lossless JSON: plain `undefined` properties,
|
|
95
|
+
* NaN and Infinity are rejected. Normalise any result before returning it.
|
|
96
|
+
* @param {unknown} value
|
|
97
|
+
* @returns {unknown}
|
|
98
|
+
*/
|
|
99
|
+
function toLossless(value) {
|
|
100
|
+
if (value === undefined || value === null) return null
|
|
101
|
+
if (typeof value === 'number') {
|
|
102
|
+
if (!Number.isFinite(value)) return null
|
|
103
|
+
return Object.is(value, -0) ? 0 : value
|
|
104
|
+
}
|
|
105
|
+
if (typeof value === 'string' || typeof value === 'boolean') return value
|
|
106
|
+
if (Array.isArray(value)) return value.map((entry) => toLossless(entry))
|
|
107
|
+
if (typeof value === 'object') {
|
|
108
|
+
/** @type {Record<string, unknown>} */
|
|
109
|
+
const output = {}
|
|
110
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
111
|
+
if (entry === undefined) continue
|
|
112
|
+
output[key] = toLossless(entry)
|
|
113
|
+
}
|
|
114
|
+
return output
|
|
115
|
+
}
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* USD unit price of the highest-volume ladder tier (bulk / full-reel price).
|
|
121
|
+
* The Recording_card calibration relates the LCSC retail CNY price to this
|
|
122
|
+
* bulk-tier USD price (× 6.0), not to the small-quantity tier.
|
|
123
|
+
* @param {Record<string, unknown>} item
|
|
124
|
+
* @returns {number | undefined}
|
|
125
|
+
*/
|
|
126
|
+
export function bulkTierUsd(item) {
|
|
127
|
+
const tiers = Array.isArray(item.priceUsdTiers) ? item.priceUsdTiers : []
|
|
128
|
+
/** @type {{ qty: number, unit: number } | undefined} */
|
|
129
|
+
let best
|
|
130
|
+
for (const tier of tiers) {
|
|
131
|
+
if (!isRecord(tier)) continue
|
|
132
|
+
const qty = asNumber(tier.qty)
|
|
133
|
+
const unit = asNumber(tier.unit)
|
|
134
|
+
if (qty === undefined || unit === undefined) continue
|
|
135
|
+
if (best === undefined || qty > best.qty) best = { qty, unit }
|
|
136
|
+
}
|
|
137
|
+
return best?.unit
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Reference CNY estimate = bulk-tier USD × empirical factor.
|
|
142
|
+
* @param {number | undefined} bulkUsd
|
|
143
|
+
* @param {number | undefined} factor
|
|
144
|
+
* @returns {number | undefined}
|
|
145
|
+
*/
|
|
146
|
+
export function estimateCnyFromUsd(bulkUsd, factor) {
|
|
147
|
+
const value = asNumber(bulkUsd)
|
|
148
|
+
const calibration = asNumber(factor)
|
|
149
|
+
if (value === undefined || calibration === undefined) return undefined
|
|
150
|
+
return money(value * calibration)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @param {number} ms @param {AbortSignal} [signal] */
|
|
154
|
+
function sleep(ms, signal) {
|
|
155
|
+
if (signal?.aborted === true) return Promise.reject(new Error('aborted'))
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
159
|
+
resolve()
|
|
160
|
+
}, ms)
|
|
161
|
+
function onAbort() {
|
|
162
|
+
clearTimeout(timer)
|
|
163
|
+
reject(new Error('aborted'))
|
|
164
|
+
}
|
|
165
|
+
signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run `fn` over items with bounded concurrency; results keep input order.
|
|
171
|
+
* @template T, R
|
|
172
|
+
* @param {T[]} items
|
|
173
|
+
* @param {number} limit
|
|
174
|
+
* @param {(item: T, index: number) => Promise<R>} fn
|
|
175
|
+
* @returns {Promise<R[]>}
|
|
176
|
+
*/
|
|
177
|
+
async function mapLimit(items, limit, fn) {
|
|
178
|
+
const results = new Array(items.length)
|
|
179
|
+
let cursor = 0
|
|
180
|
+
const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
|
|
181
|
+
while (cursor < items.length) {
|
|
182
|
+
const index = cursor
|
|
183
|
+
cursor += 1
|
|
184
|
+
results[index] = await fn(items[index], index)
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
await Promise.all(workers)
|
|
188
|
+
return results
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* -------------------------------------------------------------------------- */
|
|
192
|
+
/* Requirement parsing */
|
|
193
|
+
/* -------------------------------------------------------------------------- */
|
|
194
|
+
|
|
195
|
+
/** Chinese / mixed category words mapped to English search hints. */
|
|
196
|
+
const CATEGORY_TERMS = [
|
|
197
|
+
['自恢复保险丝', ['ptc resettable fuse']],
|
|
198
|
+
['贴片电容', ['capacitor']],
|
|
199
|
+
['贴片电阻', ['resistor']],
|
|
200
|
+
['电容', ['capacitor']],
|
|
201
|
+
['电阻', ['resistor']],
|
|
202
|
+
['电感', ['inductor']],
|
|
203
|
+
['磁珠', ['ferrite bead']],
|
|
204
|
+
['二极管', ['diode']],
|
|
205
|
+
['发光二极管', ['led']],
|
|
206
|
+
['肖特基', ['schottky']],
|
|
207
|
+
['三极管', ['transistor']],
|
|
208
|
+
['场效应管', ['mosfet']],
|
|
209
|
+
['mos管', ['mosfet']],
|
|
210
|
+
['稳压器', ['voltage regulator']],
|
|
211
|
+
['ldo', ['ldo']],
|
|
212
|
+
['连接器', ['connector']],
|
|
213
|
+
['插座', ['connector']],
|
|
214
|
+
['排针', ['pin header']],
|
|
215
|
+
['排母', ['pin header']],
|
|
216
|
+
['按键', ['tactile switch']],
|
|
217
|
+
['轻触开关', ['tactile switch']],
|
|
218
|
+
['开关', ['switch']],
|
|
219
|
+
['晶振', ['crystal']],
|
|
220
|
+
['谐振器', ['resonator']],
|
|
221
|
+
['保险丝', ['fuse']],
|
|
222
|
+
['麦克风', ['microphone']],
|
|
223
|
+
['咪头', ['microphone']],
|
|
224
|
+
['蜂鸣器', ['buzzer']],
|
|
225
|
+
['电池座', ['battery holder']],
|
|
226
|
+
['电池连接器', ['battery connector']],
|
|
227
|
+
['光耦', ['optocoupler']],
|
|
228
|
+
['运放', ['operational amplifier']],
|
|
229
|
+
['比较器', ['comparator']],
|
|
230
|
+
['单片机', ['microcontroller']],
|
|
231
|
+
['存储器', ['memory']],
|
|
232
|
+
['电平转换', ['level shifter']],
|
|
233
|
+
['tvs', ['tvs']],
|
|
234
|
+
['esd', ['esd']],
|
|
235
|
+
['type-c', ['type-c']],
|
|
236
|
+
['tf卡座', ['microsd']],
|
|
237
|
+
['sd卡座', ['microsd']],
|
|
238
|
+
['microsd', ['microsd']],
|
|
239
|
+
['usb', ['usb']],
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
/** Package / footprint tokens, longest first so SOT-23-5 wins over SOT-23. */
|
|
243
|
+
const PACKAGE_PATTERNS = [
|
|
244
|
+
'SOT-23-6', 'SOT-23-5', 'SOT-23-3', 'SOT-23', 'SOD-123FL', 'SOD-123', 'SOD-323',
|
|
245
|
+
'DO-214AC', 'DO-214AA', 'DO-214AB', 'ESOP-8', 'SOP-16', 'SOP-8', 'SOP-4',
|
|
246
|
+
'TSSOP-16', 'TSSOP-20', 'MSOP-8', 'MSOP-10', 'QFN-16', 'QFN-20', 'QFN-24',
|
|
247
|
+
'DFN-6', 'DFN-8', 'SMA', 'SMB', 'SMC', 'TO-220', 'TO-92', 'TO-252', 'TO-263',
|
|
248
|
+
'0201', '0402', '0603', '0805', '1206', '1210', '1812', '2010', '2512', '3225', '5032',
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
const VOLTAGE_UNIT = /(kv|v)/i
|
|
252
|
+
const CURRENT_UNIT = /(ma|ua|µa|a)/i
|
|
253
|
+
const CAP_UNIT = /(mf|uf|µf|nf|pf|f)/i
|
|
254
|
+
const RES_UNIT = /(mω|kω|mohm|kohm|ω|ohm|meg|m|k|r)/i
|
|
255
|
+
|
|
256
|
+
/** Normalise a comparator token to one of '>=', '<=', '='. */
|
|
257
|
+
function normalizeComparator(raw) {
|
|
258
|
+
if (raw === undefined) return undefined
|
|
259
|
+
const text = String(raw).toLowerCase()
|
|
260
|
+
if (['>=', '≥', '>', '至少', '不小于', '大于', 'min', 'minimum'].includes(text)) return '>='
|
|
261
|
+
if (['<=', '≤', '<', '最多', '不超过', '小于', 'max', 'maximum'].includes(text)) return '<='
|
|
262
|
+
return '='
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** @param {string} text @returns {string | undefined} */
|
|
266
|
+
function extractPackage(text) {
|
|
267
|
+
const upper = text.toUpperCase()
|
|
268
|
+
for (const pattern of PACKAGE_PATTERNS) {
|
|
269
|
+
if (upper.includes(pattern)) return pattern
|
|
270
|
+
}
|
|
271
|
+
const inch = upper.match(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/)
|
|
272
|
+
return inch === null ? undefined : inch[1]
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Extract numeric requirements from free text.
|
|
277
|
+
* @param {string} text
|
|
278
|
+
* @returns {{ kind: string, value: number, unit: string, comparator: string | undefined, raw: string }[]}
|
|
279
|
+
*/
|
|
280
|
+
export function extractRequirements(text) {
|
|
281
|
+
/** @type {{ kind: string, value: number, unit: string, comparator: string | undefined, raw: string }[]} */
|
|
282
|
+
const requirements = []
|
|
283
|
+
const cleaned = text
|
|
284
|
+
.replace(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/g, ' ')
|
|
285
|
+
.replace(/\b(x7r|x5r|x6s|x7s|c0g|np0|y5v|z5u)\b/gi, ' ')
|
|
286
|
+
const push = (kind, value, unit, comparator, raw) => {
|
|
287
|
+
if (!Number.isFinite(value)) return
|
|
288
|
+
requirements.push({ kind, value, unit, comparator: normalizeComparator(comparator), raw })
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Capacitance
|
|
292
|
+
for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(pf|nf|uf|µf|mf|f)\b/gi)) {
|
|
293
|
+
const unit = match[3].toLowerCase()
|
|
294
|
+
const scale = { pf: 1, nf: 1e3, uf: 1e6, 'µf': 1e6, mf: 1e9, f: 1e12 }[unit]
|
|
295
|
+
push('capacitance', Number(match[2]) * scale, unit, match[1], match[0].trim())
|
|
296
|
+
}
|
|
297
|
+
// Resistance ("10kΩ", "5.1k", "100Ω", "0R")
|
|
298
|
+
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)) {
|
|
299
|
+
const unitRaw = match[3]
|
|
300
|
+
const unit = unitRaw === undefined ? undefined : unitRaw.toLowerCase()
|
|
301
|
+
if (unit === undefined) continue
|
|
302
|
+
const value = Number(match[2])
|
|
303
|
+
const scale = { 'ω': 1, ohm: 1, r: 1, k: 1e3, 'kω': 1e3, kohm: 1e3, m: 1e6, meg: 1e6, 'mω': 1e-3, mohm: 1e-3 }[unit]
|
|
304
|
+
if (scale === undefined) continue
|
|
305
|
+
push('resistance', value * scale, unit, match[1], match[0].trim())
|
|
306
|
+
}
|
|
307
|
+
// Inductance
|
|
308
|
+
for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(nh|uh|µh|mh|h)\b/gi)) {
|
|
309
|
+
const unit = match[3].toLowerCase()
|
|
310
|
+
const scale = { nh: 1, uh: 1e3, 'µh': 1e3, mh: 1e6, h: 1e9 }[unit]
|
|
311
|
+
push('inductance', Number(match[2]) * scale, unit, match[1], match[0].trim())
|
|
312
|
+
}
|
|
313
|
+
// Voltage
|
|
314
|
+
for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(kv|v)\b/gi)) {
|
|
315
|
+
const unit = match[3].toLowerCase()
|
|
316
|
+
push('voltage', Number(match[2]) * (unit === 'kv' ? 1000 : 1), unit, match[1], match[0].trim())
|
|
317
|
+
}
|
|
318
|
+
// Current
|
|
319
|
+
for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(ma|ua|µa|a)\b/gi)) {
|
|
320
|
+
const unit = match[3].toLowerCase()
|
|
321
|
+
const scale = { ma: 1, ua: 1e-3, 'µa': 1e-3, a: 1e3 }[unit]
|
|
322
|
+
push('current', Number(match[2]) * scale, unit, match[1], match[0].trim())
|
|
323
|
+
}
|
|
324
|
+
// Power
|
|
325
|
+
for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(mw|w)\b/gi)) {
|
|
326
|
+
const unit = match[3].toLowerCase()
|
|
327
|
+
push('power', Number(match[2]) * (unit === 'mw' ? 1 : 1000), unit, match[1], match[0].trim())
|
|
328
|
+
}
|
|
329
|
+
// Tolerance (percent) - tighter is better, so default comparator is <=
|
|
330
|
+
for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*±?\s*(\d+(?:\.\d+)?)\s*%/g)) {
|
|
331
|
+
push('tolerance', Number(match[2]), '%', match[1], match[0].trim())
|
|
332
|
+
}
|
|
333
|
+
// Frequency
|
|
334
|
+
for (const match of cleaned.matchAll(/(>=|≥|>|<=|≤|<|=|至少|不小于|大于|最多|不超过|小于)?\s*(\d+(?:\.\d+)?)\s*(khz|mhz|ghz|hz)\b/gi)) {
|
|
335
|
+
const unit = match[3].toLowerCase()
|
|
336
|
+
const scale = { hz: 1, khz: 1e3, mhz: 1e6, ghz: 1e9 }[unit]
|
|
337
|
+
push('frequency', Number(match[2]) * scale, unit, match[1], match[0].trim())
|
|
338
|
+
}
|
|
339
|
+
// Footprint dimensions like 2.0x1.2mm
|
|
340
|
+
for (const match of cleaned.matchAll(/(\d+(?:\.\d+)?)\s*(?:x|×|\*)\s*(\d+(?:\.\d+)?)\s*mm/gi)) {
|
|
341
|
+
requirements.push({ kind: 'size', value: Number(match[1]), unit: 'mm', comparator: '=', raw: match[0], extra: Number(match[2]) })
|
|
342
|
+
}
|
|
343
|
+
// Pin / contact count ("16P", "16 pin", "16针")
|
|
344
|
+
for (const match of cleaned.matchAll(/(?<![A-Za-z0-9])(\d{1,3})\s*(?:p|pin|pins|针|脚)(?![A-Za-z0-9])/gi)) {
|
|
345
|
+
push('pins', Number(match[1]), 'pins', '=', match[0].trim())
|
|
346
|
+
}
|
|
347
|
+
return requirements
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Parse a free-form requirement (Chinese or English) into a structured need.
|
|
352
|
+
* @param {string} raw
|
|
353
|
+
* @returns {{ raw: string, searchKeyword: string, package?: string, directCode?: string, modelGuess: boolean, categories: string[], dielectric?: string, requirements: ReturnType<typeof extractRequirements> }}
|
|
354
|
+
*/
|
|
355
|
+
export function parseNeed(raw) {
|
|
356
|
+
const text = String(raw ?? '').trim()
|
|
357
|
+
const directCode = text.match(/\bC\d{4,11}\b/i)?.[0]?.toUpperCase()
|
|
358
|
+
const upper = text.toUpperCase()
|
|
359
|
+
const packageName = extractPackage(text)
|
|
360
|
+
const categories = []
|
|
361
|
+
const dielectricMatch = text.match(/\b(X7R|X5R|X6S|X7S|C0G|NP0|Y5V|Z5U)\b/i)
|
|
362
|
+
const dielectric = dielectricMatch === null ? undefined : dielectricMatch[1].toUpperCase()
|
|
363
|
+
for (const [word, hints] of CATEGORY_TERMS) {
|
|
364
|
+
if (text.toLowerCase().includes(word)) {
|
|
365
|
+
for (const hint of hints) if (!categories.includes(hint)) categories.push(hint)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// English category words are used as-is by the JLCPCB search, so keep them.
|
|
369
|
+
const ENGLISH_CATEGORIES = [
|
|
370
|
+
'capacitor', 'resistor', 'inductor', 'ferrite bead', 'diode', 'schottky', 'led',
|
|
371
|
+
'transistor', 'mosfet', 'ldo', 'voltage regulator', 'connector', 'pin header',
|
|
372
|
+
'tactile switch', 'switch', 'crystal', 'resonator', 'fuse', 'ptc resettable fuse',
|
|
373
|
+
'microphone', 'buzzer', 'battery holder', 'battery connector', 'optocoupler',
|
|
374
|
+
'operational amplifier', 'comparator', 'microcontroller', 'memory', 'level shifter',
|
|
375
|
+
'tvs', 'esd', 'type-c', 'microsd', 'usb', 'relay', 'sensor', 'motor driver',
|
|
376
|
+
'adc', 'dac', 'interface', 'antenna',
|
|
377
|
+
]
|
|
378
|
+
for (const word of ENGLISH_CATEGORIES) {
|
|
379
|
+
const re = new RegExp(`(^|[^a-z0-9])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i')
|
|
380
|
+
if (re.test(text) && !categories.includes(word)) categories.push(word)
|
|
381
|
+
}
|
|
382
|
+
const modelGuess =
|
|
383
|
+
directCode === undefined &&
|
|
384
|
+
!/\s/.test(text) &&
|
|
385
|
+
/[A-Za-z]/.test(text) &&
|
|
386
|
+
text.length >= 3
|
|
387
|
+
const modelToken = modelGuess ? text.replace(/[^A-Za-z0-9]/g, '').toUpperCase() : undefined
|
|
388
|
+
|
|
389
|
+
// Translate Chinese category words into English so the JLCPCB keyword search
|
|
390
|
+
// (which is English-oriented) gets a useful query.
|
|
391
|
+
let keyword = text
|
|
392
|
+
for (const [word, hints] of CATEGORY_TERMS) {
|
|
393
|
+
const re = new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')
|
|
394
|
+
keyword = keyword.replace(re, ` ${hints[0]} `)
|
|
395
|
+
}
|
|
396
|
+
keyword = keyword
|
|
397
|
+
.replace(/[,。;、,;::!!??]/g, ' ')
|
|
398
|
+
.replace(/需要|一个|帮我|找一下|寻找|找|用来|用于|的|料号|器件|元件/g, ' ')
|
|
399
|
+
.replace(/[\u4e00-\u9fff]+/g, ' ')
|
|
400
|
+
.replace(/\s+/g, ' ')
|
|
401
|
+
.trim()
|
|
402
|
+
if (keyword === '') keyword = [packageName, dielectric, ...categories].filter(Boolean).join(' ')
|
|
403
|
+
if (keyword === '') keyword = text
|
|
404
|
+
|
|
405
|
+
const requirements = extractRequirements(text)
|
|
406
|
+
// Category-aware comparator defaults.
|
|
407
|
+
const isRegulator = categories.some((category) => category === 'ldo' || category === 'voltage regulator')
|
|
408
|
+
for (const requirement of requirements) {
|
|
409
|
+
if (requirement.comparator !== undefined) continue
|
|
410
|
+
if (requirement.kind === 'voltage' && isRegulator) requirement.comparator = '='
|
|
411
|
+
else if (requirement.kind === 'tolerance') requirement.comparator = '<='
|
|
412
|
+
else if (requirement.kind === 'size') requirement.comparator = '='
|
|
413
|
+
else if (requirement.kind === 'capacitance' || requirement.kind === 'resistance' || requirement.kind === 'inductance' || requirement.kind === 'frequency') requirement.comparator = '='
|
|
414
|
+
else requirement.comparator = '>='
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
raw: text,
|
|
418
|
+
searchKeyword: keyword,
|
|
419
|
+
package: packageName,
|
|
420
|
+
directCode,
|
|
421
|
+
modelGuess,
|
|
422
|
+
categories,
|
|
423
|
+
dielectric,
|
|
424
|
+
requirements,
|
|
425
|
+
modelToken,
|
|
426
|
+
upper,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* -------------------------------------------------------------------------- */
|
|
431
|
+
/* Candidate extraction / matching */
|
|
432
|
+
/* -------------------------------------------------------------------------- */
|
|
433
|
+
|
|
434
|
+
/** @param {Record<string, unknown>} item */
|
|
435
|
+
function attributesOf(item) {
|
|
436
|
+
return Array.isArray(item.attributes)
|
|
437
|
+
? item.attributes.filter(isRecord).map((attribute) => ({
|
|
438
|
+
name: asString(attribute.name) ?? '',
|
|
439
|
+
value: asString(attribute.value) ?? '',
|
|
440
|
+
}))
|
|
441
|
+
: []
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Full searchable text of a JLCPCB candidate row. */
|
|
445
|
+
function candidateText(item) {
|
|
446
|
+
const parts = [
|
|
447
|
+
asString(item.model),
|
|
448
|
+
asString(item.package),
|
|
449
|
+
asString(item.category),
|
|
450
|
+
asString(item.description),
|
|
451
|
+
]
|
|
452
|
+
for (const attribute of attributesOf(item)) parts.push(`${attribute.name} ${attribute.value}`)
|
|
453
|
+
return parts.filter(Boolean).join(' ').toLowerCase()
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Infer quantity kinds from an attribute name. */
|
|
457
|
+
function kindsFromAttributeName(name) {
|
|
458
|
+
const kinds = []
|
|
459
|
+
if (/capacitance|^cap\b/i.test(name)) kinds.push('capacitance')
|
|
460
|
+
if (/resistance|resistor/i.test(name)) kinds.push('resistance')
|
|
461
|
+
if (/inductance/i.test(name)) kinds.push('inductance')
|
|
462
|
+
if (/voltage|vds|vgs|reverse|vrm|vrr|supply/i.test(name)) kinds.push('voltage')
|
|
463
|
+
if (/current|amp/i.test(name)) kinds.push('current')
|
|
464
|
+
if (/\bpower\b/i.test(name)) kinds.push('power')
|
|
465
|
+
if (/tolerance/i.test(name)) kinds.push('tolerance')
|
|
466
|
+
if (/frequency/i.test(name)) kinds.push('frequency')
|
|
467
|
+
return kinds
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Extract candidate numeric values for matching.
|
|
472
|
+
* @param {Record<string, unknown>} item
|
|
473
|
+
* @returns {{ kind: string, value: number }[]}
|
|
474
|
+
*/
|
|
475
|
+
export function extractCandidateQuantities(item) {
|
|
476
|
+
/** @type {{ kind: string, value: number }[]} */
|
|
477
|
+
const result = []
|
|
478
|
+
const add = (kind, value) => {
|
|
479
|
+
if (Number.isFinite(value)) result.push({ kind, value })
|
|
480
|
+
}
|
|
481
|
+
for (const attribute of attributesOf(item)) {
|
|
482
|
+
if (/number of (contacts|positions|pins)|contacts|positions|pin count|number of pins|pin number/i.test(attribute.name)) {
|
|
483
|
+
const pinMatch = attribute.value.match(/\d{1,3}/)
|
|
484
|
+
if (pinMatch !== null) add('pins', Number(pinMatch[0]))
|
|
485
|
+
}
|
|
486
|
+
const kinds = kindsFromAttributeName(attribute.name)
|
|
487
|
+
const valueText = `${attribute.value}`
|
|
488
|
+
if (kinds.length === 0) continue
|
|
489
|
+
for (const kind of kinds) {
|
|
490
|
+
for (const parsed of parseQuantitiesOfKind(valueText, kind)) add(kind, parsed)
|
|
491
|
+
}
|
|
492
|
+
// "10k" / "0R" style resistance values without a unit letter.
|
|
493
|
+
if (kinds.includes('resistance')) {
|
|
494
|
+
const bare = valueText.trim().match(/^(\d+(?:\.\d+)?)\s*(k|m)?$/i)
|
|
495
|
+
if (bare !== null) {
|
|
496
|
+
const multiplier = bare[2] === undefined ? 1 : bare[2].toLowerCase() === 'k' ? 1e3 : 1e6
|
|
497
|
+
add('resistance', Number(bare[1]) * multiplier)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// Generic scan over the description as a fallback.
|
|
502
|
+
const description = asString(item.description)
|
|
503
|
+
if (description !== undefined) {
|
|
504
|
+
for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(pf|nf|uf|µf|mf|f)\b/gi)) {
|
|
505
|
+
add('capacitance', Number(match[1]) * ({ pf: 1, nf: 1e3, uf: 1e6, 'µf': 1e6, mf: 1e9, f: 1e12 }[match[2].toLowerCase()] ?? 0))
|
|
506
|
+
}
|
|
507
|
+
for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(kv|v)\b/gi)) {
|
|
508
|
+
add('voltage', Number(match[1]) * (match[2].toLowerCase() === 'kv' ? 1000 : 1))
|
|
509
|
+
}
|
|
510
|
+
for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(ma|ua|µa|a)\b/gi)) {
|
|
511
|
+
add('current', Number(match[1]) * ({ ma: 1, ua: 1e-3, 'µa': 1e-3, a: 1e3 }[match[2].toLowerCase()] ?? 0))
|
|
512
|
+
}
|
|
513
|
+
for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(mw|w)\b/gi)) {
|
|
514
|
+
add('power', Number(match[1]) * (match[2].toLowerCase() === 'mw' ? 1 : 1000))
|
|
515
|
+
}
|
|
516
|
+
for (const match of description.matchAll(/±\s*(\d+(?:\.\d+)?)\s*%/g)) add('tolerance', Number(match[1]))
|
|
517
|
+
for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(nh|uh|µh|mh|h)\b/gi)) {
|
|
518
|
+
add('inductance', Number(match[1]) * ({ nh: 1, uh: 1e3, 'µh': 1e3, mh: 1e6, h: 1e9 }[match[2].toLowerCase()] ?? 0))
|
|
519
|
+
}
|
|
520
|
+
for (const match of description.matchAll(/(\d+(?:\.\d+)?)\s*(?:x|×|\*)\s*(\d+(?:\.\d+)?)\s*mm/gi)) {
|
|
521
|
+
add('size', Number(match[1]))
|
|
522
|
+
add('size', Number(match[2]))
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return result
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Parse values of one kind from a text fragment. */
|
|
529
|
+
function parseQuantitiesOfKind(text, kind) {
|
|
530
|
+
const values = []
|
|
531
|
+
const add = (value) => {
|
|
532
|
+
if (Number.isFinite(value)) values.push(value)
|
|
533
|
+
}
|
|
534
|
+
if (kind === 'capacitance') {
|
|
535
|
+
for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(pf|nf|uf|µf|mf|f)\b/gi)) {
|
|
536
|
+
const scale = { pf: 1, nf: 1e3, uf: 1e6, 'µf': 1e6, mf: 1e9, f: 1e12 }[match[2].toLowerCase()]
|
|
537
|
+
if (scale !== undefined) add(Number(match[1]) * scale)
|
|
538
|
+
}
|
|
539
|
+
} else if (kind === 'resistance') {
|
|
540
|
+
for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(kω|kohm|mω|mohm|meg|ω|ohm|m|k|r)?\b/gi)) {
|
|
541
|
+
const unit = match[2] === undefined ? undefined : match[2].toLowerCase()
|
|
542
|
+
const scale = unit === undefined
|
|
543
|
+
? 1
|
|
544
|
+
: { 'ω': 1, ohm: 1, r: 1, k: 1e3, 'kω': 1e3, kohm: 1e3, m: 1e6, meg: 1e6, 'mω': 1e-3, mohm: 1e-3 }[unit]
|
|
545
|
+
if (scale !== undefined) add(Number(match[1]) * scale)
|
|
546
|
+
}
|
|
547
|
+
} else if (kind === 'inductance') {
|
|
548
|
+
for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(nh|uh|µh|mh|h)\b/gi)) {
|
|
549
|
+
const scale = { nh: 1, uh: 1e3, 'µh': 1e3, mh: 1e6, h: 1e9 }[match[2].toLowerCase()]
|
|
550
|
+
if (scale !== undefined) add(Number(match[1]) * scale)
|
|
551
|
+
}
|
|
552
|
+
} else if (kind === 'voltage') {
|
|
553
|
+
for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(kv|v)\b/gi)) {
|
|
554
|
+
add(Number(match[1]) * (match[2].toLowerCase() === 'kv' ? 1000 : 1))
|
|
555
|
+
}
|
|
556
|
+
} else if (kind === 'current') {
|
|
557
|
+
for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(ma|ua|µa|a)\b/gi)) {
|
|
558
|
+
const scale = { ma: 1, ua: 1e-3, 'µa': 1e-3, a: 1e3 }[match[2].toLowerCase()]
|
|
559
|
+
if (scale !== undefined) add(Number(match[1]) * scale)
|
|
560
|
+
}
|
|
561
|
+
} else if (kind === 'power') {
|
|
562
|
+
for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(mw|w)\b/gi)) {
|
|
563
|
+
add(Number(match[1]) * (match[2].toLowerCase() === 'mw' ? 1 : 1000))
|
|
564
|
+
}
|
|
565
|
+
} else if (kind === 'tolerance') {
|
|
566
|
+
for (const match of text.matchAll(/±?\s*(\d+(?:\.\d+)?)\s*%/g)) add(Number(match[1]))
|
|
567
|
+
} else if (kind === 'frequency') {
|
|
568
|
+
for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(khz|mhz|ghz|hz)\b/gi)) {
|
|
569
|
+
const scale = { hz: 1, khz: 1e3, mhz: 1e6, ghz: 1e9 }[match[2].toLowerCase()]
|
|
570
|
+
if (scale !== undefined) add(Number(match[1]) * scale)
|
|
571
|
+
}
|
|
572
|
+
} else if (kind === 'size') {
|
|
573
|
+
for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(?:x|×|\*)\s*(\d+(?:\.\d+)?)\s*mm/gi)) {
|
|
574
|
+
add(Number(match[1]))
|
|
575
|
+
add(Number(match[2]))
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return values
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Does a candidate value satisfy a parsed requirement? */
|
|
582
|
+
function satisfies(value, requirement) {
|
|
583
|
+
const target = requirement.value
|
|
584
|
+
const comparator = requirement.comparator ?? '>='
|
|
585
|
+
const tolerance = Math.max(Math.abs(target) * 0.05, 1e-9)
|
|
586
|
+
if (comparator === '=') return Math.abs(value - target) <= tolerance
|
|
587
|
+
if (comparator === '>=') return value >= target - tolerance
|
|
588
|
+
if (comparator === '<=') return value <= target + tolerance
|
|
589
|
+
return false
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Package comparison tolerant to spacing/case differences. */
|
|
593
|
+
function packageMatches(candidatePackage, wantedPackage) {
|
|
594
|
+
if (candidatePackage === undefined) return true
|
|
595
|
+
const normal = (value) => String(value).toUpperCase().replace(/[\s_]/g, '')
|
|
596
|
+
return normal(candidatePackage) === normal(wantedPackage)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Check whether a JLCPCB candidate satisfies a parsed requirement.
|
|
601
|
+
* @param {Record<string, unknown>} item
|
|
602
|
+
* @param {ReturnType<typeof parseNeed>} need
|
|
603
|
+
* @returns {{ ok: boolean, matched: string[], missing?: string }}
|
|
604
|
+
*/
|
|
605
|
+
export function candidateMatches(item, need) {
|
|
606
|
+
if (need.directCode !== undefined && String(item.code).toUpperCase() !== need.directCode) {
|
|
607
|
+
return { ok: false, matched: [] }
|
|
608
|
+
}
|
|
609
|
+
if (need.package !== undefined && item.package !== undefined && !packageMatches(item.package, need.package)) {
|
|
610
|
+
return { ok: false, matched: [] }
|
|
611
|
+
}
|
|
612
|
+
const text = candidateText(item)
|
|
613
|
+
if (need.dielectric !== undefined && !text.includes(need.dielectric.toLowerCase())) {
|
|
614
|
+
return { ok: false, matched: [] }
|
|
615
|
+
}
|
|
616
|
+
if (need.categories.length > 0) {
|
|
617
|
+
const hit = need.categories.some((category) => text.includes(category.toLowerCase()))
|
|
618
|
+
if (!hit) return { ok: false, matched: [] }
|
|
619
|
+
}
|
|
620
|
+
const quantities = extractCandidateQuantities(item)
|
|
621
|
+
const matched = []
|
|
622
|
+
for (const requirement of need.requirements) {
|
|
623
|
+
if (requirement.kind === 'size') {
|
|
624
|
+
const values = quantities.filter((quantity) => quantity.kind === 'size').map((quantity) => quantity.value)
|
|
625
|
+
if (values.length >= 1) {
|
|
626
|
+
const wanted = requirement.value
|
|
627
|
+
const wantedExtra = Number(requirement.extra ?? wanted)
|
|
628
|
+
const pairMatch = values.some((value, index) => {
|
|
629
|
+
if (index + 1 >= values.length) return false
|
|
630
|
+
const other = values[index + 1]
|
|
631
|
+
return (Math.abs(value - wanted) <= wanted * 0.2 && Math.abs(other - wantedExtra) <= wantedExtra * 0.25) ||
|
|
632
|
+
(Math.abs(value - wantedExtra) <= wantedExtra * 0.2 && Math.abs(other - wanted) <= wanted * 0.25)
|
|
633
|
+
})
|
|
634
|
+
if (!pairMatch) return { ok: false, matched }
|
|
635
|
+
}
|
|
636
|
+
matched.push(`size=${requirement.raw}`)
|
|
637
|
+
continue
|
|
638
|
+
}
|
|
639
|
+
const values = quantities.filter((quantity) => quantity.kind === requirement.kind).map((quantity) => quantity.value)
|
|
640
|
+
if (values.length === 0) return { ok: false, matched }
|
|
641
|
+
if (!values.some((value) => satisfies(value, requirement))) return { ok: false, matched }
|
|
642
|
+
matched.push(`${requirement.kind}=${requirement.raw}`)
|
|
643
|
+
}
|
|
644
|
+
return { ok: true, matched }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/* -------------------------------------------------------------------------- */
|
|
648
|
+
/* Scoring */
|
|
649
|
+
/* -------------------------------------------------------------------------- */
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* @typedef {object} ScoreContext
|
|
653
|
+
* @property {'hand' | 'smt'} assembly
|
|
654
|
+
* @property {number} quantity components per board
|
|
655
|
+
* @property {number} boards board count
|
|
656
|
+
* @property {number} orderQty total parts to buy
|
|
657
|
+
* @property {'balanced' | 'cost' | 'stock'} sort
|
|
658
|
+
*/
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Score one candidate.
|
|
662
|
+
* @param {Record<string, unknown>} item
|
|
663
|
+
* @param {ReturnType<typeof parseNeed>} need
|
|
664
|
+
* @param {ScoreContext} context
|
|
665
|
+
*/
|
|
666
|
+
export function scoreCandidate(item, need, context) {
|
|
667
|
+
const match = candidateMatches(item, need)
|
|
668
|
+
const tiers = Array.isArray(item.priceUsdTiers) ? item.priceUsdTiers : []
|
|
669
|
+
const unitUsd = pickTierPrice(tiers, context.orderQty)
|
|
670
|
+
const expand = String(item.libraryType ?? '').toLowerCase() !== 'base'
|
|
671
|
+
const modelExact =
|
|
672
|
+
context.modelToken === undefined
|
|
673
|
+
? false
|
|
674
|
+
: String(item.model ?? '').replace(/[^A-Za-z0-9]/g, '').toUpperCase() === context.modelToken
|
|
675
|
+
const extendedFeeUsd = context.assembly === 'smt' && expand ? EXTENDED_PART_FEE_USD : 0
|
|
676
|
+
const feePerBoardUsd = extendedFeeUsd === 0 ? 0 : extendedFeeUsd / Math.max(1, context.boards)
|
|
677
|
+
const unitEffectiveUsd =
|
|
678
|
+
unitUsd === undefined
|
|
679
|
+
? undefined
|
|
680
|
+
: unitUsd + (context.assembly === 'smt' ? feePerBoardUsd / Math.max(1, context.quantity) : 0)
|
|
681
|
+
return { item, match, unitUsd, unitEffectiveUsd, extendedFeeUsd, feePerBoardUsd, expand, modelExact }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** @param {number} stock */
|
|
685
|
+
function stockScore(stock) {
|
|
686
|
+
if (!Number.isFinite(stock) || stock <= 0) return 0
|
|
687
|
+
return Math.min(1, Math.max(0, Math.log10(stock + 1) / 7))
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Rank scored candidates (already filtered by `match.ok`).
|
|
692
|
+
* @param {ReturnType<typeof scoreCandidate>[]} candidates
|
|
693
|
+
* @param {ScoreContext} context
|
|
694
|
+
* @param {number} neededQty
|
|
695
|
+
*/
|
|
696
|
+
export function rankCandidates(candidates, context, neededQty) {
|
|
697
|
+
const priced = candidates.filter((candidate) => candidate.unitEffectiveUsd !== undefined)
|
|
698
|
+
const unitValues = priced.map((candidate) => candidate.unitEffectiveUsd)
|
|
699
|
+
const minUnit = unitValues.length > 0 ? Math.min(...unitValues) : 0
|
|
700
|
+
const maxUnit = unitValues.length > 0 ? Math.max(...unitValues) : 0
|
|
701
|
+
const weights =
|
|
702
|
+
context.sort === 'cost'
|
|
703
|
+
? { cost: 0.75, stock: 0.1, cover: 0.05, library: 0.05, params: 0.05 }
|
|
704
|
+
: context.sort === 'stock'
|
|
705
|
+
? { cost: 0.2, stock: 0.55, cover: 0.15, library: 0.05, params: 0.05 }
|
|
706
|
+
: context.assembly === 'smt'
|
|
707
|
+
? { cost: 0.4, stock: 0.25, cover: 0.15, library: 0.1, params: 0.1 }
|
|
708
|
+
: { cost: 0.5, stock: 0.3, cover: 0.1, library: 0.0, params: 0.1 }
|
|
709
|
+
const scored = candidates.map((candidate) => {
|
|
710
|
+
const unit = candidate.unitEffectiveUsd
|
|
711
|
+
const costScore = unit === undefined
|
|
712
|
+
? 0.2
|
|
713
|
+
: maxUnit === minUnit
|
|
714
|
+
? 1
|
|
715
|
+
: 1 - (unit - minUnit) / (maxUnit - minUnit)
|
|
716
|
+
const stock = asInteger(candidate.item.stock) ?? 0
|
|
717
|
+
const cover = neededQty <= 0 ? 1 : Math.min(1, stock / Math.max(1, neededQty * 1.5))
|
|
718
|
+
const library =
|
|
719
|
+
context.assembly === 'smt' ? (candidate.expand ? 0 : 1) : 0.5
|
|
720
|
+
const params = needParamScore(candidate.match)
|
|
721
|
+
const score =
|
|
722
|
+
weights.cost * costScore +
|
|
723
|
+
weights.stock * stockScore(stock) +
|
|
724
|
+
weights.cover * cover +
|
|
725
|
+
weights.library * library +
|
|
726
|
+
weights.params * params
|
|
727
|
+
return { ...candidate, score: Math.round(score * 1000) / 1000, stock, cover }
|
|
728
|
+
})
|
|
729
|
+
scored.sort((left, right) => {
|
|
730
|
+
if (left.modelExact !== right.modelExact) return left.modelExact ? -1 : 1
|
|
731
|
+
if (right.score !== left.score) return right.score - left.score
|
|
732
|
+
const leftUnit = left.unitEffectiveUsd ?? Number.POSITIVE_INFINITY
|
|
733
|
+
const rightUnit = right.unitEffectiveUsd ?? Number.POSITIVE_INFINITY
|
|
734
|
+
if (leftUnit !== rightUnit) return leftUnit - rightUnit
|
|
735
|
+
return right.stock - left.stock
|
|
736
|
+
})
|
|
737
|
+
return scored
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** @param {{ matched: string[] }} match */
|
|
741
|
+
function needParamScore(match) {
|
|
742
|
+
return match.matched !== undefined && match.matched.length > 0 ? 1 : 0.5
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/* -------------------------------------------------------------------------- */
|
|
746
|
+
/* Suggestion / output helpers */
|
|
747
|
+
/* -------------------------------------------------------------------------- */
|
|
748
|
+
|
|
749
|
+
/** Pick the attributes worth showing to the model (max 6). */
|
|
750
|
+
function pickSpecs(item) {
|
|
751
|
+
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
|
|
752
|
+
const specs = []
|
|
753
|
+
for (const attribute of attributesOf(item)) {
|
|
754
|
+
if (!preferred.test(attribute.name)) continue
|
|
755
|
+
specs.push({ name: attribute.name, value: attribute.value })
|
|
756
|
+
if (specs.length >= 6) break
|
|
757
|
+
}
|
|
758
|
+
return specs
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** @param {ReturnType<typeof scoreCandidate>} candidate @param {ScoreContext} context */
|
|
762
|
+
function reasonsFor(candidate, context) {
|
|
763
|
+
const reasons = []
|
|
764
|
+
if (context.assembly === 'smt') {
|
|
765
|
+
reasons.push(
|
|
766
|
+
candidate.expand
|
|
767
|
+
? `扩展库:每片板摊 $${money(candidate.feePerBoardUsd)} 上料费(每料号 $${EXTENDED_PART_FEE_USD}/单)`
|
|
768
|
+
: '基础库:无上料费',
|
|
769
|
+
)
|
|
770
|
+
}
|
|
771
|
+
if (candidate.unitUsd !== undefined) {
|
|
772
|
+
reasons.push(`阶梯价 $${candidate.unitUsd}(按采购量 ${context.orderQty})`)
|
|
773
|
+
}
|
|
774
|
+
reasons.push(`库存 ${candidate.stock}`)
|
|
775
|
+
if (candidate.cover >= 1) reasons.push(`库存覆盖需求(${Math.round(candidate.cover * 100)}%)`)
|
|
776
|
+
else if (candidate.cover < 1) reasons.push(`库存仅覆盖需求的 ${Math.round(candidate.cover * 100)}%`)
|
|
777
|
+
if (candidate.modelExact === true) reasons.push('型号精确匹配')
|
|
778
|
+
if (candidate.match.matched.length > 0) reasons.push(`参数匹配:${candidate.match.matched.slice(0, 4).join(', ')}`)
|
|
779
|
+
return reasons
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Find requirement-matching parts.
|
|
784
|
+
* @param {ReturnType<typeof createLcscService>} service
|
|
785
|
+
* @param {Record<string, unknown>} args
|
|
786
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
787
|
+
*/
|
|
788
|
+
export async function findParts(service, args, options = {}) {
|
|
789
|
+
const needText = asString(args.need)
|
|
790
|
+
if (needText === undefined) throw new Error('find_lcsc_parts requires a non-empty `need` string')
|
|
791
|
+
const assembly = args.assembly === 'smt' ? 'smt' : 'hand'
|
|
792
|
+
const quantity = clampInteger(args.quantity, 1, 100000, 1)
|
|
793
|
+
const boards = clampInteger(args.boards, 1, 100000, 1)
|
|
794
|
+
const limit = clampInteger(args.limit, 1, MAX_CANDIDATES, DEFAULT_LIMIT)
|
|
795
|
+
const sort = ['cost', 'stock'].includes(args.sort) ? args.sort : 'balanced'
|
|
796
|
+
const includeCny = args.include_cny !== false && assembly === 'hand'
|
|
797
|
+
const exclude = new Set(
|
|
798
|
+
(Array.isArray(args.exclude_lcsc) ? args.exclude_lcsc : [])
|
|
799
|
+
.map((code) => normalizeCode(code))
|
|
800
|
+
.filter(Boolean),
|
|
801
|
+
)
|
|
802
|
+
const need = parseNeed(needText)
|
|
803
|
+
const context = { assembly, quantity, boards, orderQty: quantity * boards, sort, modelToken: need.modelGuess ? need.modelToken : undefined }
|
|
804
|
+
const usdToCnyFactor = asNumber(service.settings?.usdToCnyFactor) ?? 6.0
|
|
805
|
+
const notes = []
|
|
806
|
+
const hints = []
|
|
807
|
+
|
|
808
|
+
/** @type {Map<string, Record<string, unknown>>} */
|
|
809
|
+
const pool = new Map()
|
|
810
|
+
/** @param {{ total: number, items: Record<string, unknown>[] }} result */
|
|
811
|
+
const absorb = (result) => {
|
|
812
|
+
for (const item of result.items) {
|
|
813
|
+
if (!pool.has(String(item.code))) pool.set(String(item.code), item)
|
|
814
|
+
}
|
|
815
|
+
return pool.size
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const queries = []
|
|
819
|
+
if (need.directCode !== undefined) {
|
|
820
|
+
queries.push({ keyword: need.directCode })
|
|
821
|
+
} else if (need.modelGuess) {
|
|
822
|
+
queries.push({ keyword: need.raw })
|
|
823
|
+
} else if (assembly === 'smt') {
|
|
824
|
+
// Search the base library first so free-to-load parts are not drowned out.
|
|
825
|
+
queries.push({ keyword: need.searchKeyword, libraryType: 'base' })
|
|
826
|
+
queries.push({ keyword: need.searchKeyword })
|
|
827
|
+
} else {
|
|
828
|
+
queries.push({ keyword: need.searchKeyword })
|
|
829
|
+
}
|
|
830
|
+
for (const query of queries) {
|
|
831
|
+
const result = await service.searchRaw(query.keyword, {
|
|
832
|
+
pageSize: SEARCH_PAGE_SIZE,
|
|
833
|
+
libraryType: query.libraryType,
|
|
834
|
+
signal: options.signal,
|
|
835
|
+
})
|
|
836
|
+
absorb(result)
|
|
837
|
+
if (need.directCode !== undefined || need.modelGuess) break
|
|
838
|
+
}
|
|
839
|
+
if (pool.size === 0 && need.searchKeyword !== need.raw) {
|
|
840
|
+
const fallback = await service.searchRaw(need.raw, { pageSize: SEARCH_PAGE_SIZE, signal: options.signal })
|
|
841
|
+
absorb(fallback)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
let relaxed = false
|
|
845
|
+
let scored = [...pool.values()]
|
|
846
|
+
.filter((item) => !exclude.has(String(item.code)))
|
|
847
|
+
.filter((item) => (asInteger(item.stock) ?? 0) > 0)
|
|
848
|
+
.map((item) => scoreCandidate(item, need, context))
|
|
849
|
+
.filter((candidate) => candidate.match.ok)
|
|
850
|
+
if (scored.length === 0 && pool.size > 0) {
|
|
851
|
+
relaxed = true
|
|
852
|
+
notes.push('没有同时满足全部参数条件的候选;已放宽参数过滤,仅按关键词、库存和价格排序,请人工核对参数。')
|
|
853
|
+
scored = [...pool.values()]
|
|
854
|
+
.filter((item) => !exclude.has(String(item.code)))
|
|
855
|
+
.filter((item) => (asInteger(item.stock) ?? 0) > 0)
|
|
856
|
+
.map((item) => scoreCandidate(item, need, context))
|
|
857
|
+
.map((candidate) => ({ ...candidate, match: { ...candidate.match, ok: true } }))
|
|
858
|
+
}
|
|
859
|
+
let ranked = rankCandidates(scored, context, context.orderQty)
|
|
860
|
+
|
|
861
|
+
// Best-effort CNY enrichment for the top candidates in hand mode.
|
|
862
|
+
/** @type {Map<string, { ok: boolean, unit?: number, stock?: number, reason?: string }>} */
|
|
863
|
+
const cnyByCode = new Map()
|
|
864
|
+
if (includeCny && ranked.length > 0) {
|
|
865
|
+
const targets = ranked.slice(0, Math.min(MAX_CNY_LOOKUPS, ranked.length))
|
|
866
|
+
const results = await mapLimit(targets, 2, async (candidate) => {
|
|
867
|
+
try {
|
|
868
|
+
return await service.cny(String(candidate.item.code), options.signal)
|
|
869
|
+
} catch (error) {
|
|
870
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
871
|
+
}
|
|
872
|
+
})
|
|
873
|
+
for (let index = 0; index < targets.length; index += 1) {
|
|
874
|
+
const result = results[index]
|
|
875
|
+
cnyByCode.set(String(targets[index].item.code), result)
|
|
876
|
+
if (index < targets.length - 1) await sleep(120, options.signal)
|
|
877
|
+
}
|
|
878
|
+
const covered = [...cnyByCode.values()].filter((result) => result?.ok === true).length
|
|
879
|
+
if (covered > 0) notes.push(`人民币价来自立创商城国内站(best-effort,可能被限流):${covered}/${targets.length} 个候选取到 CNY。排序仍以 USD 参考价为基准。`)
|
|
880
|
+
else hints.push('国内站 CNY 价本次不可用(WAF/限流),价格列为 USD 参考;可稍后重试或到立创商城页面确认。')
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const candidates = ranked.slice(0, limit).map((candidate) => {
|
|
884
|
+
const cny = cnyByCode.get(String(candidate.item.code))
|
|
885
|
+
const cnyUnit = cny?.ok === true ? asNumber(cny.unit) : undefined
|
|
886
|
+
const price = cnyUnit !== undefined
|
|
887
|
+
? { currency: 'CNY', unit: money(cnyUnit), source: 'item.szlcsc.com' }
|
|
888
|
+
: candidate.unitUsd !== undefined
|
|
889
|
+
? { currency: 'USD', unit: money(candidate.unitUsd), source: 'jlcpcb-smt' }
|
|
890
|
+
: undefined
|
|
891
|
+
const bulkUsd = bulkTierUsd(candidate.item)
|
|
892
|
+
const cnyEstimate = cnyUnit === undefined && assembly === 'hand'
|
|
893
|
+
? estimateCnyFromUsd(bulkUsd, usdToCnyFactor)
|
|
894
|
+
: undefined
|
|
895
|
+
return {
|
|
896
|
+
lcsc: candidate.item.code,
|
|
897
|
+
model: candidate.item.model,
|
|
898
|
+
brand: candidate.item.brand,
|
|
899
|
+
package: candidate.item.package,
|
|
900
|
+
category: candidate.item.category,
|
|
901
|
+
library: candidate.expand ? 'expand' : 'base',
|
|
902
|
+
preferred: candidate.item.preferred === true ? true : null,
|
|
903
|
+
stock: candidate.stock,
|
|
904
|
+
price,
|
|
905
|
+
effective_unit_usd: candidate.unitEffectiveUsd === undefined ? undefined : money(candidate.unitEffectiveUsd),
|
|
906
|
+
fee_per_board_usd: context.assembly === 'smt' ? money(candidate.feePerBoardUsd) : 0,
|
|
907
|
+
cny_price: cnyUnit === undefined ? null : money(cnyUnit),
|
|
908
|
+
cny_estimate: cnyEstimate === undefined
|
|
909
|
+
? null
|
|
910
|
+
: {
|
|
911
|
+
unit: cnyEstimate,
|
|
912
|
+
factor: usdToCnyFactor,
|
|
913
|
+
basis: 'highest-volume USD tier × factor',
|
|
914
|
+
note: '参考估算,非实时汇率/非实际报价;以立创商城页面为准',
|
|
915
|
+
},
|
|
916
|
+
specs: pickSpecs(candidate.item),
|
|
917
|
+
score: candidate.score,
|
|
918
|
+
reasons: reasonsFor(candidate, context),
|
|
919
|
+
url: asString(candidate.item.url) ?? `https://www.lcsc.com/product-detail/${candidate.item.code}.html`,
|
|
920
|
+
}
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
if (candidates.length === 0) {
|
|
924
|
+
hints.push('没有找到候选。建议:改用型号/LCSC 编号;或减少参数(如去掉精度/尺寸);或把中文品类换成英文(capacitor / resistor / LDO / connector)。')
|
|
925
|
+
}
|
|
926
|
+
if (need.requirements.length > 0) {
|
|
927
|
+
notes.push(`解析到参数条件:${need.requirements.map((requirement) => `${requirement.kind}${requirement.comparator ?? '='}${requirement.value}`).join(', ')}`)
|
|
928
|
+
}
|
|
929
|
+
if (context.assembly === 'hand') notes.push('assembly=hand:不考虑嘉立创上料费与基础库偏好,价格优先展示立创商城人民币零售价(取不到时回退 USD 参考价)。')
|
|
930
|
+
if (candidates.some((candidate) => candidate.cny_estimate !== null)) {
|
|
931
|
+
notes.push(`CNY 估算 = 该料最高采购量档 USD × ${usdToCnyFactor}(Recording_card 实测中位误差约 1.6%),仅供参考;USD 为实际参考价。`)
|
|
932
|
+
}
|
|
933
|
+
else notes.push(`assembly=smt:扩展库按每料号 $${EXTENDED_PART_FEE_USD}/单摊销;优选扩展库分类接口未暴露,以下单页为准。`)
|
|
934
|
+
|
|
935
|
+
return {
|
|
936
|
+
need: needText,
|
|
937
|
+
assembly,
|
|
938
|
+
quantity,
|
|
939
|
+
boards,
|
|
940
|
+
order_qty: context.orderQty,
|
|
941
|
+
currency: includeCny ? 'CNY (best effort) + USD reference' : 'USD',
|
|
942
|
+
candidates,
|
|
943
|
+
hints,
|
|
944
|
+
notes,
|
|
945
|
+
relaxed,
|
|
946
|
+
fetched_at: new Date().toISOString(),
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/* -------------------------------------------------------------------------- */
|
|
951
|
+
/* BOM audit */
|
|
952
|
+
/* -------------------------------------------------------------------------- */
|
|
953
|
+
|
|
954
|
+
/** @param {unknown} input @returns {{ line: number, code: string, qty: number, designators?: string, comment?: string }[]} */
|
|
955
|
+
function parseBomInput(input) {
|
|
956
|
+
if (typeof input === 'string') {
|
|
957
|
+
const head = input.slice(0, 8)
|
|
958
|
+
if (head.startsWith('PK\u0003\u0004') || head.startsWith('PK\u0005\u0006') || head.startsWith('<?xml')) {
|
|
959
|
+
throw new Error(
|
|
960
|
+
'audit_lcsc_bom 收到的是 xlsx/二进制内容而不是 BOM 文本:请先在 Excel 里「另存为」CSV/TSV(或转成 CSV 文本)再传入',
|
|
961
|
+
)
|
|
962
|
+
}
|
|
963
|
+
const text = input.trim()
|
|
964
|
+
if (text === '') throw new Error('audit_lcsc_bom requires a non-empty BOM string')
|
|
965
|
+
if (text.startsWith('[') || text.startsWith('{')) {
|
|
966
|
+
let parsed
|
|
967
|
+
try {
|
|
968
|
+
parsed = JSON.parse(text)
|
|
969
|
+
} catch {
|
|
970
|
+
parsed = undefined
|
|
971
|
+
}
|
|
972
|
+
if (Array.isArray(parsed)) return { rows: parseBomRows(parsed), skipped: [] }
|
|
973
|
+
if (isRecord(parsed) && Array.isArray(parsed.rows)) return { rows: parseBomRows(parsed.rows), skipped: [] }
|
|
974
|
+
}
|
|
975
|
+
const detailed = parseBomCsvDetailed(text)
|
|
976
|
+
return {
|
|
977
|
+
skipped: detailed.skipped,
|
|
978
|
+
rows: detailed.rows.map((row) => ({
|
|
979
|
+
line: row.line,
|
|
980
|
+
code: normalizeCode(row.code) ?? '',
|
|
981
|
+
qty: Math.max(1, asInteger(row.qty) ?? 1),
|
|
982
|
+
designators: row.designators,
|
|
983
|
+
comment: row.comment,
|
|
984
|
+
})),
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (Array.isArray(input)) return { rows: parseBomRows(input), skipped: [] }
|
|
988
|
+
throw new Error('audit_lcsc_bom requires `bom` as CSV text or an array of {lcsc/code, qty} rows')
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** @param {unknown[]} rows */
|
|
992
|
+
function parseBomRows(rows) {
|
|
993
|
+
const result = []
|
|
994
|
+
for (const row of rows) {
|
|
995
|
+
if (!isRecord(row)) continue
|
|
996
|
+
const code = normalizeCode(row.lcsc ?? row.code ?? row.LCSC)
|
|
997
|
+
const qty = Math.max(1, asInteger(row.qty ?? row.quantity ?? 1) ?? 1)
|
|
998
|
+
result.push({
|
|
999
|
+
line: result.length + 1,
|
|
1000
|
+
code: code ?? '',
|
|
1001
|
+
qty,
|
|
1002
|
+
designators: asString(row.designators ?? row.refs),
|
|
1003
|
+
comment: asString(row.comment ?? row.description ?? row.model),
|
|
1004
|
+
})
|
|
1005
|
+
}
|
|
1006
|
+
return result
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Build a synthetic need from an existing part so we can search for a base
|
|
1011
|
+
* library alternative with the same identity parameters.
|
|
1012
|
+
* @param {Record<string, unknown>} item
|
|
1013
|
+
* @param {ReturnType<typeof parseNeed>} originalNeed
|
|
1014
|
+
*/
|
|
1015
|
+
function needFromItem(item, originalNeed) {
|
|
1016
|
+
const requirements = []
|
|
1017
|
+
for (const attribute of attributesOf(item)) {
|
|
1018
|
+
const name = attribute.name
|
|
1019
|
+
const value = attribute.value
|
|
1020
|
+
const pushParsed = (kind, comparator) => {
|
|
1021
|
+
for (const parsed of parseQuantitiesOfKind(value, kind)) {
|
|
1022
|
+
requirements.push({ kind, value: parsed, unit: '', comparator, raw: `${name}=${value}` })
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (/^capacitance$/i.test(name)) pushParsed('capacitance', '=')
|
|
1026
|
+
else if (/^resistance$/i.test(name)) pushParsed('resistance', '=')
|
|
1027
|
+
else if (/^inductance$/i.test(name)) pushParsed('inductance', '=')
|
|
1028
|
+
else if (/^output voltage$/i.test(name)) pushParsed('voltage', '=')
|
|
1029
|
+
else if (/voltage rating|reverse voltage|drain-source voltage|maximum input voltage/i.test(name)) pushParsed('voltage', '>=')
|
|
1030
|
+
else if (/output current|forward current|current - output/i.test(name)) pushParsed('current', '>=')
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
...originalNeed,
|
|
1034
|
+
requirements,
|
|
1035
|
+
directCode: undefined,
|
|
1036
|
+
modelGuess: false,
|
|
1037
|
+
categories: originalNeed.categories,
|
|
1038
|
+
package: originalNeed.package ?? asString(item.package),
|
|
1039
|
+
searchKeyword: [
|
|
1040
|
+
asString(item.package),
|
|
1041
|
+
...attributesOf(item)
|
|
1042
|
+
.filter((attribute) => /capacitance|resistance|voltage rating|output voltage|output current|inductance/i.test(attribute.name))
|
|
1043
|
+
.slice(0, 3)
|
|
1044
|
+
.map((attribute) => attribute.value),
|
|
1045
|
+
].filter(Boolean).join(' ') || String(item.model ?? item.code ?? ''),
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Find one basic-library substitute for an extended part.
|
|
1051
|
+
* @param {ReturnType<typeof createLcscService>} service
|
|
1052
|
+
* @param {Record<string, unknown>} item
|
|
1053
|
+
* @param {ReturnType<typeof parseNeed>} originalNeed
|
|
1054
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
1055
|
+
*/
|
|
1056
|
+
export async function findAlternative(service, item, originalNeed, options = {}) {
|
|
1057
|
+
const packageName = asString(item.package)
|
|
1058
|
+
const tryCodes = []
|
|
1059
|
+
const alternatives = asString(item.alternatives)
|
|
1060
|
+
if (alternatives !== undefined) {
|
|
1061
|
+
for (const match of alternatives.matchAll(/C\d{4,11}/gi)) tryCodes.push(match[0].toUpperCase())
|
|
1062
|
+
}
|
|
1063
|
+
const candidates = []
|
|
1064
|
+
for (const code of tryCodes.slice(0, 3)) {
|
|
1065
|
+
const result = await service.searchRaw(code, { pageSize: 5, signal: options.signal })
|
|
1066
|
+
const hit = result.items.find((candidate) => String(candidate.code) === code)
|
|
1067
|
+
if (hit !== undefined && String(hit.libraryType).toLowerCase() === 'base') candidates.push(hit)
|
|
1068
|
+
}
|
|
1069
|
+
if (candidates.length === 0) {
|
|
1070
|
+
const need = needFromItem(item, originalNeed)
|
|
1071
|
+
const result = await service.searchRaw(need.searchKeyword, {
|
|
1072
|
+
pageSize: 50,
|
|
1073
|
+
libraryType: 'base',
|
|
1074
|
+
signal: options.signal,
|
|
1075
|
+
})
|
|
1076
|
+
const categoryName = asString(item.category)
|
|
1077
|
+
for (const candidate of result.items) {
|
|
1078
|
+
if (String(candidate.libraryType).toLowerCase() !== 'base') continue
|
|
1079
|
+
if (packageName !== undefined && !packageMatches(asString(candidate.package), packageName)) continue
|
|
1080
|
+
// Stay conservative: a substitute must be the same kind of device.
|
|
1081
|
+
if (categoryName !== undefined) {
|
|
1082
|
+
const candidateCategory = asString(candidate.category)
|
|
1083
|
+
if (candidateCategory === undefined || candidateCategory.toLowerCase() !== categoryName.toLowerCase()) continue
|
|
1084
|
+
}
|
|
1085
|
+
if ((asInteger(candidate.stock) ?? 0) <= 0) continue
|
|
1086
|
+
if (candidateMatches(candidate, need).ok) candidates.push(candidate)
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (candidates.length === 0) return undefined
|
|
1090
|
+
const scored = candidates.map((candidate) => scoreCandidate(candidate, originalNeed, { assembly: 'smt', quantity: 1, boards: 1, orderQty: 1, sort: 'cost' }))
|
|
1091
|
+
scored.sort((left, right) => (left.unitUsd ?? Number.POSITIVE_INFINITY) - (right.unitUsd ?? Number.POSITIVE_INFINITY))
|
|
1092
|
+
const best = scored[0]
|
|
1093
|
+
return {
|
|
1094
|
+
lcsc: best.item.code,
|
|
1095
|
+
model: best.item.model,
|
|
1096
|
+
package: best.item.package,
|
|
1097
|
+
library: 'base',
|
|
1098
|
+
stock: asInteger(best.item.stock) ?? 0,
|
|
1099
|
+
unit_usd: best.unitUsd === undefined ? undefined : money(best.unitUsd),
|
|
1100
|
+
fee_saved_per_board_usd: undefined,
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Reduce the data layer's source-aware `part()` detail into the flat shape the
|
|
1106
|
+
* auditor consumes.
|
|
1107
|
+
*
|
|
1108
|
+
* The two warehouses are not interchangeable: hand soldering buys from the LCSC
|
|
1109
|
+
* store (lcsc.com / 立创商城), while assembly="smt" is limited to JLCPCB's SMT
|
|
1110
|
+
* parts library. A part can exist in one and not the other, and their stock
|
|
1111
|
+
* counts are unrelated.
|
|
1112
|
+
* @param {Record<string, unknown>} detail
|
|
1113
|
+
* @param {'hand' | 'smt'} assembly
|
|
1114
|
+
*/
|
|
1115
|
+
function itemFromPartDetail(detail, assembly) {
|
|
1116
|
+
const stock = isRecord(detail.stock) ? detail.stock : {}
|
|
1117
|
+
const countOf = (key) => (isRecord(stock[key]) ? asInteger(stock[key].count) : undefined)
|
|
1118
|
+
const intl = countOf('intl')
|
|
1119
|
+
const domestic = countOf('domestic')
|
|
1120
|
+
const jlc = countOf('jlcpcb')
|
|
1121
|
+
// Stock source preference, identical for both modes: the verifiable JLCPCB
|
|
1122
|
+
// SMT count first, then lcsc.com's *international* warehouse, then domestic.
|
|
1123
|
+
// Hand buyers draw on the LCSC store, but lcsc.com only reports its
|
|
1124
|
+
// international allocation — many domestic-stocked parts read 0 there
|
|
1125
|
+
// (measured C15849: lcsc.com 0 vs JLCPCB 12.46M) — and the domestic stock page
|
|
1126
|
+
// is blocked, so we keep the verifiable count and label its source rather than
|
|
1127
|
+
// passing an international-warehouse 0 off as "立创商城无库存".
|
|
1128
|
+
const order = [['jlcpcb-smt', jlc], ['lcsc.com', intl], ['item.szlcsc.com', domestic]]
|
|
1129
|
+
const picked = order.find(([, value]) => value !== undefined)
|
|
1130
|
+
const prices = isRecord(detail.prices) ? detail.prices : {}
|
|
1131
|
+
const usd = isRecord(prices.usd) ? prices.usd : undefined
|
|
1132
|
+
const identity = isRecord(detail.identity) ? detail.identity : {}
|
|
1133
|
+
return {
|
|
1134
|
+
code: asString(detail.code) ?? '',
|
|
1135
|
+
model: asString(identity.model),
|
|
1136
|
+
brand: asString(identity.brand),
|
|
1137
|
+
package: asString(detail.package) ?? asString(identity.package),
|
|
1138
|
+
libraryType: asString(detail.libraryType),
|
|
1139
|
+
minPurchaseNum: asInteger(detail.minPurchaseNum),
|
|
1140
|
+
stock: picked === undefined ? undefined : picked[1],
|
|
1141
|
+
stockSource: picked === undefined ? undefined : picked[0],
|
|
1142
|
+
priceSource: usd === undefined ? undefined : asString(usd.source),
|
|
1143
|
+
priceUsdTiers: usd !== undefined && Array.isArray(usd.tiers) ? usd.tiers : [],
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Audit an existing BOM.
|
|
1149
|
+
* @param {ReturnType<typeof createLcscService>} service
|
|
1150
|
+
* @param {Record<string, unknown>} args
|
|
1151
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
1152
|
+
*/
|
|
1153
|
+
export async function auditBom(service, args, options = {}) {
|
|
1154
|
+
const { rows, skipped } = parseBomInput(args.bom)
|
|
1155
|
+
if (rows.length === 0) {
|
|
1156
|
+
throw new Error(
|
|
1157
|
+
`audit_lcsc_bom parsed 0 rows from the BOM input${skipped.length > 0 ? `(其中 ${skipped.length} 行缺少可识别的 LCSC 编号)` : ''}`,
|
|
1158
|
+
)
|
|
1159
|
+
}
|
|
1160
|
+
const assembly = args.assembly === 'smt' ? 'smt' : 'hand'
|
|
1161
|
+
const boards = clampInteger(args.boards, 1, 100000, 1)
|
|
1162
|
+
const includeCny = args.include_cny === true
|
|
1163
|
+
const suggestAlternatives = args.suggest_alternatives !== false
|
|
1164
|
+
const notes = []
|
|
1165
|
+
const hints = []
|
|
1166
|
+
const usdToCnyFactor = asNumber(service.settings?.usdToCnyFactor) ?? 6.0
|
|
1167
|
+
|
|
1168
|
+
const codes = [...new Set(rows.map((row) => row.code).filter((code) => code !== ''))]
|
|
1169
|
+
// Hand assembly buys parts from the LCSC store, so it must read the LCSC
|
|
1170
|
+
// availability; SMT assembly can only use JLCPCB's own SMT library. Using one
|
|
1171
|
+
// warehouse's stock number for the other mode reports the wrong inventory, and
|
|
1172
|
+
// worse, marks LCSC-stocked parts as NOT-FOUND just because SMT does not carry
|
|
1173
|
+
// them.
|
|
1174
|
+
const useLcscStore = assembly === 'hand' && typeof service.part === 'function'
|
|
1175
|
+
const infoEntries = await mapLimit(codes, 3, async (code) => {
|
|
1176
|
+
try {
|
|
1177
|
+
if (useLcscStore) {
|
|
1178
|
+
const detail = await service.part(code, { includeCny: false, signal: options.signal })
|
|
1179
|
+
if (detail === undefined || asString(detail.status) === 'NOT-FOUND') {
|
|
1180
|
+
return [code, undefined, 'not found in the LCSC store or the JLCPCB SMT library']
|
|
1181
|
+
}
|
|
1182
|
+
return [code, itemFromPartDetail(detail, assembly)]
|
|
1183
|
+
}
|
|
1184
|
+
const result = await service.searchRaw(code, { pageSize: 5, signal: options.signal })
|
|
1185
|
+
// Never accept a fuzzy hit as if it were the requested part: JLCPCB search
|
|
1186
|
+
// is keyword-based, so searching a code that is not a real part (e.g. the
|
|
1187
|
+
// footprint string "C0603") returns thousands of unrelated components.
|
|
1188
|
+
const hit = result.items.find((candidate) => String(candidate.code).toUpperCase() === code)
|
|
1189
|
+
if (hit === undefined) {
|
|
1190
|
+
const nearest = result.items[0]
|
|
1191
|
+
return [
|
|
1192
|
+
code,
|
|
1193
|
+
undefined,
|
|
1194
|
+
nearest === undefined
|
|
1195
|
+
? 'not found in JLCPCB library'
|
|
1196
|
+
: `exact LCSC code not in JLCPCB library; rejected nearest fuzzy hit ${nearest.code} (${nearest.model ?? '?'})`,
|
|
1197
|
+
]
|
|
1198
|
+
}
|
|
1199
|
+
return [code, hit]
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
return [code, undefined, error instanceof Error ? error.message : String(error)]
|
|
1202
|
+
}
|
|
1203
|
+
})
|
|
1204
|
+
/** @type {Map<string, Record<string, unknown> | undefined>} */
|
|
1205
|
+
const infoByCode = new Map()
|
|
1206
|
+
const errors = []
|
|
1207
|
+
for (const entry of infoEntries) {
|
|
1208
|
+
const [code, hit, error] = entry
|
|
1209
|
+
infoByCode.set(code, hit)
|
|
1210
|
+
if (hit === undefined) errors.push(`${code}: ${error ?? 'not found in JLCPCB library'}`)
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const cnyByCode = new Map()
|
|
1214
|
+
if (includeCny) {
|
|
1215
|
+
const cnyTargets = codes.slice(0, MAX_AUDIT_CNY_LOOKUPS)
|
|
1216
|
+
const results = await mapLimit(cnyTargets, 2, async (code) => {
|
|
1217
|
+
try {
|
|
1218
|
+
return await service.cny(code, options.signal)
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
1221
|
+
}
|
|
1222
|
+
})
|
|
1223
|
+
results.forEach((result, index) => cnyByCode.set(cnyTargets[index], result))
|
|
1224
|
+
notes.push(`CNY prices requested for ${cnyTargets.length}/${codes.length} parts (best effort; domestic site is WAF/rate limited).`)
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
let extendedUnique = new Set()
|
|
1228
|
+
const auditRows = rows.map((row) => {
|
|
1229
|
+
const item = infoByCode.get(row.code)
|
|
1230
|
+
const qty = row.qty
|
|
1231
|
+
const orderQty = qty * boards
|
|
1232
|
+
if (item === undefined) {
|
|
1233
|
+
return {
|
|
1234
|
+
line: row.line,
|
|
1235
|
+
lcsc: row.code || '(missing)',
|
|
1236
|
+
qty,
|
|
1237
|
+
designators: row.designators,
|
|
1238
|
+
status: 'NOT-FOUND',
|
|
1239
|
+
flags: [
|
|
1240
|
+
assembly === 'hand'
|
|
1241
|
+
? '立创商城与嘉立创 SMT 库均未查到该编号'
|
|
1242
|
+
: 'LCSC 编号在嘉立创 SMT 元件库中未找到',
|
|
1243
|
+
],
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
const rawLibrary = String(item.libraryType ?? '').toLowerCase()
|
|
1247
|
+
const library = rawLibrary === 'base' ? 'base' : rawLibrary === 'expand' ? 'expand' : undefined
|
|
1248
|
+
if (library === 'expand') extendedUnique.add(String(item.code))
|
|
1249
|
+
const tiers = Array.isArray(item.priceUsdTiers) ? item.priceUsdTiers : []
|
|
1250
|
+
const unitUsd = pickTierPrice(tiers, orderQty)
|
|
1251
|
+
const stock = asInteger(item.stock) ?? 0
|
|
1252
|
+
const flags = []
|
|
1253
|
+
if (stock <= 0) flags.push('无库存')
|
|
1254
|
+
else if (stock < orderQty) flags.push(`库存不足(需 ${orderQty})`)
|
|
1255
|
+
if (library === 'expand') flags.push(assembly === 'smt' ? '扩展库:$3/料号上料费' : '扩展库(手焊无上料费)')
|
|
1256
|
+
if (library === undefined && assembly === 'hand') flags.push('不在嘉立创 SMT 库(手焊可单独买)')
|
|
1257
|
+
if (unitUsd === undefined) flags.push('无价格数据')
|
|
1258
|
+
const minPurchase = asInteger(item.minPurchaseNum)
|
|
1259
|
+
if (minPurchase !== undefined && minPurchase > orderQty) flags.push(`起订量 ${minPurchase}`)
|
|
1260
|
+
const cny = cnyByCode.get(String(item.code))
|
|
1261
|
+
const unitCny = cny?.ok === true ? asNumber(cny.unit) : undefined
|
|
1262
|
+
const bulkUsd = bulkTierUsd(item)
|
|
1263
|
+
const unitCnyEstimate =
|
|
1264
|
+
unitCny === undefined && assembly === 'hand' ? estimateCnyFromUsd(bulkUsd, usdToCnyFactor) : undefined
|
|
1265
|
+
return {
|
|
1266
|
+
line: row.line,
|
|
1267
|
+
lcsc: String(item.code),
|
|
1268
|
+
qty,
|
|
1269
|
+
designators: row.designators,
|
|
1270
|
+
status: stock <= 0 ? 'NO-STOCK' : 'verified',
|
|
1271
|
+
model: item.model,
|
|
1272
|
+
brand: item.brand,
|
|
1273
|
+
package: item.package,
|
|
1274
|
+
library,
|
|
1275
|
+
stock,
|
|
1276
|
+
stock_source: item.stockSource ?? 'jlcpcb-smt',
|
|
1277
|
+
price_source: item.priceSource ?? 'jlcpcb-smt',
|
|
1278
|
+
unit_usd: unitUsd === undefined ? undefined : money(unitUsd),
|
|
1279
|
+
subtotal_usd: unitUsd === undefined ? undefined : money(unitUsd * orderQty),
|
|
1280
|
+
unit_cny: unitCny === undefined ? undefined : money(unitCny),
|
|
1281
|
+
subtotal_cny: unitCny === undefined ? undefined : money(unitCny * orderQty),
|
|
1282
|
+
unit_cny_estimate: unitCnyEstimate === undefined ? undefined : money(unitCnyEstimate),
|
|
1283
|
+
subtotal_cny_estimate: unitCnyEstimate === undefined ? undefined : money(unitCnyEstimate * orderQty),
|
|
1284
|
+
flags,
|
|
1285
|
+
}
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
// Alternatives for extended parts (bounded, best effort).
|
|
1289
|
+
const alternatives = new Map()
|
|
1290
|
+
if (suggestAlternatives && assembly === 'smt') {
|
|
1291
|
+
const costByCode = new Map()
|
|
1292
|
+
for (const row of auditRows) {
|
|
1293
|
+
if (row.library !== 'expand') continue
|
|
1294
|
+
costByCode.set(row.lcsc, (costByCode.get(row.lcsc) ?? 0) + (row.subtotal_usd ?? 0))
|
|
1295
|
+
}
|
|
1296
|
+
const expandCodes = [...extendedUnique]
|
|
1297
|
+
.sort((left, right) => (costByCode.get(right) ?? 0) - (costByCode.get(left) ?? 0))
|
|
1298
|
+
.slice(0, MAX_ALTERNATIVE_LOOKUPS)
|
|
1299
|
+
const originalNeed = parseNeed('')
|
|
1300
|
+
const pairs = await mapLimit(expandCodes, 2, async (code) => {
|
|
1301
|
+
const item = infoByCode.get(code)
|
|
1302
|
+
if (item === undefined) return [code, undefined]
|
|
1303
|
+
try {
|
|
1304
|
+
return [code, await findAlternative(service, item, originalNeed, { signal: options.signal })]
|
|
1305
|
+
} catch {
|
|
1306
|
+
return [code, undefined]
|
|
1307
|
+
}
|
|
1308
|
+
})
|
|
1309
|
+
for (const [code, alternative] of pairs) {
|
|
1310
|
+
if (alternative !== undefined) alternatives.set(code, alternative)
|
|
1311
|
+
}
|
|
1312
|
+
if (expandCodes.length < extendedUnique.size) {
|
|
1313
|
+
notes.push(`替代料建议仅检查了 ${expandCodes.length}/${extendedUnique.size} 个扩展库料号(每次最多 ${MAX_ALTERNATIVE_LOOKUPS} 个)。`)
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const componentUsd = auditRows.reduce((sum, row) => sum + (row.subtotal_usd ?? 0), 0)
|
|
1318
|
+
const componentCny = auditRows.reduce((sum, row) => sum + (row.subtotal_cny ?? 0), 0)
|
|
1319
|
+
const componentCnyEstimate = auditRows.reduce((sum, row) => sum + (row.subtotal_cny_estimate ?? 0), 0)
|
|
1320
|
+
const extendedFee = assembly === 'smt' ? extendedUnique.size * EXTENDED_PART_FEE_USD : 0
|
|
1321
|
+
const cnyCovered = auditRows.filter((row) => row.unit_cny !== undefined).length
|
|
1322
|
+
const summary = {
|
|
1323
|
+
lines: auditRows.length,
|
|
1324
|
+
lines_in_bom: auditRows.length + skipped.length,
|
|
1325
|
+
skipped_lines: skipped.length,
|
|
1326
|
+
unique_parts: codes.length,
|
|
1327
|
+
verified: auditRows.filter((row) => row.status === 'verified').length,
|
|
1328
|
+
no_stock: auditRows.filter((row) => row.status === 'NO-STOCK').length,
|
|
1329
|
+
not_found: auditRows.filter((row) => row.status === 'NOT-FOUND').length,
|
|
1330
|
+
base_parts: auditRows.filter((row) => row.library === 'base').length,
|
|
1331
|
+
expand_parts: extendedUnique.size,
|
|
1332
|
+
library_unknown: auditRows.filter((row) => row.status !== 'NOT-FOUND' && row.library === undefined).length,
|
|
1333
|
+
extended_fee_per_order_usd: money(extendedFee),
|
|
1334
|
+
components_usd: money(componentUsd),
|
|
1335
|
+
components_per_board_usd: money(componentUsd / Math.max(1, boards)),
|
|
1336
|
+
total_usd: money(componentUsd + extendedFee),
|
|
1337
|
+
total_per_board_usd: money((componentUsd + extendedFee) / Math.max(1, boards)),
|
|
1338
|
+
cny_covered_lines: cnyCovered,
|
|
1339
|
+
components_cny: money(componentCny),
|
|
1340
|
+
cny_estimated_lines: auditRows.filter((row) => row.unit_cny_estimate !== undefined).length,
|
|
1341
|
+
components_cny_estimate: money(componentCnyEstimate),
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
notes.push(
|
|
1345
|
+
assembly === 'smt'
|
|
1346
|
+
? 'assembly=smt:价格与库存均取嘉立创 SMT 元件库(USD 阶梯价按采购数量取档),这是贴片备料仓的实际数据。'
|
|
1347
|
+
: 'assembly=hand:价格优先取立创商城零售阶梯价(lcsc.com),库存取嘉立创 SMT 备料仓(立创国内站当前不可达,国际站库存≠国内仓库存,故不冒充)。行内 stock_source / price_source 标明来源。',
|
|
1348
|
+
)
|
|
1349
|
+
notes.push('USD 与 CNY 从不互相换算;每条价格都带 currency 与 source。')
|
|
1350
|
+
if (assembly === 'smt') notes.push(`扩展库上料费按每唯一料号 $${EXTENDED_PART_FEE_USD}/单估算,未包含工程费、钢网费、焊点费和手焊费。`)
|
|
1351
|
+
else {
|
|
1352
|
+
notes.push('assembly=hand:不计嘉立创上料费;价格供参考,手焊请以立创商城实际下单页为准。')
|
|
1353
|
+
if (componentCnyEstimate > 0) {
|
|
1354
|
+
notes.push(`CNY 估算 = 各料最高采购量档 USD × ${usdToCnyFactor}(Recording_card 实测中位误差约 1.6%),仅供参考;USD 为实际参考价。`)
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
notes.push('优选扩展库(Preferred Extended)分类未在公开接口暴露,若需免上料费请到嘉立创 SMT 下单页确认。')
|
|
1358
|
+
|
|
1359
|
+
if (skipped.length > 0) {
|
|
1360
|
+
const list = skipped
|
|
1361
|
+
.slice(0, 30)
|
|
1362
|
+
.map((row) => {
|
|
1363
|
+
const label = asString(row.comment) ?? asString(row.raw) ?? ''
|
|
1364
|
+
return `${row.designators === undefined ? '' : `${row.designators} `}${label}`.trim()
|
|
1365
|
+
})
|
|
1366
|
+
.filter((text) => text !== '')
|
|
1367
|
+
.join(';')
|
|
1368
|
+
notes.push(
|
|
1369
|
+
`BOM 中有 ${skipped.length} 行没有可识别的 LCSC 编号,未计入成本:${list}${skipped.length > 30 ? ' 等' : ''}`,
|
|
1370
|
+
)
|
|
1371
|
+
hints.push('以上行需先选型(补上 LCSC 编号)再重新审计,否则单板成本会偏低。')
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
return {
|
|
1375
|
+
assembly,
|
|
1376
|
+
boards,
|
|
1377
|
+
currency: includeCny ? 'USD + CNY (best effort)' : 'USD',
|
|
1378
|
+
summary,
|
|
1379
|
+
rows: auditRows.map((row) => {
|
|
1380
|
+
const alternative = alternatives.get(row.lcsc)
|
|
1381
|
+
return alternative === undefined ? row : { ...row, alternative }
|
|
1382
|
+
}),
|
|
1383
|
+
skipped: skipped.slice(0, 50),
|
|
1384
|
+
errors,
|
|
1385
|
+
hints,
|
|
1386
|
+
notes,
|
|
1387
|
+
fetched_at: new Date().toISOString(),
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/* -------------------------------------------------------------------------- */
|
|
1392
|
+
/* Rendering */
|
|
1393
|
+
/* -------------------------------------------------------------------------- */
|
|
1394
|
+
|
|
1395
|
+
/** @param {Record<string, unknown>} value */
|
|
1396
|
+
export function renderFindText(value) {
|
|
1397
|
+
const candidates = Array.isArray(value.candidates) ? value.candidates : []
|
|
1398
|
+
const lines = [
|
|
1399
|
+
`Find "${value.need}" [${value.assembly}] qty ${value.quantity}/board x ${value.boards} board(s) => ${candidates.length} candidate(s)`,
|
|
1400
|
+
]
|
|
1401
|
+
for (const candidate of candidates) {
|
|
1402
|
+
if (!isRecord(candidate)) continue
|
|
1403
|
+
const price = isRecord(candidate.price) ? `${candidate.price.unit} ${candidate.price.currency}` : 'n/a'
|
|
1404
|
+
const cnyEstimate = isRecord(candidate.cny_estimate) ? ` | CNY≈${candidate.cny_estimate.unit}(估算)` : ''
|
|
1405
|
+
lines.push(
|
|
1406
|
+
`- ${candidate.lcsc} | ${candidate.model ?? '?'} | ${candidate.package ?? '?'} | lib ${candidate.library} | stock ${candidate.stock} | ${price}${cnyEstimate} | score ${candidate.score}`,
|
|
1407
|
+
)
|
|
1408
|
+
if (Array.isArray(candidate.reasons) && candidate.reasons.length > 0) lines.push(` ${candidate.reasons.join('; ')}`)
|
|
1409
|
+
}
|
|
1410
|
+
for (const hint of Array.isArray(value.hints) ? value.hints : []) lines.push(`hint: ${hint}`)
|
|
1411
|
+
for (const note of Array.isArray(value.notes) ? value.notes : []) lines.push(`note: ${note}`)
|
|
1412
|
+
return lines.join('\n')
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** @param {Record<string, unknown>} value */
|
|
1416
|
+
export function renderAuditText(value) {
|
|
1417
|
+
const summary = isRecord(value.summary) ? value.summary : {}
|
|
1418
|
+
const rows = Array.isArray(value.rows) ? value.rows : []
|
|
1419
|
+
const lines = [
|
|
1420
|
+
`BOM audit [${value.assembly}] boards=${value.boards}: ${summary.lines ?? 0} lines, ${summary.unique_parts ?? 0} unique | base ${summary.base_parts ?? 0} / expand ${summary.expand_parts ?? 0}` +
|
|
1421
|
+
(summary.skipped_lines > 0 ? ` | ${summary.skipped_lines} line(s) skipped (no LCSC code)` : ''),
|
|
1422
|
+
`components USD ${summary.components_usd ?? 0} + extended fee USD ${summary.extended_fee_per_order_usd ?? 0} = USD ${summary.total_usd ?? 0}` +
|
|
1423
|
+
(summary.cny_covered_lines > 0 ? ` | CNY ${summary.components_cny ?? 0} (${summary.cny_covered_lines} lines)` : '') +
|
|
1424
|
+
(summary.components_cny_estimate > 0 ? ` | CNY est. ≈ ${summary.components_cny_estimate} (估算参考)` : ''),
|
|
1425
|
+
]
|
|
1426
|
+
for (const row of rows) {
|
|
1427
|
+
if (!isRecord(row)) continue
|
|
1428
|
+
const flags = Array.isArray(row.flags) && row.flags.length > 0 ? ` [${row.flags.join('; ')}]` : ''
|
|
1429
|
+
const alternative = isRecord(row.alternative) ? ` -> base alt: ${row.alternative.lcsc} (${row.alternative.model ?? '?'})` : ''
|
|
1430
|
+
const cnyEstimate = typeof row.unit_cny_estimate === 'number' ? ` | CNY≈${row.unit_cny_estimate}(估算)` : ''
|
|
1431
|
+
lines.push(
|
|
1432
|
+
`${row.lcsc} x${row.qty} | ${row.status} | lib ${row.library ?? '?'} | stock ${row.stock ?? '?'}${row.stock_source === undefined ? '' : `(${row.stock_source})`} | USD ${row.unit_usd ?? '-'} (sub ${row.subtotal_usd ?? '-'})${cnyEstimate}${flags}${alternative}`,
|
|
1433
|
+
)
|
|
1434
|
+
}
|
|
1435
|
+
for (const error of Array.isArray(value.errors) ? value.errors : []) lines.push(`error: ${error}`)
|
|
1436
|
+
for (const hint of Array.isArray(value.hints) ? value.hints : []) lines.push(`hint: ${hint}`)
|
|
1437
|
+
for (const note of Array.isArray(value.notes) ? value.notes : []) lines.push(`note: ${note}`)
|
|
1438
|
+
return lines.join('\n')
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/* -------------------------------------------------------------------------- */
|
|
1442
|
+
/* Tool definitions */
|
|
1443
|
+
/* -------------------------------------------------------------------------- */
|
|
1444
|
+
|
|
1445
|
+
const FIND_DESCRIPTION =
|
|
1446
|
+
'Find LCSC / JLCPCB components that match a real requirement (package, capacitance, voltage rating, current, resistance, tolerance, dimensions, …) ' +
|
|
1447
|
+
'and return up to 8 candidates ranked by practical cost-effectiveness: unit price at the actual order quantity, stock coverage, parameter match, and ' +
|
|
1448
|
+
'basic-vs-extended JLCPCB library. ' +
|
|
1449
|
+
'Default assembly="hand" for DIY parts you buy and solder yourself (library type ignored, LCSC domestic CNY retail price used when reachable, USD reference otherwise). ' +
|
|
1450
|
+
'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. ' +
|
|
1451
|
+
'Use a precise `need` such as "0402 100nF 50V X7R capacitor", "3.3V LDO SOT-23-5 500mA", "TP4054" or "C1581". ' +
|
|
1452
|
+
'When the LCSC domestic CNY price is unavailable in hand mode, a clearly-labelled reference CNY estimate (bulk-tier USD × configurable factor, default 6.0) is included; the USD price stays the actual reference. ' +
|
|
1453
|
+
'This is NOT a generic keyword search tool: it parses requirements, filters candidates and explains the ranking.'
|
|
1454
|
+
|
|
1455
|
+
const AUDIT_DESCRIPTION =
|
|
1456
|
+
'Audit an existing BOM against the JLCPCB parts library: per line price (USD ladder at the order quantity), stock, basic-vs-extended library, ' +
|
|
1457
|
+
'and for assembly="smt" the USD 3 extended-part feeder fee amortization, plus basic-library alternative suggestions for extended parts. ' +
|
|
1458
|
+
'Input `bom` as CSV/TSV text (LCSC codes required; header optional) or a JSON array like [{"lcsc":"C1581","qty":5}]. ' +
|
|
1459
|
+
'Default assembly="hand" (no feeder fees); set assembly="smt" for JLCPCB assembly cost planning. ' +
|
|
1460
|
+
'CNY retail prices are optional (include_cny=true, best effort, rate limited) and never mixed into USD totals; in hand mode a reference CNY estimate from bulk-tier USD × the configurable factor is reported when the real CNY price is unavailable.'
|
|
1461
|
+
|
|
1462
|
+
/** Build the two DSH tool definitions bound to a service instance. */
|
|
1463
|
+
export function createToolDefinitions(service) {
|
|
1464
|
+
return [
|
|
1465
|
+
{
|
|
1466
|
+
name: 'find_lcsc_parts',
|
|
1467
|
+
description: FIND_DESCRIPTION,
|
|
1468
|
+
parameters: {
|
|
1469
|
+
type: 'object',
|
|
1470
|
+
additionalProperties: false,
|
|
1471
|
+
required: ['need'],
|
|
1472
|
+
properties: {
|
|
1473
|
+
need: {
|
|
1474
|
+
type: 'string',
|
|
1475
|
+
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".',
|
|
1476
|
+
},
|
|
1477
|
+
assembly: {
|
|
1478
|
+
type: 'string',
|
|
1479
|
+
enum: ['hand', 'smt'],
|
|
1480
|
+
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).',
|
|
1481
|
+
},
|
|
1482
|
+
quantity: { type: 'integer', minimum: 1, maximum: 100000, description: 'Parts needed per board (default 1).' },
|
|
1483
|
+
boards: { type: 'integer', minimum: 1, maximum: 100000, description: 'Number of boards to build (default 1; used for price tiers and SMT fee amortization).' },
|
|
1484
|
+
sort: { type: 'string', enum: ['balanced', 'cost', 'stock'], description: 'Ranking preference (default balanced).' },
|
|
1485
|
+
limit: { type: 'integer', minimum: 1, maximum: 8, description: 'Candidates to return (default 5, max 8).' },
|
|
1486
|
+
include_cny: { type: 'boolean', description: 'hand mode only: try to fetch LCSC domestic CNY retail prices for the top candidates (default true; set false to skip the slower, rate-limited lookups).' },
|
|
1487
|
+
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.' },
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
output: {
|
|
1491
|
+
schema: {
|
|
1492
|
+
type: 'object',
|
|
1493
|
+
additionalProperties: false,
|
|
1494
|
+
required: ['need', 'assembly', 'candidates', 'notes'],
|
|
1495
|
+
properties: {
|
|
1496
|
+
need: { type: 'string' },
|
|
1497
|
+
assembly: { type: 'string' },
|
|
1498
|
+
quantity: { type: 'integer' },
|
|
1499
|
+
boards: { type: 'integer' },
|
|
1500
|
+
order_qty: { type: 'integer' },
|
|
1501
|
+
currency: { type: 'string' },
|
|
1502
|
+
candidates: { type: 'array', items: { type: 'object' } },
|
|
1503
|
+
hints: { type: 'array', items: { type: 'string' } },
|
|
1504
|
+
notes: { type: 'array', items: { type: 'string' } },
|
|
1505
|
+
relaxed: { type: 'boolean' },
|
|
1506
|
+
fetched_at: { type: 'string' },
|
|
1507
|
+
},
|
|
1508
|
+
},
|
|
1509
|
+
render: (_args, value) => [{ type: 'text', text: renderFindText(value) }],
|
|
1510
|
+
},
|
|
1511
|
+
presentCall: (args) => ({ card: 'generic', title: 'Find LCSC parts', kind: 'read', rawInput: args }),
|
|
1512
|
+
async execute(args, exec) {
|
|
1513
|
+
return toLossless(await findParts(service, args, { signal: exec?.signal }))
|
|
1514
|
+
},
|
|
1515
|
+
},
|
|
1516
|
+
{
|
|
1517
|
+
name: 'audit_lcsc_bom',
|
|
1518
|
+
description: AUDIT_DESCRIPTION,
|
|
1519
|
+
parameters: {
|
|
1520
|
+
type: 'object',
|
|
1521
|
+
additionalProperties: false,
|
|
1522
|
+
required: ['bom'],
|
|
1523
|
+
properties: {
|
|
1524
|
+
bom: {
|
|
1525
|
+
type: 'string',
|
|
1526
|
+
description: 'BOM as CSV/TSV text with LCSC codes and quantities (header optional), or a JSON array string like [{"lcsc":"C1581","qty":5}].',
|
|
1527
|
+
},
|
|
1528
|
+
assembly: {
|
|
1529
|
+
type: 'string',
|
|
1530
|
+
enum: ['hand', 'smt'],
|
|
1531
|
+
description: 'hand = no JLCPCB feeder fees (default); smt = include USD 3 per extended part number per order.',
|
|
1532
|
+
},
|
|
1533
|
+
boards: { type: 'integer', minimum: 1, maximum: 100000, description: 'Number of boards the BOM is for (default 1).' },
|
|
1534
|
+
include_cny: { type: 'boolean', description: 'Also try LCSC domestic CNY retail prices (best effort, capped; default false).' },
|
|
1535
|
+
suggest_alternatives: { type: 'boolean', description: 'For extended-library parts, suggest a basic-library substitute (default true; only in smt mode).' },
|
|
1536
|
+
},
|
|
1537
|
+
},
|
|
1538
|
+
output: {
|
|
1539
|
+
schema: {
|
|
1540
|
+
type: 'object',
|
|
1541
|
+
additionalProperties: false,
|
|
1542
|
+
required: ['assembly', 'summary', 'rows', 'notes'],
|
|
1543
|
+
properties: {
|
|
1544
|
+
assembly: { type: 'string' },
|
|
1545
|
+
boards: { type: 'integer' },
|
|
1546
|
+
currency: { type: 'string' },
|
|
1547
|
+
summary: { type: 'object' },
|
|
1548
|
+
rows: { type: 'array', items: { type: 'object' } },
|
|
1549
|
+
skipped: { type: 'array', items: { type: 'object' } },
|
|
1550
|
+
errors: { type: 'array', items: { type: 'string' } },
|
|
1551
|
+
hints: { type: 'array', items: { type: 'string' } },
|
|
1552
|
+
notes: { type: 'array', items: { type: 'string' } },
|
|
1553
|
+
fetched_at: { type: 'string' },
|
|
1554
|
+
},
|
|
1555
|
+
},
|
|
1556
|
+
render: (_args, value) => [{ type: 'text', text: renderAuditText(value) }],
|
|
1557
|
+
},
|
|
1558
|
+
presentCall: (args) => ({ card: 'generic', title: 'Audit LCSC BOM', kind: 'read', rawInput: args }),
|
|
1559
|
+
async execute(args, exec) {
|
|
1560
|
+
return toLossless(await auditBom(service, args, { signal: exec?.signal }))
|
|
1561
|
+
},
|
|
1562
|
+
},
|
|
1563
|
+
]
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/* -------------------------------------------------------------------------- */
|
|
1567
|
+
/* Plugin entry */
|
|
1568
|
+
/* -------------------------------------------------------------------------- */
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* DSH plugin entry: called by the Loader once the `tools` service is available.
|
|
1572
|
+
* @param {unknown} ctx
|
|
1573
|
+
* @param {unknown} [config] row `config` from cordis.patch.yml
|
|
1574
|
+
*/
|
|
1575
|
+
export function apply(ctx, config) {
|
|
1576
|
+
const warnings = []
|
|
1577
|
+
const settings = normalizeConfig(config, { onWarning: (message) => warnings.push(message) })
|
|
1578
|
+
const logger = safeLogger(ctx)
|
|
1579
|
+
for (const warning of warnings) logger?.warn?.(`[lcsc] ${warning}`)
|
|
1580
|
+
const service = createLcscService(settings)
|
|
1581
|
+
/** @type {{ effect?: (fn: () => (() => void)) => void, tools?: { register: (definition: unknown) => void } }} */
|
|
1582
|
+
const context = isRecord(ctx) ? ctx : {}
|
|
1583
|
+
context.effect?.(() => () => service.clearCache())
|
|
1584
|
+
for (const definition of createToolDefinitions(service)) {
|
|
1585
|
+
context.tools?.register(definition)
|
|
1586
|
+
}
|
|
1587
|
+
if (settings.verbose) logger?.info?.('[lcsc] tools registered: find_lcsc_parts, audit_lcsc_bom')
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** @param {unknown} ctx */
|
|
1591
|
+
function safeLogger(ctx) {
|
|
1592
|
+
try {
|
|
1593
|
+
if (!isRecord(ctx)) return undefined
|
|
1594
|
+
const logger = ctx.logger
|
|
1595
|
+
if (typeof logger !== 'function') return undefined
|
|
1596
|
+
const named = logger('lcsc')
|
|
1597
|
+
return isRecord(named) ? named : undefined
|
|
1598
|
+
} catch {
|
|
1599
|
+
return undefined
|
|
1600
|
+
}
|
|
1601
|
+
}
|