dsh-plugin-lcsc 0.3.2 → 0.3.4
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 -21
- package/README.md +181 -125
- package/cn.js +808 -707
- package/cordis.patch.yml +28 -23
- package/data.js +1816 -1816
- package/icon.svg +9 -9
- package/index.js +2207 -2140
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +52 -52
package/index.js
CHANGED
|
@@ -1,2140 +1,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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
/*
|
|
50
|
-
/*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (typeof value === '
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* @
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* @
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
*
|
|
143
|
-
* @param {number | undefined}
|
|
144
|
-
* @
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return new
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
*
|
|
172
|
-
* @
|
|
173
|
-
* @param {
|
|
174
|
-
* @param {
|
|
175
|
-
* @
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
/*
|
|
193
|
-
/*
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
['
|
|
199
|
-
['
|
|
200
|
-
['
|
|
201
|
-
['
|
|
202
|
-
['
|
|
203
|
-
['
|
|
204
|
-
['充电
|
|
205
|
-
['
|
|
206
|
-
['
|
|
207
|
-
['
|
|
208
|
-
['
|
|
209
|
-
['
|
|
210
|
-
['mems
|
|
211
|
-
['mems
|
|
212
|
-
['mems
|
|
213
|
-
['
|
|
214
|
-
['type-c
|
|
215
|
-
['type
|
|
216
|
-
['母座', ['receptacle']],
|
|
217
|
-
['
|
|
218
|
-
['
|
|
219
|
-
['
|
|
220
|
-
['
|
|
221
|
-
['
|
|
222
|
-
['
|
|
223
|
-
['
|
|
224
|
-
['
|
|
225
|
-
['
|
|
226
|
-
['
|
|
227
|
-
['
|
|
228
|
-
['
|
|
229
|
-
['
|
|
230
|
-
['
|
|
231
|
-
['
|
|
232
|
-
['
|
|
233
|
-
['
|
|
234
|
-
['
|
|
235
|
-
['
|
|
236
|
-
['
|
|
237
|
-
['
|
|
238
|
-
['
|
|
239
|
-
['
|
|
240
|
-
['
|
|
241
|
-
['
|
|
242
|
-
['
|
|
243
|
-
['
|
|
244
|
-
['
|
|
245
|
-
['
|
|
246
|
-
['
|
|
247
|
-
['
|
|
248
|
-
['
|
|
249
|
-
['
|
|
250
|
-
['
|
|
251
|
-
['
|
|
252
|
-
['
|
|
253
|
-
['
|
|
254
|
-
['
|
|
255
|
-
['
|
|
256
|
-
['
|
|
257
|
-
]
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* @
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (
|
|
290
|
-
if (
|
|
291
|
-
if (
|
|
292
|
-
if (
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (strong.length
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
'
|
|
312
|
-
'
|
|
313
|
-
'
|
|
314
|
-
'
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if (['
|
|
328
|
-
return '
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
*
|
|
344
|
-
* @
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
.replace(/\b(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
*
|
|
419
|
-
* @
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
'
|
|
457
|
-
'
|
|
458
|
-
'
|
|
459
|
-
'
|
|
460
|
-
'
|
|
461
|
-
'
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
.replace(
|
|
479
|
-
.replace(
|
|
480
|
-
.replace(
|
|
481
|
-
.
|
|
482
|
-
|
|
483
|
-
if (keyword === '') keyword =
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (requirement.
|
|
491
|
-
|
|
492
|
-
else if (requirement.kind === '
|
|
493
|
-
else if (requirement.kind === '
|
|
494
|
-
else requirement.comparator = '
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
/*
|
|
514
|
-
/*
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
asString(item.
|
|
531
|
-
asString(item.
|
|
532
|
-
asString(item.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
if (/
|
|
543
|
-
if (/
|
|
544
|
-
if (/
|
|
545
|
-
if (/
|
|
546
|
-
if (
|
|
547
|
-
if (/
|
|
548
|
-
if (/
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
*
|
|
555
|
-
* @
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
for (const match of description.matchAll(
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
add('size', Number(match[
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
add(Number(match[
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
if (comparator === '
|
|
670
|
-
if (comparator === '
|
|
671
|
-
return
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
*
|
|
684
|
-
* @param {
|
|
685
|
-
* @
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
if (
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
* @param {
|
|
741
|
-
* @
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
.
|
|
781
|
-
.
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
* @param {string | undefined}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
/*
|
|
804
|
-
/*
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
* @
|
|
809
|
-
* @property {
|
|
810
|
-
* @property {number}
|
|
811
|
-
* @property {number}
|
|
812
|
-
* @property {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
*
|
|
818
|
-
* @param {
|
|
819
|
-
* @param {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
const
|
|
825
|
-
const
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
const
|
|
832
|
-
const
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
*
|
|
848
|
-
* @param {
|
|
849
|
-
* @param {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
const
|
|
854
|
-
const
|
|
855
|
-
const
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
:
|
|
860
|
-
|
|
861
|
-
:
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
const
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
weights.
|
|
879
|
-
weights.
|
|
880
|
-
weights.
|
|
881
|
-
weights.
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
if (
|
|
887
|
-
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
return
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
/*
|
|
902
|
-
/*
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
if (candidate.
|
|
933
|
-
if (candidate.
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
*
|
|
940
|
-
* @param {
|
|
941
|
-
* @param {
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
*
|
|
961
|
-
* @param {
|
|
962
|
-
* @param {
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
const
|
|
969
|
-
const
|
|
970
|
-
const
|
|
971
|
-
const
|
|
972
|
-
const
|
|
973
|
-
const
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
.
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
const
|
|
981
|
-
const
|
|
982
|
-
const
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
const
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
queries.push({ keyword: need.searchKeyword })
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
//
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
.filter((item) => (
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
.
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
let
|
|
1053
|
-
let
|
|
1054
|
-
let
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
.
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
const
|
|
1148
|
-
const
|
|
1149
|
-
const
|
|
1150
|
-
const
|
|
1151
|
-
const
|
|
1152
|
-
const
|
|
1153
|
-
const
|
|
1154
|
-
const
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
if (
|
|
1230
|
-
notes.push('
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
if (
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
}
|
|
1383
|
-
return
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
.
|
|
1457
|
-
.
|
|
1458
|
-
.
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
const
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
if (
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
if (
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
})
|
|
1799
|
-
.
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
(
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
lines.
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
const
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
const
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
}
|
|
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
|
+
}
|