ecadlib 0.3.0__py3-none-any.whl
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.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/sourcing.py
ADDED
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
"""Second sources and cost: price, stock and alternates for every BOM line.
|
|
2
|
+
|
|
3
|
+
python -m ecadlib.sourcing examples/sample_bom.csv [--qty 100] [--alternates] [--write-plan out/altium365/plan.json] \
|
|
4
|
+
[--providers nexar,digikey,mouser,jlcpcb_search] [--currency USD] [--country US] [-o out/sourcing] [--offline]
|
|
5
|
+
|
|
6
|
+
Per fitted BOM line: need = BOM qty per board (Qty column, else designator count) x --qty boards. Every offer from every
|
|
7
|
+
distributor is costed at the order quantity it would actually take (MOQ, order multiple, price breaks; buying up to the
|
|
8
|
+
next break is used when that is cheaper in total). The chosen offer is the cheapest one whose stock covers the order;
|
|
9
|
+
the report also gives MOQ, order multiple, lead time, and flags:
|
|
10
|
+
no_stock no distributor has any stock low_stock stock < --low-stock-factor x need,
|
|
11
|
+
single_distributor only one distributor has stock or no single offer covers the need
|
|
12
|
+
lifecycle:<status> NRND / last-time-buy / obsolete not_found / no_price / no_mpn
|
|
13
|
+
no_answer no source answered for this line (not configured, blocked, quota)
|
|
14
|
+
With --alternates:
|
|
15
|
+
* generic passives (designators R, C, L): drop-in parts by parametric match against the part's own specs (from the
|
|
16
|
+
sources) and the BOM value/description: same value and package; tolerance no wider; voltage, power and current
|
|
17
|
+
ratings no lower; capacitor dielectric the same or a better class (C0G/NP0 > X8R > X7R > X7S/X7T > X6S > X5R > Y5V).
|
|
18
|
+
A candidate whose data cannot confirm every required parameter is listed as `unconfirmed`, never used.
|
|
19
|
+
Flag `single_source` when no confirmed drop-in (passives) or no candidate at all (others) was found.
|
|
20
|
+
* everything else (ICs, connectors, crystals, discretes): Nexar similarParts, DigiKey substitutions and Mouser's
|
|
21
|
+
suggested replacement, always marked `needs engineer review`. They are never substituted or written as alternates.
|
|
22
|
+
* NRND / last-time-buy / obsolete candidates are dropped (unknown lifecycle is kept and marked).
|
|
23
|
+
--write-plan PLAN (an altium365 plan.json, changed in place) fills empty `Supplier 1/2` + `Supplier Part Number 1/2`
|
|
24
|
+
parameters (an existing Supplier, e.g. the BOM's LCSC number, is never replaced) and, for passives, confirmed drop-ins as
|
|
25
|
+
`Alternate Manufacturer N` / `Alternate Manufacturer Part Number N`. Filled names leave `cleared`, so `altium365 push`
|
|
26
|
+
sends them instead of deleting them. Prices and IC review candidates go to `item["sourcing"]` only (not parameters, so
|
|
27
|
+
a price change does not cause a new revision on every push).
|
|
28
|
+
Outputs: <out>.md and <out>.csv (one row per BOM line, with Altium-style Supplier columns).
|
|
29
|
+
Exit codes: 0 every fitted line can be bought from stock, 1 some line cannot (no stock / not found / no price),
|
|
30
|
+
2 bad input, 3 no source answered (secrets missing, quota, or network blocked).
|
|
31
|
+
|
|
32
|
+
Auth reuses the lifecycle clients (lifecycle.py): NEXAR_CLIENT_ID/NEXAR_CLIENT_SECRET (or NEXAR_SUPPLY_TOKEN; pricing
|
|
33
|
+
needs a Nexar plan with pricing), DIGIKEY_CLIENT_ID/DIGIKEY_CLIENT_SECRET, MOUSER_API_KEY. JLCPCB needs none. Responses
|
|
34
|
+
are cached by httpcache (query-string secrets such as Mouser's apiKey are redacted from cache keys and files; token
|
|
35
|
+
requests are never cached). Response shapes: each provider's `shapes` says what was checked against public docs or the
|
|
36
|
+
Nexar schema and what is assumed. None of these hosts was reachable from the sandbox this was written in."""
|
|
37
|
+
import argparse, csv, json, math, os, re, sys
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from datetime import datetime, timezone
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from urllib.parse import quote
|
|
42
|
+
|
|
43
|
+
from . import lifecycle as L, providers as P
|
|
44
|
+
from . import sheetsafe as SS
|
|
45
|
+
from .bom_triage import count_refs, part_numbers, placeholder_mpn, read_bom
|
|
46
|
+
from .httpcache import CachedHTTP
|
|
47
|
+
from .kicad_index import norm_mpn, package_of, parse_value
|
|
48
|
+
|
|
49
|
+
RISKY = {"nrnd", "ltb", "obsolete"}
|
|
50
|
+
SUPPLIER = {"digikey": "Digi-Key", "mouser": "Mouser", "lcsc": "LCSC", "farnell": "Farnell", "newark": "Newark",
|
|
51
|
+
"arrow": "Arrow", "tme": "TME", "rs": "RS"}
|
|
52
|
+
DIELECTRIC = {"C0G": 0, "NP0": 0, "X8R": 1, "X7R": 2, "X7S": 3, "X7T": 3, "X6S": 4, "X5R": 5, "Y5V": 6, "Z5U": 6}
|
|
53
|
+
KIND_WORD = {"R": "resistor", "C": "capacitor", "L": "inductor"}
|
|
54
|
+
KIND_UNIT = {"R": "R", "C": "F", "L": "H"}
|
|
55
|
+
REQUIRED = {"R": ("tolerance", "power"), "C": ("voltage", "dielectric", "tolerance"), "L": ("current", "tolerance")}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _now():
|
|
59
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _int(v):
|
|
63
|
+
return L._int(v)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dist_key(name):
|
|
67
|
+
"""'Digi-Key Electronics' -> digikey, 'Mouser Electronics' -> mouser, 'LCSC'/'JLCPCB' -> lcsc."""
|
|
68
|
+
k = re.sub(r"[^a-z]", "", str(name or "").lower())
|
|
69
|
+
for short in ("digikey", "mouser", "farnell", "newark", "arrow"):
|
|
70
|
+
if k.startswith(short):
|
|
71
|
+
return short
|
|
72
|
+
if k.startswith(("lcsc", "jlc")):
|
|
73
|
+
return "lcsc"
|
|
74
|
+
if k.startswith("rscomponents") or k == "rs" or k.startswith("rsonline"):
|
|
75
|
+
return "rs"
|
|
76
|
+
return k
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------ numbers and specs
|
|
80
|
+
_SI = {"p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6, "μ": 1e-6, "m": 1e-3, "": 1.0, "k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9, "R": 1.0}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def si(s):
|
|
84
|
+
"""'10 kOhms' / '100nF' / '0.1 µF' / '4k7' / '1/10W' / '0.1W, 1/10W' / '16 VDC' -> float (first number), else None."""
|
|
85
|
+
s = str(s or "").strip()
|
|
86
|
+
m = re.match(r"(\d+)([pnuµμmkKMGR])(\d+)\b", s) # 4k7, 4R7, 2n2
|
|
87
|
+
if m:
|
|
88
|
+
return float(f"{m.group(1)}.{m.group(3)}") * _SI[m.group(2)]
|
|
89
|
+
m = re.search(r"(\d+(?:\.\d+)?)(?:\s*/\s*(\d+(?:\.\d+)?))?\s*([pnuµμmkKMG]?)", s)
|
|
90
|
+
if not m:
|
|
91
|
+
return None
|
|
92
|
+
v = float(m.group(1)) / (float(m.group(2)) if m.group(2) else 1.0)
|
|
93
|
+
return v * _SI[m.group(3)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def pct(s):
|
|
97
|
+
m = re.search(r"(\d+(?:\.\d+)?)\s*%", str(s or ""))
|
|
98
|
+
return float(m.group(1)) if m else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def dielectric(s):
|
|
102
|
+
m = re.search(r"\b(C0G|NP0|COG|NPO|X8R|X7R|X7S|X7T|X6S|X5R|Y5V|Z5U)\b", str(s or "").upper())
|
|
103
|
+
return {"COG": "C0G", "NPO": "NP0"}.get(m.group(1), m.group(1)) if m else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def fmt_si(v, unit=""):
|
|
107
|
+
for p, f in (("G", 1e9), ("M", 1e6), ("k", 1e3), ("", 1.0), ("m", 1e-3), ("u", 1e-6), ("n", 1e-9), ("p", 1e-12)):
|
|
108
|
+
if abs(v) >= f * 0.999:
|
|
109
|
+
n = v / f
|
|
110
|
+
return f"{n:.3g}{p}{'' if unit == 'R' else unit}"
|
|
111
|
+
return f"{v:g}{unit}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
SPEC_RX = [ # (canonical, regex on the attribute name/shortname, parser); first match per attribute wins
|
|
115
|
+
("value:R", r"^resistance\b", si), ("value:F", r"^capacitance\b", si), ("value:H", r"^inductance\b", si),
|
|
116
|
+
("tolerance", r"tolerance", pct),
|
|
117
|
+
("voltage", r"voltage.*(rat|dc)|rated voltage|voltagerating|^voltage$", si),
|
|
118
|
+
("dielectric", r"dielectric|temperature ?coefficient|tempco", dielectric),
|
|
119
|
+
("power", r"^power|power ?rating", si),
|
|
120
|
+
("current", r"current.*rat|rated current|currentrating|^current$", si),
|
|
121
|
+
("package", r"case|package|^size$", lambda v: package_of(v)),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def specs_from_pairs(pairs):
|
|
126
|
+
"""[(attribute name, value text)] from any source -> {value, unit, tolerance, voltage, dielectric, power, current, package}."""
|
|
127
|
+
out = {}
|
|
128
|
+
for name, val in pairs:
|
|
129
|
+
n = str(name or "").strip().lower().replace("_", " ")
|
|
130
|
+
for key, rx, parse in SPEC_RX:
|
|
131
|
+
if not re.search(rx, n):
|
|
132
|
+
continue
|
|
133
|
+
if key == "package" and re.search(r"\bmm\b|metric", n): # Mouser 'Case Code - mm: 0402' is 01005
|
|
134
|
+
break
|
|
135
|
+
v = parse(val)
|
|
136
|
+
if v is not None:
|
|
137
|
+
if key.startswith("value:"):
|
|
138
|
+
out.setdefault("value", v)
|
|
139
|
+
out.setdefault("unit", key[6:])
|
|
140
|
+
else:
|
|
141
|
+
out.setdefault(key, v)
|
|
142
|
+
break
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def specs_from_text(text, kind=None):
|
|
147
|
+
"""Specs from a description such as '100nF ±10% 16V X7R 0402' or 'RES SMD 10K OHM 1% 1/10W 0603'."""
|
|
148
|
+
t, out = str(text or ""), {}
|
|
149
|
+
if kind == "C":
|
|
150
|
+
m = re.search(r"(?<![\w.])(\d+(?:\.\d+)?)\s*([pnuµμmPNU]?)F\b", t) # DigiKey writes 0.1UF, 1000PF
|
|
151
|
+
elif kind == "L":
|
|
152
|
+
m = re.search(r"(?<![\w.])(\d+(?:\.\d+)?)\s*([nuµμmNU]?)H\b", t)
|
|
153
|
+
elif kind == "R":
|
|
154
|
+
m = (re.search(r"(?<![\w.])(\d+(?:\.\d+)?)\s*([kKM]?)\s*(?:Ω|(?i:ohms?)\b)", t)
|
|
155
|
+
or re.search(r"(?<![\w.])(\d+)([kKMR])(\d+)\b", t))
|
|
156
|
+
else:
|
|
157
|
+
m = None
|
|
158
|
+
if m:
|
|
159
|
+
out["value"] = (float(f"{m.group(1)}.{m.group(3)}") * _SI[m.group(2)] if m.re.groups == 3
|
|
160
|
+
else float(m.group(1)) * _SI[m.group(2) if kind == "R" else m.group(2).lower()])
|
|
161
|
+
out["unit"] = KIND_UNIT[kind]
|
|
162
|
+
if (v := pct(t)) is not None:
|
|
163
|
+
out["tolerance"] = v
|
|
164
|
+
if m := re.search(r"(?<![\w./])(\d+(?:\.\d+)?)\s*(k?)V(?:DC)?\b", t):
|
|
165
|
+
out["voltage"] = float(m.group(1)) * (1e3 if m.group(2) else 1.0)
|
|
166
|
+
if m := re.search(r"(?<![\w./])(\d+/\d+|\d+(?:\.\d+)?)\s*(m?)W\b", t):
|
|
167
|
+
out["power"] = si(m.group(1)) * (1e-3 if m.group(2) else 1.0)
|
|
168
|
+
if kind == "L" and (m := re.search(r"(?<![\w./])(\d+(?:\.\d+)?)\s*(m?)A\b", t)):
|
|
169
|
+
out["current"] = float(m.group(1)) * (1e-3 if m.group(2) else 1.0)
|
|
170
|
+
if d := dielectric(t):
|
|
171
|
+
out["dielectric"] = d
|
|
172
|
+
if p := package_of(t):
|
|
173
|
+
out["package"] = p
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _close(a, b):
|
|
178
|
+
return abs(a - b) <= 1e-3 * max(abs(a), abs(b), 1e-15)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def drop_in(req, cand, kind):
|
|
182
|
+
"""-> ('drop_in' | 'unconfirmed' | 'mismatch', reasons). Value and package must match; every other parameter
|
|
183
|
+
the original part states must be met or bettered; a parameter the candidate does not state is 'unconfirmed'."""
|
|
184
|
+
bad, missing = [], []
|
|
185
|
+
if cand.get("value") is None:
|
|
186
|
+
missing.append("value")
|
|
187
|
+
elif not _close(cand["value"], req["value"]) or cand.get("unit", KIND_UNIT[kind]) != KIND_UNIT[kind]:
|
|
188
|
+
bad.append(f"value {fmt_si(cand['value'], cand.get('unit', ''))}")
|
|
189
|
+
if not cand.get("package"):
|
|
190
|
+
missing.append("package")
|
|
191
|
+
elif cand["package"] != req["package"]:
|
|
192
|
+
bad.append(f"package {cand['package']}")
|
|
193
|
+
for k in REQUIRED[kind]:
|
|
194
|
+
if req.get(k) is None:
|
|
195
|
+
continue
|
|
196
|
+
c = cand.get(k)
|
|
197
|
+
if c is None:
|
|
198
|
+
missing.append(k)
|
|
199
|
+
elif k == "tolerance" and c > req[k] + 1e-9:
|
|
200
|
+
bad.append(f"tolerance {c:g}% > {req[k]:g}%")
|
|
201
|
+
elif k in ("voltage", "power", "current") and c < req[k] * (1 - 1e-6):
|
|
202
|
+
bad.append(f"{k} {fmt_si(c)} < {fmt_si(req[k])}")
|
|
203
|
+
elif k == "dielectric" and DIELECTRIC.get(c, 99) > DIELECTRIC.get(req[k], -1):
|
|
204
|
+
bad.append(f"dielectric {c} (need {req[k]} or better)")
|
|
205
|
+
if bad:
|
|
206
|
+
return "mismatch", bad
|
|
207
|
+
if missing:
|
|
208
|
+
return "unconfirmed", [f"not stated: {', '.join(missing)}"]
|
|
209
|
+
return "drop_in", []
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------------ offers and buying
|
|
213
|
+
@dataclass
|
|
214
|
+
class Offer:
|
|
215
|
+
source: str # provider that reported it
|
|
216
|
+
distributor: str
|
|
217
|
+
sku: str = ""
|
|
218
|
+
stock: int | None = None
|
|
219
|
+
moq: int = 1
|
|
220
|
+
mult: int = 1
|
|
221
|
+
breaks: list = field(default_factory=list) # [(qty, unit price)] ascending
|
|
222
|
+
currency: str = ""
|
|
223
|
+
lead_days: int | None = None
|
|
224
|
+
packaging: str = ""
|
|
225
|
+
url: str = ""
|
|
226
|
+
authorized: bool | None = None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class PartInfo:
|
|
231
|
+
provider: str
|
|
232
|
+
found: bool | None = None
|
|
233
|
+
mpn: str = ""
|
|
234
|
+
manufacturer: str = ""
|
|
235
|
+
description: str = ""
|
|
236
|
+
lifecycle: str = "unknown"
|
|
237
|
+
lifecycle_raw: str = ""
|
|
238
|
+
specs: dict = field(default_factory=dict)
|
|
239
|
+
offers: list = field(default_factory=list)
|
|
240
|
+
similar: list = field(default_factory=list) # PartInfo candidates from the source's own alternates
|
|
241
|
+
lead_days: int | None = None
|
|
242
|
+
url: str = ""
|
|
243
|
+
note: str = ""
|
|
244
|
+
error: str = ""
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _ceil_to(q, mult):
|
|
248
|
+
mult = max(int(mult or 1), 1)
|
|
249
|
+
return int(math.ceil(q / mult) * mult)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def buy(offer, need):
|
|
253
|
+
"""Cheapest way to get >= need units from one offer: order quantity respects MOQ, order multiple and the first
|
|
254
|
+
price break; ordering up to a later break is chosen when the total is lower. None when the offer has no prices."""
|
|
255
|
+
br = sorted((int(q), float(p)) for q, p in offer.breaks if q and p is not None and p >= 0)
|
|
256
|
+
if not br or need <= 0:
|
|
257
|
+
return None
|
|
258
|
+
q0 = _ceil_to(max(need, offer.moq or 1, br[0][0]), offer.mult)
|
|
259
|
+
best = None
|
|
260
|
+
for q in sorted({q0} | {_ceil_to(b, offer.mult) for b, _ in br if b > q0}):
|
|
261
|
+
unit = [p for b, p in br if b <= q][-1]
|
|
262
|
+
total = round(q * unit, 6)
|
|
263
|
+
if best is None or total < best["ext"] - 1e-9:
|
|
264
|
+
best = {"order_qty": q, "unit": unit, "ext": total}
|
|
265
|
+
best["covers"] = offer.stock is not None and offer.stock >= best["order_qty"]
|
|
266
|
+
return best
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def rank_offers(offers, need, currency):
|
|
270
|
+
"""[(offer, buy)] for priced offers in `currency`: full-stock offers by total cost, then partial stock (most
|
|
271
|
+
stock first), then no/unknown stock (shortest lead time first)."""
|
|
272
|
+
rows = [(o, b) for o in offers if (o.currency or currency).upper() == currency.upper() and (b := buy(o, need))]
|
|
273
|
+
tier = lambda ob: 0 if ob[1]["covers"] else 1 if (ob[0].stock or 0) > 0 else 2
|
|
274
|
+
return sorted(rows, key=lambda ob: (tier(ob), ob[1]["ext"] if tier(ob) == 0 else 0,
|
|
275
|
+
-(ob[0].stock or 0) if tier(ob) == 1 else 0,
|
|
276
|
+
ob[0].lead_days if ob[0].lead_days is not None else 10 ** 6, ob[1]["ext"]))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ------------------------------------------------------------------ providers (auth from lifecycle.py)
|
|
280
|
+
class NexarSourcing(L.NexarSupply):
|
|
281
|
+
shapes = ("CHECKED against tests/nexar/schema.graphql (validated in tests): supSearchMpn/supSearch(q, country, "
|
|
282
|
+
"currency, limit, inStockOnly), SupPart.sellers{company{name} isAuthorized offers{sku inventoryLevel moq "
|
|
283
|
+
"orderMultiple factoryLeadDays packaging clickUrl prices{quantity price currency convertedPrice "
|
|
284
|
+
"convertedCurrency}}}, estimatedFactoryLeadDays, similarParts, specs{attribute{name shortname} displayValue}. "
|
|
285
|
+
"ASSUMED: attribute names for passives (Resistance, Capacitance, Tolerance, Voltage Rating (DC), Dielectric, "
|
|
286
|
+
"Power Rating, Case/Package); similarParts may be empty without a plan that includes it.")
|
|
287
|
+
FIELDS = """fragment F on SupPart {
|
|
288
|
+
mpn manufacturer { name } octopartUrl shortDescription estimatedFactoryLeadDays
|
|
289
|
+
specs { attribute { name shortname } displayValue value }
|
|
290
|
+
sellers { company { name } isAuthorized
|
|
291
|
+
offers { sku inventoryLevel moq orderMultiple factoryLeadDays packaging clickUrl
|
|
292
|
+
prices { quantity price currency convertedPrice convertedCurrency } } }
|
|
293
|
+
}"""
|
|
294
|
+
Q_PART = FIELDS + """
|
|
295
|
+
query ($q: String!, $limit: Int, $country: String!, $currency: String!) {
|
|
296
|
+
supSearchMpn(q: $q, limit: $limit, country: $country, currency: $currency) { hits results { part { ...F } } }
|
|
297
|
+
}"""
|
|
298
|
+
Q_PART_ALT = FIELDS + """
|
|
299
|
+
query ($q: String!, $limit: Int, $country: String!, $currency: String!) {
|
|
300
|
+
supSearchMpn(q: $q, limit: $limit, country: $country, currency: $currency) {
|
|
301
|
+
hits results { part { ...F similarParts { ...F } } } }
|
|
302
|
+
}"""
|
|
303
|
+
Q_SEARCH = FIELDS + """
|
|
304
|
+
query ($q: String!, $limit: Int, $country: String!, $currency: String!) {
|
|
305
|
+
supSearch(q: $q, limit: $limit, country: $country, currency: $currency, inStockOnly: true) { hits results { part { ...F } } }
|
|
306
|
+
}"""
|
|
307
|
+
country, currency = "US", "USD"
|
|
308
|
+
|
|
309
|
+
def _need(self):
|
|
310
|
+
if not self.configured():
|
|
311
|
+
raise P.NotConfigured(f"{self.name}: set NEXAR_SUPPLY_TOKEN or {', '.join(self.env)} ({self.auth})")
|
|
312
|
+
|
|
313
|
+
def _gql(self, query, variables):
|
|
314
|
+
tok = self.token()
|
|
315
|
+
if not tok:
|
|
316
|
+
return None, self._token_err
|
|
317
|
+
r = self.http.post(self.API_URL, json.dumps({"query": query, "variables": variables}),
|
|
318
|
+
headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"})
|
|
319
|
+
if not r.ok:
|
|
320
|
+
return None, L._http_error(L.LifecycleResult(self.name), r).error
|
|
321
|
+
try:
|
|
322
|
+
d = r.json()
|
|
323
|
+
except ValueError as e:
|
|
324
|
+
return None, f"unparseable response: {e}"
|
|
325
|
+
if d.get("errors"):
|
|
326
|
+
return None, "; ".join(str(e.get("message", "?")) for e in d["errors"] if isinstance(e, dict))
|
|
327
|
+
return d.get("data") or {}, ""
|
|
328
|
+
|
|
329
|
+
def _vars(self, q, limit):
|
|
330
|
+
return {"q": q, "limit": limit, "country": self.country, "currency": self.currency}
|
|
331
|
+
|
|
332
|
+
def part(self, p, alternates=False):
|
|
333
|
+
self._need()
|
|
334
|
+
res = PartInfo(self.name, mpn=p["mpn"])
|
|
335
|
+
if not p["mpn"]:
|
|
336
|
+
res.error = "no MPN"
|
|
337
|
+
return res
|
|
338
|
+
data, err = self._gql(self.Q_PART_ALT if alternates else self.Q_PART, self._vars(p["mpn"], 5))
|
|
339
|
+
if err:
|
|
340
|
+
res.error = err
|
|
341
|
+
return res
|
|
342
|
+
try:
|
|
343
|
+
return parse_nexar_part(data, p, res, self.currency)
|
|
344
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
345
|
+
res.error = f"unparseable response: {e}"
|
|
346
|
+
return res
|
|
347
|
+
|
|
348
|
+
def search(self, q, limit=20):
|
|
349
|
+
self._need()
|
|
350
|
+
data, err = self._gql(self.Q_SEARCH, self._vars(q, limit))
|
|
351
|
+
if err:
|
|
352
|
+
return [], err
|
|
353
|
+
try:
|
|
354
|
+
return [nexar_info(x["part"], self.name, self.currency) for x in
|
|
355
|
+
((data.get("supSearch") or {}).get("results") or []) if isinstance(x, dict) and isinstance(x.get("part"), dict)], ""
|
|
356
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
357
|
+
return [], f"unparseable response: {e}"
|
|
358
|
+
|
|
359
|
+
def substitutes(self, p):
|
|
360
|
+
return [], ""
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def nexar_info(part, provider, currency):
|
|
364
|
+
info = PartInfo(provider, True, part.get("mpn") or "", (part.get("manufacturer") or {}).get("name") or "",
|
|
365
|
+
part.get("shortDescription") or "", url=part.get("octopartUrl") or "",
|
|
366
|
+
lead_days=_int(part.get("estimatedFactoryLeadDays")))
|
|
367
|
+
pairs = []
|
|
368
|
+
for s in part.get("specs") or []:
|
|
369
|
+
a = s.get("attribute") or {}
|
|
370
|
+
if a.get("shortname") == "lifecyclestatus":
|
|
371
|
+
info.lifecycle_raw = s.get("displayValue") or s.get("value") or ""
|
|
372
|
+
info.lifecycle = L.normalise(info.lifecycle_raw)
|
|
373
|
+
continue
|
|
374
|
+
val = s.get("displayValue") or s.get("value")
|
|
375
|
+
pairs += [(a.get("name"), val), (a.get("shortname"), val)]
|
|
376
|
+
info.specs = specs_from_pairs(pairs)
|
|
377
|
+
for seller in part.get("sellers") or []:
|
|
378
|
+
name = (seller.get("company") or {}).get("name") or "?"
|
|
379
|
+
for o in seller.get("offers") or []:
|
|
380
|
+
byq = {}
|
|
381
|
+
for pp in o.get("prices") or []:
|
|
382
|
+
q = _int(pp.get("quantity"))
|
|
383
|
+
if q is None:
|
|
384
|
+
continue
|
|
385
|
+
native = str(pp.get("currency") or "").upper() == currency.upper()
|
|
386
|
+
price = pp.get("price") if native else pp.get("convertedPrice")
|
|
387
|
+
if price is not None and (q not in byq or native):
|
|
388
|
+
byq[q] = float(price)
|
|
389
|
+
info.offers.append(Offer(provider, name, o.get("sku") or "", _int(o.get("inventoryLevel")),
|
|
390
|
+
_int(o.get("moq")) or 1, _int(o.get("orderMultiple")) or 1, sorted(byq.items()),
|
|
391
|
+
currency.upper(), _int(o.get("factoryLeadDays")) or info.lead_days,
|
|
392
|
+
o.get("packaging") or "", o.get("clickUrl") or "", seller.get("isAuthorized")))
|
|
393
|
+
info.similar = [nexar_info(s, provider, currency) for s in part.get("similarParts") or [] if isinstance(s, dict)]
|
|
394
|
+
return info
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def parse_nexar_part(data, p, res, currency="USD"):
|
|
398
|
+
results = ((data or {}).get("supSearchMpn") or {}).get("results") or []
|
|
399
|
+
best = L._pick([x.get("part") for x in results if isinstance(x, dict)], p,
|
|
400
|
+
lambda x: x.get("mpn"), lambda x: (x.get("manufacturer") or {}).get("name"))
|
|
401
|
+
if not best:
|
|
402
|
+
res.found = False
|
|
403
|
+
return res
|
|
404
|
+
return nexar_info(best, res.provider, currency)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class DigiKeySourcing(L.DigiKeyLifecycle):
|
|
408
|
+
shapes = ("CHECKED (DigiKey v4 OpenAPI field names, also seen in third-party clients): GET /products/v4/search/{pn}/"
|
|
409
|
+
"productdetails -> Product{ManufacturerProductNumber, Manufacturer{Name}, Description{ProductDescription}, "
|
|
410
|
+
"ProductUrl, QuantityAvailable, ProductStatus{Status}, Parameters[{ParameterText, ValueText}], "
|
|
411
|
+
"ProductVariations[{DigiKeyProductNumber, PackageType{Name}, StandardPricing[{BreakQuantity, UnitPrice}], "
|
|
412
|
+
"MinimumOrderQuantity, QuantityAvailableforPackageType}]}; endpoints /search/keyword and /{pn}/substitutions "
|
|
413
|
+
"exist. ASSUMED: ManufacturerLeadWeeks (weeks), SearchLocaleUsed{Currency}, keyword body {Keywords, Limit, "
|
|
414
|
+
"Offset} -> {Products: [...]}, substitutions -> {ProductSubstitutes: [{ManufacturerProductNumber, "
|
|
415
|
+
"Manufacturer{Name}, SubstituteType, QuantityAvailable}]}. Digi-Reel variations are skipped (reeling fee).")
|
|
416
|
+
KEYWORD_URL = "https://api.digikey.com/products/v4/search/keyword"
|
|
417
|
+
SUBS_URL = "https://api.digikey.com/products/v4/search/{}/substitutions"
|
|
418
|
+
country, currency = "US", "USD"
|
|
419
|
+
|
|
420
|
+
def _headers(self):
|
|
421
|
+
return {"X-DIGIKEY-Client-Id": os.environ["DIGIKEY_CLIENT_ID"], "Authorization": f"Bearer {self._token}",
|
|
422
|
+
"Accept": "application/json", "Content-Type": "application/json",
|
|
423
|
+
"X-DIGIKEY-Locale-Site": self.country, "X-DIGIKEY-Locale-Currency": self.currency}
|
|
424
|
+
|
|
425
|
+
def _need(self):
|
|
426
|
+
if not self.configured():
|
|
427
|
+
raise P.NotConfigured(f"{self.name}: set {', '.join(self.env)} ({self.auth})")
|
|
428
|
+
return self.token()
|
|
429
|
+
|
|
430
|
+
def part(self, p, alternates=False):
|
|
431
|
+
res = PartInfo(self.name, mpn=p["mpn"])
|
|
432
|
+
if not self._need():
|
|
433
|
+
res.error = self._token_err
|
|
434
|
+
return res
|
|
435
|
+
if not p["mpn"]:
|
|
436
|
+
res.error = "no MPN"
|
|
437
|
+
return res
|
|
438
|
+
r = self.http.get(self.URL.format(quote(p["mpn"], safe="")), headers=self._headers())
|
|
439
|
+
if r.status == 404:
|
|
440
|
+
res.found = False
|
|
441
|
+
return res
|
|
442
|
+
if not r.ok:
|
|
443
|
+
res.error = L._http_error(L.LifecycleResult(self.name), r).error
|
|
444
|
+
return res
|
|
445
|
+
try:
|
|
446
|
+
d = r.json()
|
|
447
|
+
prod = d.get("Product") if isinstance(d.get("Product"), dict) else d
|
|
448
|
+
if norm_mpn(prod.get("ManufacturerProductNumber")) != norm_mpn(p["mpn"]):
|
|
449
|
+
res.found, res.note = False, f"best match {prod.get('ManufacturerProductNumber')!r} is not this MPN"
|
|
450
|
+
return res
|
|
451
|
+
return digikey_info(prod, self.name, (d.get("SearchLocaleUsed") or {}).get("Currency") or self.currency)
|
|
452
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
453
|
+
res.error = f"unparseable response: {e}"
|
|
454
|
+
return res
|
|
455
|
+
|
|
456
|
+
def search(self, q, limit=20):
|
|
457
|
+
if not self._need():
|
|
458
|
+
return [], self._token_err
|
|
459
|
+
r = self.http.post(self.KEYWORD_URL, json.dumps({"Keywords": q, "Limit": limit, "Offset": 0}), headers=self._headers())
|
|
460
|
+
if not r.ok:
|
|
461
|
+
return [], L._http_error(L.LifecycleResult(self.name), r).error
|
|
462
|
+
try:
|
|
463
|
+
d = r.json()
|
|
464
|
+
cur = (d.get("SearchLocaleUsed") or {}).get("Currency") or self.currency
|
|
465
|
+
return [digikey_info(x, self.name, cur) for x in d.get("Products") or [] if isinstance(x, dict)], ""
|
|
466
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
467
|
+
return [], f"unparseable response: {e}"
|
|
468
|
+
|
|
469
|
+
def substitutes(self, p):
|
|
470
|
+
if not self._need():
|
|
471
|
+
return [], self._token_err
|
|
472
|
+
r = self.http.get(self.SUBS_URL.format(quote(p["mpn"], safe="")), headers=self._headers())
|
|
473
|
+
if r.status == 404:
|
|
474
|
+
return [], ""
|
|
475
|
+
if not r.ok:
|
|
476
|
+
return [], L._http_error(L.LifecycleResult(self.name), r).error
|
|
477
|
+
try:
|
|
478
|
+
out = []
|
|
479
|
+
for s in r.json().get("ProductSubstitutes") or []:
|
|
480
|
+
if not isinstance(s, dict):
|
|
481
|
+
continue
|
|
482
|
+
m = s.get("Manufacturer")
|
|
483
|
+
out.append(PartInfo(self.name, True, s.get("ManufacturerProductNumber") or "",
|
|
484
|
+
(m.get("Name") if isinstance(m, dict) else m) or "", s.get("Description") or "",
|
|
485
|
+
url=s.get("ProductUrl") or "", note=f"DigiKey substitute: {s.get('SubstituteType') or '?'}"))
|
|
486
|
+
return out, ""
|
|
487
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
488
|
+
return [], f"unparseable response: {e}"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def digikey_info(prod, provider, currency):
|
|
492
|
+
desc = prod.get("Description")
|
|
493
|
+
desc = (desc.get("ProductDescription") or desc.get("DetailedDescription") or "") if isinstance(desc, dict) else str(desc or "")
|
|
494
|
+
m = prod.get("Manufacturer")
|
|
495
|
+
words = [(prod.get("ProductStatus") or {}).get("Status") or ""] + (["End of Life"] if prod.get("EndOfLife") else []) \
|
|
496
|
+
+ (["Discontinued at Digi-Key"] if prod.get("Discontinued") else [])
|
|
497
|
+
known = [s for s in map(L.normalise, [w for w in words if w]) if s != "unknown"]
|
|
498
|
+
lead = _int(prod.get("ManufacturerLeadWeeks"))
|
|
499
|
+
info = PartInfo(provider, True, prod.get("ManufacturerProductNumber") or "", (m.get("Name") if isinstance(m, dict) else m) or "",
|
|
500
|
+
desc, max(known, key=L.SEVERITY.get) if known else "unknown", "; ".join(w for w in words if w),
|
|
501
|
+
url=prod.get("ProductUrl") or "", lead_days=lead * 7 if lead is not None else None)
|
|
502
|
+
info.specs = specs_from_pairs([(x.get("ParameterText"), x.get("ValueText")) for x in prod.get("Parameters") or []
|
|
503
|
+
if isinstance(x, dict)])
|
|
504
|
+
for v in prod.get("ProductVariations") or []:
|
|
505
|
+
pkg = (v.get("PackageType") or {}).get("Name") or ""
|
|
506
|
+
if "digi-reel" in pkg.lower():
|
|
507
|
+
continue
|
|
508
|
+
info.offers.append(Offer(provider, "Digi-Key", v.get("DigiKeyProductNumber") or "",
|
|
509
|
+
_int(v.get("QuantityAvailableforPackageType")) if v.get("QuantityAvailableforPackageType") is not None
|
|
510
|
+
else _int(prod.get("QuantityAvailable")),
|
|
511
|
+
_int(v.get("MinimumOrderQuantity")) or 1, 1,
|
|
512
|
+
sorted((_int(b.get("BreakQuantity")), float(b["UnitPrice"])) for b in v.get("StandardPricing") or []
|
|
513
|
+
if isinstance(b, dict) and b.get("UnitPrice") is not None and _int(b.get("BreakQuantity"))),
|
|
514
|
+
str(currency).upper(), info.lead_days, pkg, info.url, True))
|
|
515
|
+
return info
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class MouserSourcing(L.MouserLifecycle):
|
|
519
|
+
shapes = ("CHECKED (Mouser swagger + third-party clients): POST /api/v1/search/partnumber and /search/keyword "
|
|
520
|
+
"(?apiKey=), body {SearchByKeywordRequest: {keyword, records, startingRecord, searchOptions}}; Parts[] "
|
|
521
|
+
"{ManufacturerPartNumber, Manufacturer, MouserPartNumber, Description, AvailabilityInStock, Min, Mult, "
|
|
522
|
+
"LeadTime ('84 Days'), PriceBreaks[{Quantity, Price ('$0.10', a string), Currency}], ProductAttributes"
|
|
523
|
+
"[{AttributeName, AttributeValue}], LifecycleStatus}. ASSUMED: SuggestedReplacement (an MPN string); "
|
|
524
|
+
"ProductAttributes rarely carry electrical specs, so the Description is parsed too.")
|
|
525
|
+
KEYWORD_URL = "https://api.mouser.com/api/v1/search/keyword?apiKey={}"
|
|
526
|
+
|
|
527
|
+
def _post(self, url, body):
|
|
528
|
+
if not self.configured():
|
|
529
|
+
raise P.NotConfigured(f"{self.name}: set {', '.join(self.env)} ({self.auth})")
|
|
530
|
+
# the key is a query parameter: httpcache redacts apiKey= from the cache key and the stored URL
|
|
531
|
+
return self.http.post(url.format(quote(os.environ["MOUSER_API_KEY"], safe="")), json.dumps(body),
|
|
532
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"})
|
|
533
|
+
|
|
534
|
+
def part(self, p, alternates=False):
|
|
535
|
+
res = PartInfo(self.name, mpn=p["mpn"])
|
|
536
|
+
r = self._post(self.URL, {"SearchByPartRequest": {"mouserPartNumber": p["mpn"], "partSearchOptions": "Exact"}}) \
|
|
537
|
+
if p["mpn"] else None
|
|
538
|
+
if r is None:
|
|
539
|
+
res.error = "no MPN"
|
|
540
|
+
return res
|
|
541
|
+
if not r.ok:
|
|
542
|
+
res.error = L._http_error(L.LifecycleResult(self.name), r).error
|
|
543
|
+
return res
|
|
544
|
+
try:
|
|
545
|
+
parts, err = _mouser_parts(r.json())
|
|
546
|
+
if err:
|
|
547
|
+
res.error = err
|
|
548
|
+
return res
|
|
549
|
+
best = L._pick(parts, p, lambda x: x.get("ManufacturerPartNumber"), lambda x: x.get("Manufacturer"))
|
|
550
|
+
if not best:
|
|
551
|
+
res.found = False
|
|
552
|
+
return res
|
|
553
|
+
info = mouser_info(best, self.name)
|
|
554
|
+
rep = str(best.get("SuggestedReplacement") or "").strip()
|
|
555
|
+
if rep and norm_mpn(rep) != norm_mpn(p["mpn"]):
|
|
556
|
+
info.similar.append(PartInfo(self.name, True, rep, note="Mouser suggested replacement"))
|
|
557
|
+
return info
|
|
558
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
559
|
+
res.error = f"unparseable response: {e}"
|
|
560
|
+
return res
|
|
561
|
+
|
|
562
|
+
def search(self, q, limit=20):
|
|
563
|
+
r = self._post(self.KEYWORD_URL, {"SearchByKeywordRequest": {"keyword": q, "records": min(limit, 50),
|
|
564
|
+
"startingRecord": 0, "searchOptions": "InStock"}})
|
|
565
|
+
if not r.ok:
|
|
566
|
+
return [], L._http_error(L.LifecycleResult(self.name), r).error
|
|
567
|
+
try:
|
|
568
|
+
parts, err = _mouser_parts(r.json())
|
|
569
|
+
return [mouser_info(x, self.name) for x in parts], err
|
|
570
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
571
|
+
return [], f"unparseable response: {e}"
|
|
572
|
+
|
|
573
|
+
def substitutes(self, p):
|
|
574
|
+
return [], ""
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _mouser_parts(d):
|
|
578
|
+
errs = [e for e in d.get("Errors") or [] if isinstance(e, dict)]
|
|
579
|
+
if errs:
|
|
580
|
+
return [], "; ".join(str(e.get("Message") or e.get("Code") or e) for e in errs)
|
|
581
|
+
return [x for x in (d.get("SearchResults") or {}).get("Parts") or [] if isinstance(x, dict)], ""
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def money(s):
|
|
585
|
+
"""'$0.10' / '0,10 €' / '1.234,50 €' / '1,234.50' / 0.1 -> float."""
|
|
586
|
+
if isinstance(s, (int, float)):
|
|
587
|
+
return float(s)
|
|
588
|
+
t = re.sub(r"[^\d.,]", "", str(s or ""))
|
|
589
|
+
if not t:
|
|
590
|
+
return None
|
|
591
|
+
if "," in t and "." in t:
|
|
592
|
+
t = t.replace(",", "") if t.rfind(".") > t.rfind(",") else t.replace(".", "").replace(",", ".")
|
|
593
|
+
elif "," in t:
|
|
594
|
+
t = t.replace(",", ".") if len(t.split(",")[-1]) != 3 or re.match(r"0?,", t) else t.replace(",", "")
|
|
595
|
+
# ("0,125 €" is 0.125: a thousands separator never follows a leading 0)
|
|
596
|
+
try:
|
|
597
|
+
return float(t)
|
|
598
|
+
except ValueError:
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def lead_days(s):
|
|
603
|
+
"""'84 Days' / '18 Weeks' / 12 -> days."""
|
|
604
|
+
n = _int(s)
|
|
605
|
+
if n is None:
|
|
606
|
+
return None
|
|
607
|
+
return n * 7 if re.search(r"week", str(s), re.I) else n
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def mouser_info(x, provider):
|
|
611
|
+
status = x.get("LifecycleStatus") or ""
|
|
612
|
+
stock = _int(x.get("AvailabilityInStock"))
|
|
613
|
+
if stock is None and re.search(r"in stock", str(x.get("Availability")), re.I):
|
|
614
|
+
stock = _int(x.get("Availability"))
|
|
615
|
+
info = PartInfo(provider, True, x.get("ManufacturerPartNumber") or "", x.get("Manufacturer") or "",
|
|
616
|
+
x.get("Description") or "", L.normalise(status) if status else "active", status,
|
|
617
|
+
url=x.get("ProductDetailUrl") or "", lead_days=lead_days(x.get("LeadTime")))
|
|
618
|
+
info.specs = specs_from_pairs([(a.get("AttributeName"), a.get("AttributeValue")) for a in x.get("ProductAttributes") or []
|
|
619
|
+
if isinstance(a, dict)])
|
|
620
|
+
breaks, cur = [], ""
|
|
621
|
+
for b in x.get("PriceBreaks") or []:
|
|
622
|
+
if isinstance(b, dict) and _int(b.get("Quantity")) and (pr := money(b.get("Price"))) is not None:
|
|
623
|
+
breaks.append((_int(b.get("Quantity")), pr))
|
|
624
|
+
cur = cur or str(b.get("Currency") or "")
|
|
625
|
+
info.offers.append(Offer(provider, "Mouser", x.get("MouserPartNumber") or "", stock, _int(x.get("Min")) or 1,
|
|
626
|
+
_int(x.get("Mult")) or 1, sorted(breaks), cur.upper(), info.lead_days, "", info.url, True))
|
|
627
|
+
return info
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
class JLCSourcing(L.JLCLifecycle):
|
|
631
|
+
shapes = ("ASSUMED (undocumented JLCPCB parts endpoint; field paths from easyeda2kicad and public scrapers): "
|
|
632
|
+
"data.componentPageInfo.list[] {componentCode, componentModelEn, componentBrandEn, stockCount, "
|
|
633
|
+
"componentSpecificationEn (package), describe, componentPrices[{startNumber, endNumber, productPrice}] in USD, "
|
|
634
|
+
"leastNumber (MOQ, if present)}. Stock and price are JLCPCB assembly stock, written as Supplier 'LCSC' "
|
|
635
|
+
"with the C-number. No lifecycle data.")
|
|
636
|
+
|
|
637
|
+
def part(self, p, alternates=False):
|
|
638
|
+
res = PartInfo(self.name, mpn=p["mpn"])
|
|
639
|
+
lcsc, mpn = (p.get("lcsc") or "").upper(), p["mpn"]
|
|
640
|
+
if not (lcsc or mpn):
|
|
641
|
+
res.error = "no MPN or LCSC number"
|
|
642
|
+
return res
|
|
643
|
+
items, err = self._query(lcsc or mpn)
|
|
644
|
+
if err:
|
|
645
|
+
res.error = err
|
|
646
|
+
return res
|
|
647
|
+
best = (next((i for i in items if str(i.get("componentCode", "")).upper() == lcsc), None) if lcsc
|
|
648
|
+
else L._pick(items, p, lambda i: i.get("componentModelEn"), lambda i: i.get("componentBrandEn")))
|
|
649
|
+
if not best:
|
|
650
|
+
res.found = False
|
|
651
|
+
return res
|
|
652
|
+
return jlc_info(best, self.name)
|
|
653
|
+
|
|
654
|
+
def _query(self, kw, size=10):
|
|
655
|
+
r = self.post(kw, size)
|
|
656
|
+
if not r.ok:
|
|
657
|
+
return [], L._http_error(L.LifecycleResult(self.name), r).error
|
|
658
|
+
try:
|
|
659
|
+
return [i for i in (((r.json().get("data") or {}).get("componentPageInfo") or {}).get("list") or [])
|
|
660
|
+
if isinstance(i, dict)], ""
|
|
661
|
+
except (ValueError, AttributeError, TypeError) as e:
|
|
662
|
+
return [], f"unparseable response: {e}"
|
|
663
|
+
|
|
664
|
+
def search(self, q, limit=20):
|
|
665
|
+
items, err = self._query(q, limit)
|
|
666
|
+
return [jlc_info(i, self.name) for i in items], err
|
|
667
|
+
|
|
668
|
+
def substitutes(self, p):
|
|
669
|
+
return [], ""
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def jlc_info(i, provider):
|
|
673
|
+
code = str(i.get("componentCode") or "")
|
|
674
|
+
desc = " ".join(str(i.get(k) or "") for k in ("describe", "componentSpecificationEn")).strip()
|
|
675
|
+
info = PartInfo(provider, True, i.get("componentModelEn") or "", i.get("componentBrandEn") or "", desc,
|
|
676
|
+
url=f"https://jlcpcb.com/partdetail/{code}" if code else "")
|
|
677
|
+
info.specs = {k: v for k, v in {"package": package_of(i.get("componentSpecificationEn"))}.items() if v}
|
|
678
|
+
breaks = sorted((_int(b.get("startNumber")), float(b["productPrice"])) for b in i.get("componentPrices") or []
|
|
679
|
+
if isinstance(b, dict) and b.get("productPrice") is not None and _int(b.get("startNumber")))
|
|
680
|
+
info.offers.append(Offer(provider, "LCSC", code, _int(i.get("stockCount")), _int(i.get("leastNumber")) or 1, 1,
|
|
681
|
+
breaks, "USD", None, "", info.url, True))
|
|
682
|
+
return info
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
PROVIDERS = [NexarSourcing, DigiKeySourcing, MouserSourcing, JLCSourcing]
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# ------------------------------------------------------------------ merging what the sources say
|
|
689
|
+
def merge_infos(infos):
|
|
690
|
+
"""One view of a part across sources: first non-empty field in provider order, worst known lifecycle, all offers
|
|
691
|
+
(a distributor SKU reported both directly and through Nexar is kept once, the direct one)."""
|
|
692
|
+
found = [i for i in infos if i.found]
|
|
693
|
+
if not found:
|
|
694
|
+
return None
|
|
695
|
+
m = PartInfo("+".join(i.provider for i in found), True)
|
|
696
|
+
for i in found:
|
|
697
|
+
m.mpn, m.manufacturer = m.mpn or i.mpn, m.manufacturer or i.manufacturer
|
|
698
|
+
m.description, m.url = m.description or i.description, m.url or i.url
|
|
699
|
+
for k, v in i.specs.items():
|
|
700
|
+
m.specs.setdefault(k, v)
|
|
701
|
+
if m.lead_days is None:
|
|
702
|
+
m.lead_days = i.lead_days
|
|
703
|
+
m.similar += i.similar
|
|
704
|
+
known = [i for i in found if i.lifecycle != "unknown"]
|
|
705
|
+
if known:
|
|
706
|
+
w = max(known, key=lambda i: L.SEVERITY[i.lifecycle])
|
|
707
|
+
m.lifecycle = w.lifecycle
|
|
708
|
+
m.lifecycle_raw = "; ".join(f"{i.provider}: {i.lifecycle_raw}" for i in found if i.lifecycle_raw)
|
|
709
|
+
seen = {}
|
|
710
|
+
for o in sorted((o for i in found for o in i.offers), key=lambda o: o.source == "nexar"):
|
|
711
|
+
k = (dist_key(o.distributor), norm_mpn(o.sku) or id(o))
|
|
712
|
+
seen.setdefault(k, o)
|
|
713
|
+
m.offers = list(seen.values())
|
|
714
|
+
return m
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def merge_candidates(cands):
|
|
718
|
+
by = {}
|
|
719
|
+
for c in cands:
|
|
720
|
+
if c.mpn:
|
|
721
|
+
by.setdefault(norm_mpn(c.mpn), []).append(c)
|
|
722
|
+
out = []
|
|
723
|
+
for group in by.values():
|
|
724
|
+
m = merge_infos(group)
|
|
725
|
+
m.note = "; ".join(dict.fromkeys(c.note for c in group if c.note))
|
|
726
|
+
out.append(m)
|
|
727
|
+
return out
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# ------------------------------------------------------------------ BOM lines
|
|
731
|
+
def line_kind(ln):
|
|
732
|
+
m = re.match(r"[A-Z]+", (ln.get("designator") or "").strip().upper())
|
|
733
|
+
return m.group(0) if m and m.group(0) in KIND_WORD else None
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def line_qty(ln):
|
|
737
|
+
q = _int(ln.get("qty"))
|
|
738
|
+
return q if q else (count_refs(ln.get("designator") or "") or 1)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def required_specs(ln, kind, part):
|
|
742
|
+
"""What a drop-in must meet: the part's own specs (from the sources) first, then the BOM value, footprint and
|
|
743
|
+
description."""
|
|
744
|
+
req = dict(part.specs) if part else {}
|
|
745
|
+
if part:
|
|
746
|
+
for k, v in specs_from_text(part.description, kind).items():
|
|
747
|
+
req.setdefault(k, v)
|
|
748
|
+
v, unit = parse_value(ln.get("value"), True)
|
|
749
|
+
if v is not None and unit in ("", KIND_UNIT[kind]):
|
|
750
|
+
req.setdefault("value", v)
|
|
751
|
+
pkg = package_of(ln.get("footprint"), ln.get("description"), ln.get("value"))
|
|
752
|
+
if pkg:
|
|
753
|
+
req.setdefault("package", pkg)
|
|
754
|
+
for k, val in specs_from_text(f"{ln.get('value', '')} {ln.get('description', '')}", kind).items():
|
|
755
|
+
req.setdefault(k, val)
|
|
756
|
+
req["unit"] = KIND_UNIT[kind]
|
|
757
|
+
return req
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _offer_row(o, b):
|
|
761
|
+
return {"distributor": o.distributor, "sku": o.sku, "source": o.source, "stock": o.stock, "moq": o.moq, "mult": o.mult,
|
|
762
|
+
"order_qty": b["order_qty"], "unit": b["unit"], "ext": b["ext"], "covers": b["covers"], "lead_days": o.lead_days,
|
|
763
|
+
"packaging": o.packaging, "url": o.url}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def suppliers(ranked):
|
|
767
|
+
"""Best offer per distributor, in rank order -> [(Supplier name, part number)]."""
|
|
768
|
+
out, seen = [], set()
|
|
769
|
+
for o, b in ranked:
|
|
770
|
+
k = dist_key(o.distributor)
|
|
771
|
+
if k in seen or not o.sku:
|
|
772
|
+
continue
|
|
773
|
+
seen.add(k)
|
|
774
|
+
out.append((SUPPLIER.get(k, o.distributor), o.sku))
|
|
775
|
+
return out
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def evaluate_alternates(part, line_mpn, kind, req, cands, need, currency):
|
|
779
|
+
"""Score candidate parts. Passives: parametric drop-in check. Others: listed for engineer review only."""
|
|
780
|
+
rows, orig = [], norm_mpn(line_mpn)
|
|
781
|
+
for c in merge_candidates(cands):
|
|
782
|
+
if norm_mpn(c.mpn) == orig:
|
|
783
|
+
continue
|
|
784
|
+
if kind:
|
|
785
|
+
spec = dict(c.specs)
|
|
786
|
+
for k, v in specs_from_text(c.description, kind).items():
|
|
787
|
+
spec.setdefault(k, v)
|
|
788
|
+
verdict, why = drop_in(req, spec, kind)
|
|
789
|
+
if verdict == "mismatch":
|
|
790
|
+
continue
|
|
791
|
+
else:
|
|
792
|
+
verdict, why = "needs engineer review", []
|
|
793
|
+
if c.lifecycle in RISKY:
|
|
794
|
+
continue # never propose an NRND / LTB / obsolete part
|
|
795
|
+
if c.lifecycle == "unknown":
|
|
796
|
+
why = why + ["lifecycle unknown"]
|
|
797
|
+
ranked = rank_offers(c.offers, need, currency)
|
|
798
|
+
best = _offer_row(*ranked[0]) if ranked else None
|
|
799
|
+
same_mfr = bool(part and part.manufacturer and norm_mpn(c.manufacturer)[:6] == norm_mpn(part.manufacturer)[:6])
|
|
800
|
+
rows.append({"mpn": c.mpn, "manufacturer": c.manufacturer, "verdict": verdict, "lifecycle": c.lifecycle,
|
|
801
|
+
"notes": "; ".join(why + ([c.note] if c.note else [])), "best": best, "same_manufacturer": same_mfr,
|
|
802
|
+
"sources": c.provider})
|
|
803
|
+
order = {"drop_in": 0, "needs engineer review": 0, "unconfirmed": 1}
|
|
804
|
+
rows.sort(key=lambda r: (order[r["verdict"]], not (r["best"] and r["best"]["covers"]), r["same_manufacturer"],
|
|
805
|
+
r["best"]["ext"] if r["best"] else float("inf"), r["mpn"]))
|
|
806
|
+
return rows
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def source_line(i, ln, provs, build_qty, currency, alternates, low_factor, unconfigured, merged=()):
|
|
810
|
+
"""merged: [(line number, line)] of later BOM lines with the same part, bought with this one."""
|
|
811
|
+
per_board = line_qty(ln) + sum(line_qty(x) for _, x in merged)
|
|
812
|
+
need = per_board * build_qty
|
|
813
|
+
pns = part_numbers(ln)
|
|
814
|
+
mpn = pns[0] if pns else ""
|
|
815
|
+
kind = line_kind(ln)
|
|
816
|
+
row = {"line": i, "designator": ln.get("designator", ""), "qty_per_board": per_board, "need": need, "mpn": mpn,
|
|
817
|
+
"manufacturer": ln.get("manufacturer", ""), "lcsc": ln.get("lcsc", ""), "value": ln.get("value", ""),
|
|
818
|
+
"kind": KIND_WORD.get(kind, "other"), "flags": [], "notes": [], "answered": False, "offers": [], "alternates": [],
|
|
819
|
+
"suppliers": [], "chosen": None, "lifecycle": "unknown", "distributors_in_stock": 0, "lead_days": None}
|
|
820
|
+
if merged:
|
|
821
|
+
row["notes"].append("bought together with line " + ", ".join(
|
|
822
|
+
f"{j} ({x.get('designator', '')}, {line_qty(x)} per board)" for j, x in merged) + ": same part")
|
|
823
|
+
p = {"mpn": mpn, "manufacturer": ln.get("manufacturer", ""), "lcsc": ln.get("lcsc", "")}
|
|
824
|
+
if not (mpn or p["lcsc"]):
|
|
825
|
+
row["flags"].append("no_mpn")
|
|
826
|
+
infos = []
|
|
827
|
+
for prov in provs:
|
|
828
|
+
if not (mpn or (p["lcsc"] and isinstance(prov, JLCSourcing))):
|
|
829
|
+
continue
|
|
830
|
+
try:
|
|
831
|
+
r = prov.part(p, alternates=alternates)
|
|
832
|
+
except P.NotConfigured:
|
|
833
|
+
unconfigured.add(prov.name)
|
|
834
|
+
continue
|
|
835
|
+
infos.append(r)
|
|
836
|
+
if r.error:
|
|
837
|
+
row["notes"].append(f"{prov.name}: {r.error}")
|
|
838
|
+
elif r.found is False:
|
|
839
|
+
row["notes"].append(f"{prov.name}: not found" + (f" ({r.note})" if r.note else ""))
|
|
840
|
+
row["answered"] = any(r.found is not None and not r.error for r in infos)
|
|
841
|
+
if (mpn or p["lcsc"]) and not row["answered"]:
|
|
842
|
+
row["flags"].append("no_answer")
|
|
843
|
+
part = merge_infos(infos)
|
|
844
|
+
if (mpn or p["lcsc"]) and row["answered"] and not part:
|
|
845
|
+
row["flags"].append("not_found")
|
|
846
|
+
if part:
|
|
847
|
+
row["manufacturer"] = row["manufacturer"] or part.manufacturer
|
|
848
|
+
row["lifecycle"] = part.lifecycle
|
|
849
|
+
if part.lifecycle in RISKY:
|
|
850
|
+
row["flags"].append(f"lifecycle:{part.lifecycle}")
|
|
851
|
+
skipped = sorted({o.currency for o in part.offers if o.currency and o.currency.upper() != currency.upper()})
|
|
852
|
+
if skipped:
|
|
853
|
+
row["notes"].append(f"offers in {', '.join(skipped)} not compared (want {currency})")
|
|
854
|
+
ranked = rank_offers(part.offers, need, currency)
|
|
855
|
+
row["offers"] = [_offer_row(o, b) for o, b in ranked]
|
|
856
|
+
in_stock = {dist_key(o.distributor) for o in part.offers if (o.stock or 0) > 0}
|
|
857
|
+
row["distributors_in_stock"] = len(in_stock)
|
|
858
|
+
row["suppliers"] = suppliers(ranked)
|
|
859
|
+
if not ranked:
|
|
860
|
+
row["flags"].append("no_price")
|
|
861
|
+
else:
|
|
862
|
+
row["chosen"] = row["offers"][0]
|
|
863
|
+
c = row["chosen"]
|
|
864
|
+
row["lead_days"] = c["lead_days"] if c["lead_days"] is not None else part.lead_days
|
|
865
|
+
if not in_stock:
|
|
866
|
+
row["flags"].append("no_stock")
|
|
867
|
+
else:
|
|
868
|
+
if not (row["chosen"] and row["chosen"]["covers"]):
|
|
869
|
+
row["flags"].append("low_stock")
|
|
870
|
+
elif row["chosen"]["stock"] < low_factor * need:
|
|
871
|
+
row["flags"].append("low_stock")
|
|
872
|
+
if len(in_stock) == 1:
|
|
873
|
+
row["flags"].append("single_distributor")
|
|
874
|
+
if alternates and (part or kind):
|
|
875
|
+
cands, req, searched = list(part.similar) if part else [], None, bool(part)
|
|
876
|
+
if kind:
|
|
877
|
+
req = required_specs(ln, kind, part)
|
|
878
|
+
if req.get("value") is None or not req.get("package"):
|
|
879
|
+
row["notes"].append("alternates: value or package unknown, no parametric search")
|
|
880
|
+
else:
|
|
881
|
+
q = " ".join(x for x in (fmt_si(req["value"], KIND_UNIT[kind]), req["package"], KIND_WORD[kind],
|
|
882
|
+
req.get("dielectric") if kind == "C" else None) if x)
|
|
883
|
+
for prov in provs:
|
|
884
|
+
try:
|
|
885
|
+
found, err = prov.search(q)
|
|
886
|
+
except P.NotConfigured:
|
|
887
|
+
unconfigured.add(prov.name)
|
|
888
|
+
continue
|
|
889
|
+
cands += found
|
|
890
|
+
searched |= not err
|
|
891
|
+
if err:
|
|
892
|
+
row["notes"].append(f"{prov.name} search: {err}")
|
|
893
|
+
elif part:
|
|
894
|
+
for prov in provs:
|
|
895
|
+
try:
|
|
896
|
+
found, err = prov.substitutes(p)
|
|
897
|
+
except P.NotConfigured:
|
|
898
|
+
continue
|
|
899
|
+
cands += found
|
|
900
|
+
if err:
|
|
901
|
+
row["notes"].append(f"{prov.name} substitutes: {err}")
|
|
902
|
+
if not kind or req.get("value") is not None and req.get("package"):
|
|
903
|
+
row["alternates"] = evaluate_alternates(part, mpn, kind, req, cands, need, currency)
|
|
904
|
+
if not any(a["verdict"] in ("drop_in", "needs engineer review") for a in row["alternates"]):
|
|
905
|
+
if searched: # a source answered and offered nothing usable
|
|
906
|
+
row["flags"].append("single_source")
|
|
907
|
+
elif not kind:
|
|
908
|
+
row["flags"].append("alternates_need_review")
|
|
909
|
+
row["required"] = {k: v for k, v in (req or {}).items()}
|
|
910
|
+
row["notes"] = list(dict.fromkeys(row["notes"]))
|
|
911
|
+
return row
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _buy_key(ln):
|
|
915
|
+
"""Lines with this key are one part to buy: the normalised MPN, else the LCSC number ('' = never merged)."""
|
|
916
|
+
pns = part_numbers(ln)
|
|
917
|
+
mpn = pns[0] if pns else ""
|
|
918
|
+
if mpn and not placeholder_mpn(mpn) and norm_mpn(mpn):
|
|
919
|
+
return norm_mpn(mpn)
|
|
920
|
+
return f"LCSC:{str(ln['lcsc']).upper()}" if ln.get("lcsc") else ""
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def source_bom(lines, provs, build_qty=1, currency="USD", alternates=False, low_factor=3.0):
|
|
924
|
+
"""One row per BOM line. Fitted lines with the same part (MPN, else LCSC number) are bought together: the first
|
|
925
|
+
carries the summed quantity (stock, MOQ and price breaks apply to the total), the later ones are `merged` rows."""
|
|
926
|
+
unconfigured, rows = set(), []
|
|
927
|
+
groups = {}
|
|
928
|
+
for i, ln in enumerate(lines, 1):
|
|
929
|
+
if not ln.get("dnp") and _buy_key(ln):
|
|
930
|
+
groups.setdefault(_buy_key(ln), []).append((i, ln))
|
|
931
|
+
for i, ln in enumerate(lines, 1):
|
|
932
|
+
grp = groups.get(_buy_key(ln)) if not ln.get("dnp") and _buy_key(ln) else None
|
|
933
|
+
if grp and grp[0][0] != i:
|
|
934
|
+
rows.append({"line": i, "designator": ln.get("designator", ""), "qty_per_board": line_qty(ln), "need": 0,
|
|
935
|
+
"mpn": (part_numbers(ln) or [""])[0], "manufacturer": ln.get("manufacturer", ""), "lcsc": ln.get("lcsc", ""),
|
|
936
|
+
"value": ln.get("value", ""), "kind": "merged", "flags": ["merged"],
|
|
937
|
+
"notes": [f"same part as line {grp[0][0]}: bought there"], "answered": False, "offers": [],
|
|
938
|
+
"alternates": [], "suppliers": [], "chosen": None, "lifecycle": "", "distributors_in_stock": 0,
|
|
939
|
+
"lead_days": None})
|
|
940
|
+
continue
|
|
941
|
+
if ln.get("dnp"):
|
|
942
|
+
rows.append({"line": i, "designator": ln.get("designator", ""), "qty_per_board": line_qty(ln), "need": 0,
|
|
943
|
+
"mpn": (part_numbers(ln) or [""])[0], "manufacturer": ln.get("manufacturer", ""), "lcsc": ln.get("lcsc", ""),
|
|
944
|
+
"value": ln.get("value", ""), "kind": "dnp", "flags": ["dnp"], "notes": ["do not populate: not bought"],
|
|
945
|
+
"answered": False, "offers": [], "alternates": [], "suppliers": [], "chosen": None,
|
|
946
|
+
"lifecycle": "", "distributors_in_stock": 0, "lead_days": None})
|
|
947
|
+
continue
|
|
948
|
+
rows.append(source_line(i, ln, provs, build_qty, currency, alternates, low_factor, unconfigured, (grp or [])[1:]))
|
|
949
|
+
return rows, sorted(unconfigured)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def rollup(rows, build_qty, currency):
|
|
953
|
+
fitted = [r for r in rows if "dnp" not in r["flags"] and "merged" not in r["flags"]]
|
|
954
|
+
priced = [r for r in fitted if r["chosen"]]
|
|
955
|
+
total = round(sum(r["chosen"]["ext"] for r in priced), 4)
|
|
956
|
+
full = [r for r in priced if r["chosen"]["covers"]]
|
|
957
|
+
per_dist = {}
|
|
958
|
+
for r in fitted:
|
|
959
|
+
for o in r["offers"]:
|
|
960
|
+
if o["covers"]:
|
|
961
|
+
d = per_dist.setdefault(SUPPLIER.get(dist_key(o["distributor"]), o["distributor"]), {"lines": 0, "cost": 0.0, "seen": set()})
|
|
962
|
+
if r["line"] not in d["seen"]:
|
|
963
|
+
d["seen"].add(r["line"])
|
|
964
|
+
d["lines"] += 1
|
|
965
|
+
d["cost"] += min(x["ext"] for x in r["offers"] if x["covers"] and x["distributor"] == o["distributor"])
|
|
966
|
+
lead = [r["lead_days"] for r in fitted if r["lead_days"] is not None and not (r["chosen"] and r["chosen"]["covers"])]
|
|
967
|
+
return {"currency": currency, "build_qty": build_qty, "lines": len(fitted), "priced": len(priced),
|
|
968
|
+
"in_stock": len(full), "total": total, "per_board": round(total / build_qty, 4) if build_qty else None,
|
|
969
|
+
"complete": len(priced) == len(fitted) and len(full) == len(fitted),
|
|
970
|
+
"longest_lead_days": max(lead) if lead else None,
|
|
971
|
+
"per_distributor": {k: {"lines": v["lines"], "cost": round(v["cost"], 4)} for k, v in
|
|
972
|
+
sorted(per_dist.items(), key=lambda kv: (-kv[1]["lines"], kv[1]["cost"]))}}
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
# ------------------------------------------------------------------ plan write-back
|
|
976
|
+
def write_plan(plan_path, rows, build_qty, currency, now=None, max_alternates=2):
|
|
977
|
+
"""Fill empty Supplier/Alternate parameters of an altium365 plan in place. Returns [(item name, [params set])]."""
|
|
978
|
+
path = Path(plan_path)
|
|
979
|
+
items = json.loads(path.read_text(encoding="utf-8"))
|
|
980
|
+
by = {}
|
|
981
|
+
for r in rows:
|
|
982
|
+
if r["mpn"] and "dnp" not in r["flags"] and "merged" not in r["flags"]:
|
|
983
|
+
by.setdefault(norm_mpn(r["mpn"]), r)
|
|
984
|
+
changed = []
|
|
985
|
+
for it in items:
|
|
986
|
+
par = {p["name"]: p["value"] for p in it.get("parameters", [])}
|
|
987
|
+
r = by.get(norm_mpn(par.get("Manufacturer Part Number", "")))
|
|
988
|
+
if not r:
|
|
989
|
+
continue
|
|
990
|
+
sets = {}
|
|
991
|
+
have_pn = {norm_mpn(par.get(f"Supplier Part Number {n}")) for n in (1, 2)} - {""}
|
|
992
|
+
have_d = {dist_key(par.get(f"Supplier {n}")) for n in (1, 2)} - {""}
|
|
993
|
+
sug = [(s, pn) for s, pn in r["suppliers"] if norm_mpn(pn) not in have_pn and dist_key(s) not in have_d]
|
|
994
|
+
for n in (1, 2):
|
|
995
|
+
if not str(par.get(f"Supplier {n}", "")).strip() and not str(par.get(f"Supplier Part Number {n}", "")).strip() and sug:
|
|
996
|
+
s, pn = sug.pop(0)
|
|
997
|
+
sets[f"Supplier {n}"], sets[f"Supplier Part Number {n}"] = s, pn
|
|
998
|
+
drop = [a for a in r["alternates"] if a["verdict"] == "drop_in" and a["lifecycle"] not in RISKY]
|
|
999
|
+
if r["kind"] in KIND_WORD.values():
|
|
1000
|
+
have_alt = {norm_mpn(par.get(f"Alternate Manufacturer Part Number {n}")) for n in range(1, max_alternates + 1)} - {""}
|
|
1001
|
+
drop = [a for a in drop if norm_mpn(a["mpn"]) not in have_alt]
|
|
1002
|
+
for n in range(1, max_alternates + 1):
|
|
1003
|
+
if not str(par.get(f"Alternate Manufacturer Part Number {n}", "")).strip() and drop:
|
|
1004
|
+
a = drop.pop(0)
|
|
1005
|
+
sets[f"Alternate Manufacturer {n}"], sets[f"Alternate Manufacturer Part Number {n}"] = a["manufacturer"], a["mpn"]
|
|
1006
|
+
for k, v in sets.items(): # same rule as librarian._set_par: a set value is not cleared
|
|
1007
|
+
it["parameters"] = [p for p in it["parameters"] if p["name"] != k] + [{"name": k, "value": v}]
|
|
1008
|
+
it["cleared"] = [c for c in it.get("cleared", []) if c not in sets]
|
|
1009
|
+
it["sourcing"] = {"checked": now or _now(), "build_qty": build_qty, "currency": currency, "need": r["need"],
|
|
1010
|
+
"chosen": r["chosen"], "flags": r["flags"], "lifecycle": r["lifecycle"],
|
|
1011
|
+
"suppliers": [list(s) for s in r["suppliers"]],
|
|
1012
|
+
"alternates": [{k: a[k] for k in ("mpn", "manufacturer", "verdict", "lifecycle", "notes")}
|
|
1013
|
+
for a in r["alternates"][:5]]}
|
|
1014
|
+
changed.append((it["name"], sorted(sets)))
|
|
1015
|
+
tmp = path.with_suffix(".tmp")
|
|
1016
|
+
tmp.write_text(json.dumps(items, indent=2), encoding="utf-8")
|
|
1017
|
+
os.replace(tmp, path)
|
|
1018
|
+
return changed
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
# ------------------------------------------------------------------ reports
|
|
1022
|
+
COLS = ["line", "designator", "qty_per_board", "need", "mpn", "manufacturer", "kind", "lifecycle", "distributor", "sku",
|
|
1023
|
+
"unit_price", "order_qty", "ext_price", "currency", "stock", "moq", "mult", "lead_days", "distributors_in_stock",
|
|
1024
|
+
"flags", "Supplier 1", "Supplier Part Number 1", "Supplier 2", "Supplier Part Number 2", "alternates", "notes"]
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _alt_text(a):
|
|
1028
|
+
b = a["best"]
|
|
1029
|
+
price = f" {b['unit']:.4g}@{b['order_qty']}" if b else ""
|
|
1030
|
+
return f"{a['manufacturer']} {a['mpn']} [{a['verdict']}{', ' + a['notes'] if a['notes'] else ''}]{price}"
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def csv_row(r, currency):
|
|
1034
|
+
c = r["chosen"] or {}
|
|
1035
|
+
s = r["suppliers"] + [("", "")] * 2
|
|
1036
|
+
return {**{k: r.get(k, "") for k in ("line", "designator", "qty_per_board", "need", "mpn", "manufacturer", "kind",
|
|
1037
|
+
"lifecycle", "distributors_in_stock")},
|
|
1038
|
+
"distributor": c.get("distributor", ""), "sku": c.get("sku", ""), "unit_price": c.get("unit", ""),
|
|
1039
|
+
"order_qty": c.get("order_qty", ""), "ext_price": c.get("ext", ""), "currency": currency if c else "",
|
|
1040
|
+
"stock": c.get("stock", ""), "moq": c.get("moq", ""), "mult": c.get("mult", ""),
|
|
1041
|
+
"lead_days": "" if r["lead_days"] is None else r["lead_days"], "flags": " ".join(r["flags"]),
|
|
1042
|
+
"Supplier 1": s[0][0], "Supplier Part Number 1": s[0][1], "Supplier 2": s[1][0], "Supplier Part Number 2": s[1][1],
|
|
1043
|
+
"alternates": "; ".join(_alt_text(a) for a in r["alternates"][:5]), "notes": "; ".join(r["notes"])}
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def write_reports(rows, out, meta, provs, unconfigured, http, roll, plan_changes=None):
|
|
1047
|
+
out = Path(out)
|
|
1048
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1049
|
+
cur = roll["currency"]
|
|
1050
|
+
with open(out.with_suffix(".csv"), "w", newline="", encoding="utf-8-sig") as f:
|
|
1051
|
+
w = SS.DictWriter(f, fieldnames=COLS)
|
|
1052
|
+
w.writeheader()
|
|
1053
|
+
w.writerows(csv_row(r, cur) for r in rows)
|
|
1054
|
+
money_ = lambda v: "-" if v is None or v == "" else f"{v:,.4f}".rstrip("0").rstrip(".") if isinstance(v, float) else str(v)
|
|
1055
|
+
md = [f"# Sourcing - `{meta['bom']}`", "", f"- checked: {meta['now']}", f"- build quantity: {roll['build_qty']} boards",
|
|
1056
|
+
f"- currency: {cur}", "", "## Cost rollup", "",
|
|
1057
|
+
f"- **total**: {money_(roll['total'])} {cur} for {roll['build_qty']} boards "
|
|
1058
|
+
f"(**{money_(roll['per_board'])} {cur} per board**; order quantities include MOQ and price-break buy-up)",
|
|
1059
|
+
f"- lines priced: {roll['priced']} / {roll['lines']}; fully in stock: {roll['in_stock']} / {roll['lines']}",
|
|
1060
|
+
f"- longest lead time of a line not in stock: {roll['longest_lead_days'] if roll['longest_lead_days'] is not None else '-'} days"]
|
|
1061
|
+
if not roll["complete"]:
|
|
1062
|
+
md.append("- **the total is incomplete**: some lines are unpriced or not in stock (see flags)")
|
|
1063
|
+
flags = {}
|
|
1064
|
+
for r in rows:
|
|
1065
|
+
for fl in r["flags"]:
|
|
1066
|
+
flags.setdefault(fl, []).append(r["mpn"] or r["designator"])
|
|
1067
|
+
md += ["", "## Flags", ""] + ([f"- **{k}** ({len(v)}): {', '.join(v)}" for k, v in sorted(flags.items())] or ["None."])
|
|
1068
|
+
md += ["", "## Lines", "", f"| # | Ref | Need | MPN | Lifecycle | Chosen | Unit | Order qty | Ext ({cur}) | Stock | MOQ | Lead (d) | Flags |",
|
|
1069
|
+
"|---|---|---|---|---|---|---|---|---|---|---|---|---|"]
|
|
1070
|
+
for r in rows:
|
|
1071
|
+
c = r["chosen"] or {}
|
|
1072
|
+
md.append(f"| {r['line']} | {r['designator'][:18]} | {r['need']} | {r['mpn']} | {L.LABEL.get(r['lifecycle'], r['lifecycle'] or '-')} | "
|
|
1073
|
+
f"{(c.get('distributor', '') + ' ' + c.get('sku', '')).strip() or '-'} | {money_(c.get('unit'))} | {c.get('order_qty', '-')} | "
|
|
1074
|
+
f"{money_(c.get('ext'))} | {c.get('stock', '-') if c.get('stock') is not None else '-'} | {c.get('moq', '-')} | "
|
|
1075
|
+
f"{r['lead_days'] if r['lead_days'] is not None else '-'} | {' '.join(r['flags'])} |")
|
|
1076
|
+
md += ["", "## Cost if bought from one distributor", "", "Lines each distributor can supply in full from stock, and their cost.", "",
|
|
1077
|
+
"| distributor | lines | cost |", "|---|---|---|"]
|
|
1078
|
+
md += [f"| {k} | {v['lines']} / {roll['lines']} | {money_(v['cost'])} |" for k, v in roll["per_distributor"].items()] or ["| - | - | - |"]
|
|
1079
|
+
alts = [r for r in rows if r["alternates"] or "single_source" in r["flags"]]
|
|
1080
|
+
if meta.get("alternates"):
|
|
1081
|
+
md += ["", "## Alternates", "",
|
|
1082
|
+
"Passives: parametric drop-ins (`drop_in` = every parameter the original states is met or bettered; "
|
|
1083
|
+
"`unconfirmed` = the source does not state everything, check before use). "
|
|
1084
|
+
"ICs and other parts: **needs engineer review**, never substituted automatically. NRND/LTB/obsolete candidates are dropped.", ""]
|
|
1085
|
+
for r in alts:
|
|
1086
|
+
req = r.get("required") or {}
|
|
1087
|
+
reqs = ", ".join(f"{k} {fmt_si(v, req.get('unit', '')) if k == 'value' else f'{v:g}' if isinstance(v, float) else v}"
|
|
1088
|
+
for k, v in req.items() if k != "unit")
|
|
1089
|
+
md += [f"### {r['line']}. {r['mpn'] or r['designator']} ({r['kind']})" + (f" - need {reqs}" if reqs else ""), ""]
|
|
1090
|
+
md += [f"- {_alt_text(a)}" for a in r["alternates"][:8]] or ["- no alternate found (single source)"]
|
|
1091
|
+
md.append("")
|
|
1092
|
+
md += ["", "## Sources", "", "| provider | configured | auth | response shape |", "|---|---|---|---|"]
|
|
1093
|
+
md += [f"| {p.name} | {'no' if p.name in unconfigured else 'yes'} | {p.auth} | {p.shapes} |" for p in provs]
|
|
1094
|
+
if plan_changes is not None:
|
|
1095
|
+
md += ["", f"## Plan write-back (`{meta['plan']}`)", ""]
|
|
1096
|
+
md += [f"- {n}: {', '.join(ks) if ks else 'no empty fields to fill (sourcing notes recorded)'}" for n, ks in plan_changes] or ["No plan item matched a BOM MPN."]
|
|
1097
|
+
hosts = {}
|
|
1098
|
+
for m, u, s, cached in http.log:
|
|
1099
|
+
hosts.setdefault(re.sub(r"^https?://([^/]+).*", r"\1", u), []).append(("cache " if cached else "") + str(s))
|
|
1100
|
+
md += ["", "## HTTP (this run)", ""] + [f"- `{h}`: {len(v)} requests -> {sorted(set(v))}" for h, v in hosts.items()]
|
|
1101
|
+
out.with_suffix(".md").write_text("\n".join(md) + "\n", encoding="utf-8")
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def main(argv=None):
|
|
1105
|
+
ap = argparse.ArgumentParser(description="Price, stock, second sources and alternates for a BOM")
|
|
1106
|
+
ap.add_argument("bom")
|
|
1107
|
+
ap.add_argument("--qty", type=int, default=1, help="number of boards to build (default 1)")
|
|
1108
|
+
ap.add_argument("--alternates", action="store_true", help="find drop-in passives and IC alternates (for review)")
|
|
1109
|
+
ap.add_argument("--write-plan", metavar="PLAN", help="altium365 plan.json to fill Supplier/Alternate parameters in (in place)")
|
|
1110
|
+
ap.add_argument("--providers", default="nexar,digikey,mouser,jlcpcb_search")
|
|
1111
|
+
ap.add_argument("--currency", default="USD")
|
|
1112
|
+
ap.add_argument("--country", default="US")
|
|
1113
|
+
ap.add_argument("--low-stock-factor", type=float, default=3.0, help="flag low_stock when stock < factor x need")
|
|
1114
|
+
ap.add_argument("-o", "--out", default="out/sourcing", help="report path stem (.md and .csv written)")
|
|
1115
|
+
ap.add_argument("--offline", action="store_true", help="use HTTP cache only")
|
|
1116
|
+
ap.add_argument("--cache", default="out/http_cache")
|
|
1117
|
+
ap.add_argument("--max-age", type=float, default=12, help="hours a cached response stays fresh (prices move)")
|
|
1118
|
+
ap.add_argument("--rate", type=float, default=1.0, help="min seconds between requests per host")
|
|
1119
|
+
a = ap.parse_args(argv)
|
|
1120
|
+
if a.qty < 1:
|
|
1121
|
+
ap.error("--qty must be at least 1")
|
|
1122
|
+
try:
|
|
1123
|
+
from . import rules as RU
|
|
1124
|
+
rules = RU.load(None, start=Path(a.bom).resolve().parent) # its [bom] columns name your headers
|
|
1125
|
+
lines, _ = read_bom(a.bom, RU.bom_columns(rules), rules)
|
|
1126
|
+
except Exception as e:
|
|
1127
|
+
print(f"cannot read BOM: {type(e).__name__}: {e}", file=sys.stderr)
|
|
1128
|
+
return 2
|
|
1129
|
+
if a.write_plan and not Path(a.write_plan).is_file():
|
|
1130
|
+
print(f"plan not found: {a.write_plan}", file=sys.stderr)
|
|
1131
|
+
return 2
|
|
1132
|
+
http = CachedHTTP(a.cache, min_interval=a.rate, ttl_s=a.max_age * 3600, offline=a.offline)
|
|
1133
|
+
want = a.providers.split(",")
|
|
1134
|
+
provs = [c(http) for c in PROVIDERS if c.name in want]
|
|
1135
|
+
for p in provs:
|
|
1136
|
+
p.country, p.currency = a.country, a.currency.upper()
|
|
1137
|
+
rows, unconfigured = source_bom(lines, provs, a.qty, a.currency.upper(), a.alternates, a.low_stock_factor)
|
|
1138
|
+
roll = rollup(rows, a.qty, a.currency.upper())
|
|
1139
|
+
now = _now()
|
|
1140
|
+
changes = write_plan(a.write_plan, rows, a.qty, a.currency.upper(), now) if a.write_plan else None
|
|
1141
|
+
write_reports(rows, a.out, {"bom": a.bom, "now": now, "alternates": a.alternates, "plan": a.write_plan},
|
|
1142
|
+
provs, unconfigured, http, roll, changes)
|
|
1143
|
+
for r in rows:
|
|
1144
|
+
c = r["chosen"] or {}
|
|
1145
|
+
print(f"{r['line']:>3} {(r['mpn'] or r['designator'])[:24]:<24} need {r['need']:<6} "
|
|
1146
|
+
f"{(c.get('distributor', '-') + ' ' + c.get('sku', ''))[:30]:<30} {c.get('ext', '-')!s:<10} {' '.join(r['flags'])}")
|
|
1147
|
+
print(f"total {roll['total']} {roll['currency']} for {a.qty} boards ({roll['per_board']} per board); "
|
|
1148
|
+
f"{roll['priced']}/{roll['lines']} priced, {roll['in_stock']}/{roll['lines']} in stock -> {Path(a.out).with_suffix('.md')} and .csv")
|
|
1149
|
+
if changes is not None:
|
|
1150
|
+
print(f"plan updated: {sum(bool(k) for _, k in changes)} items got new fields -> {a.write_plan}")
|
|
1151
|
+
if unconfigured:
|
|
1152
|
+
print(f"not configured: {', '.join(unconfigured)}")
|
|
1153
|
+
fitted = [r for r in rows if "dnp" not in r["flags"] and "merged" not in r["flags"]]
|
|
1154
|
+
if fitted and not any(r["answered"] for r in fitted):
|
|
1155
|
+
print("no source answered for any line (secrets missing, quota, or network blocked)", file=sys.stderr)
|
|
1156
|
+
return 3
|
|
1157
|
+
return 0 if roll["complete"] else 1
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
if __name__ == "__main__":
|
|
1161
|
+
sys.exit(main())
|