Skip to content

Commit 7982571

Browse files
committed
Drop alternatives, only show 1 LCSC
1 parent 7a11787 commit 7982571

File tree

5 files changed

+168
-95
lines changed

5 files changed

+168
-95
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pcb-diode-api/src/bom.rs

Lines changed: 124 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ use reqwest::blocking::Client;
33
use serde::{Deserialize, Serialize};
44
use std::time::Duration;
55

6+
/// Number of boards to use for availability and pricing calculations
7+
const NUM_BOARDS: i32 = 20;
8+
9+
/// Availability tier for offer selection
10+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11+
enum AvailabilityTier {
12+
NoInventory = 0,
13+
Limited = 1,
14+
Plenty = 2,
15+
}
16+
617
/// Price break structure
718
#[derive(Debug, Clone, Deserialize, Serialize)]
819
pub struct PriceBreak {
@@ -91,6 +102,83 @@ pub struct MatchBomResponse {
91102
pub results: Vec<DesignMatchResult>,
92103
}
93104

105+
/// Check if this is a small generic passive requiring higher stock threshold
106+
fn is_small_generic_passive(entry: &pcb_sch::BomEntry) -> bool {
107+
let is_generic_passive = matches!(
108+
entry.generic_data,
109+
Some(pcb_sch::GenericComponent::Resistor(_) | pcb_sch::GenericComponent::Capacitor(_))
110+
);
111+
let is_small_package = matches!(entry.package.as_deref(), Some("0201" | "0402" | "0603"));
112+
113+
is_generic_passive && is_small_package
114+
}
115+
116+
/// Determine the availability tier for an offer
117+
fn get_availability_tier(stock: i32, qty: i32, is_small_passive: bool) -> AvailabilityTier {
118+
if stock == 0 {
119+
AvailabilityTier::NoInventory
120+
} else {
121+
let required_stock = if is_small_passive {
122+
100
123+
} else {
124+
qty * NUM_BOARDS
125+
};
126+
if stock >= required_stock {
127+
AvailabilityTier::Plenty
128+
} else {
129+
AvailabilityTier::Limited
130+
}
131+
}
132+
}
133+
134+
/// Rank availability tier (lower is better)
135+
#[inline]
136+
fn tier_rank(tier: AvailabilityTier) -> u8 {
137+
match tier {
138+
AvailabilityTier::Plenty => 0,
139+
AvailabilityTier::Limited => 1,
140+
AvailabilityTier::NoInventory => 2,
141+
}
142+
}
143+
144+
/// Compare offers within the same tier: prefer lower price, then higher stock
145+
#[inline]
146+
fn within_tier_cmp(a: &OfferWithMatchKey, b: &OfferWithMatchKey, qty: i32) -> std::cmp::Ordering {
147+
use std::cmp::Ordering::*;
148+
149+
let price_a = a.unit_price_at_qty(qty * NUM_BOARDS);
150+
let price_b = b.unit_price_at_qty(qty * NUM_BOARDS);
151+
152+
match (price_a, price_b) {
153+
(Some(pa), Some(pb)) => pa
154+
.partial_cmp(&pb)
155+
.unwrap_or(Equal)
156+
.then_with(|| b.stock_available.cmp(&a.stock_available)),
157+
(None, None) => b.stock_available.cmp(&a.stock_available),
158+
(Some(_), None) => Less,
159+
(None, Some(_)) => Greater,
160+
}
161+
}
162+
163+
/// Select the best offer: Plenty > Limited > None tier, then lowest price within tier.
164+
/// Single-pass, allocation-free selection using iterator comparator.
165+
fn select_best_offer(
166+
offers: &[OfferWithMatchKey],
167+
qty: i32,
168+
is_small_passive: bool,
169+
) -> Option<&OfferWithMatchKey> {
170+
offers.iter().min_by(|a, b| {
171+
let stock_a = a.stock_available.unwrap_or(0);
172+
let stock_b = b.stock_available.unwrap_or(0);
173+
let tier_a = get_availability_tier(stock_a, qty, is_small_passive);
174+
let tier_b = get_availability_tier(stock_b, qty, is_small_passive);
175+
176+
tier_rank(tier_a)
177+
.cmp(&tier_rank(tier_b))
178+
.then_with(|| within_tier_cmp(a, b, qty))
179+
})
180+
}
181+
94182
/// Fetch BOM matching results from the API and populate availability data
95183
pub fn fetch_and_populate_availability(auth_token: &str, bom: &mut pcb_sch::Bom) -> Result<()> {
96184
let api_base_url = crate::get_api_base_url();
@@ -136,47 +224,51 @@ pub fn fetch_and_populate_availability(auth_token: &str, bom: &mut pcb_sch::Bom)
136224
.filter(|(p, _)| p.as_str() == path)
137225
.count() as i32;
138226

139-
let mut stock_total = 0;
140-
let mut cheapest_single = None;
141-
let mut cheapest_20x = None;
142-
let mut lcsc_part_id = None;
143-
let mut product_url = None;
144-
145-
for offer in &result.offers {
146-
if offer.distributor.as_deref() == Some("lcsc") && lcsc_part_id.is_none() {
147-
lcsc_part_id = offer.distributor_part_id.as_ref().map(|id| {
148-
// Ensure LCSC part IDs have "C" prefix
149-
if id.starts_with('C') {
150-
id.clone()
151-
} else {
152-
format!("C{}", id)
153-
}
154-
});
155-
product_url = offer.product_url.clone();
156-
}
227+
// Get BOM entry to check if it's a small generic passive
228+
let bom_entry = bom.entries.get(path).unwrap();
229+
let is_small_passive = is_small_generic_passive(bom_entry);
157230

158-
stock_total += offer.stock_available.unwrap_or(0);
231+
// Select the best offer based on availability tier (Plenty > Limited > None)
232+
// then price within tier. This single offer is used for all downstream data.
233+
let best_offer = select_best_offer(&result.offers, qty, is_small_passive);
159234

160-
if let Some(unit_price) = offer.unit_price_at_qty(qty) {
161-
cheapest_single =
162-
Some(cheapest_single.map_or(unit_price, |p: f64| p.min(unit_price)));
163-
}
235+
// Extract all data from the best matched offer
236+
let (stock_total, lcsc_part_ids, price_single, price_boards) = match best_offer {
237+
Some(offer) => {
238+
let stock = offer.stock_available.unwrap_or(0);
164239

165-
if let Some(unit_price) = offer.unit_price_at_qty(qty * 20) {
166-
let total_price_20x = unit_price * (qty * 20) as f64;
167-
cheapest_20x =
168-
Some(cheapest_20x.map_or(total_price_20x, |p: f64| p.min(total_price_20x)));
240+
let lcsc_id = match (
241+
offer.distributor.as_deref(),
242+
&offer.distributor_part_id,
243+
&offer.product_url,
244+
) {
245+
(Some("lcsc"), Some(id), Some(url)) => {
246+
let formatted_id = if id.starts_with('C') {
247+
id.clone()
248+
} else {
249+
format!("C{}", id)
250+
};
251+
vec![(formatted_id, url.clone())]
252+
}
253+
_ => Vec::new(),
254+
};
255+
256+
let single = offer.unit_price_at_qty(qty);
257+
let unit_boards = offer.unit_price_at_qty(qty * NUM_BOARDS);
258+
let total_boards = unit_boards.map(|p| p * (qty * NUM_BOARDS) as f64);
259+
260+
(stock, lcsc_id, single, total_boards)
169261
}
170-
}
262+
None => (0, Vec::new(), None, None),
263+
};
171264

172265
bom.availability.insert(
173266
path.to_string(),
174267
pcb_sch::AvailabilityData {
175268
stock_total,
176-
price_single: cheapest_single,
177-
price_20x: cheapest_20x,
178-
lcsc_part_id,
179-
product_url,
269+
price_single,
270+
price_boards,
271+
lcsc_part_ids,
180272
},
181273
);
182274
}

crates/pcb-sch/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ comfy-table = { workspace = true, optional = true }
3030
starlark_syntax = { workspace = true }
3131
supports-hyperlinks = { version = "3.1", optional = true }
3232
terminal_hyperlink = { version = "0.1", optional = true }
33+
urlencoding = { version = "2.1", optional = true }
3334

3435
[features]
3536
default = []
36-
table = ["comfy-table", "supports-hyperlinks", "terminal_hyperlink"]
37+
table = ["comfy-table", "supports-hyperlinks", "terminal_hyperlink", "urlencoding"]
3738

3839
[dev-dependencies]
3940
tempfile = { workspace = true }

crates/pcb-sch/src/bom.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ pub struct Bom {
1616

1717
#[derive(Debug, Clone, PartialEq)]
1818
pub struct AvailabilityData {
19-
pub stock_total: i32, // Total stock across all offers
20-
pub price_single: Option<f64>, // Cheapest price for single board qty
21-
pub price_20x: Option<f64>, // Cheapest price for 20 boards qty
22-
pub lcsc_part_id: Option<String>, // LCSC part number for debugging
23-
pub product_url: Option<String>, // Product page URL for hyperlink
19+
pub stock_total: i32, // Total stock across all offers
20+
pub price_single: Option<f64>, // Cheapest price for single board qty
21+
pub price_boards: Option<f64>, // Cheapest price for NUM_BOARDS boards qty
22+
pub lcsc_part_ids: Vec<(String, String)>, // Vec of (LCSC part number, product URL)
2423
}
2524

2625
/// Trim and truncate description to 100 chars max

crates/pcb-sch/src/bom_table.rs

Lines changed: 37 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ use std::io::{self, Write};
22

33
use comfy_table::{Cell, Color, Table};
44
use terminal_hyperlink::Hyperlink as _;
5+
use urlencoding::encode as urlencode;
56

67
use crate::{AvailabilityData, Bom, GenericComponent};
78

9+
/// Number of boards to use for availability and pricing calculations
10+
const NUM_BOARDS: i32 = 20;
11+
812
/// Sourcing status for component availability
913
enum SourcingStatus {
1014
None, // Red - no stock available
@@ -54,11 +58,11 @@ fn get_sourcing_status(
5458
return SourcingStatus::Limited;
5559
}
5660

57-
// Small generic passives need ≥100 stock, others need qty × 20
61+
// Small generic passives need ≥100 stock, others need qty × NUM_BOARDS
5862
let required_stock = if is_small_generic_passive(generic_data, package) {
5963
100
6064
} else {
61-
(qty as i32) * 20
65+
(qty as i32) * NUM_BOARDS
6266
};
6367

6468
if stock >= required_stock {
@@ -244,12 +248,23 @@ impl Bom {
244248
}
245249
};
246250

251+
// Make MPN clickable with Digikey search link
252+
let mpn_display = if mpn.is_empty() {
253+
String::new()
254+
} else {
255+
let digikey_url = format!(
256+
"https://www.digikey.com/en/products/result?keywords={}",
257+
urlencode(mpn)
258+
);
259+
hyperlink(&digikey_url, mpn)
260+
};
261+
247262
let mpn_cell = if is_dnp {
248-
Cell::new(mpn).fg(Color::DarkGrey)
263+
Cell::new(mpn_display).fg(Color::DarkGrey)
249264
} else if is_house_part {
250-
Cell::new(mpn).fg(Color::Blue)
265+
Cell::new(mpn_display).fg(Color::Blue)
251266
} else {
252-
Cell::new(mpn)
267+
Cell::new(mpn_display)
253268
};
254269

255270
let manufacturer_cell = if is_dnp {
@@ -270,30 +285,6 @@ impl Bom {
270285
Cell::new(description)
271286
};
272287

273-
// Get alternatives
274-
let alternatives_str = entry
275-
.get("alternatives")
276-
.and_then(|a| a.as_array())
277-
.map(|arr| {
278-
if arr.is_empty() {
279-
String::new()
280-
} else {
281-
arr.iter()
282-
.filter_map(|alt| alt["mpn"].as_str())
283-
.collect::<Vec<_>>()
284-
.join(", ")
285-
}
286-
})
287-
.unwrap_or_default();
288-
289-
let alternatives_cell = if is_dnp {
290-
Cell::new(alternatives_str.as_str()).fg(Color::DarkGrey)
291-
} else if is_house_part {
292-
Cell::new(alternatives_str.as_str()).fg(Color::Blue)
293-
} else {
294-
Cell::new(alternatives_str.as_str())
295-
};
296-
297288
// Build row with stock as 2nd column when availability is present
298289
let mut row = vec![qty_cell];
299290

@@ -309,22 +300,22 @@ impl Bom {
309300
stock.to_string()
310301
};
311302

312-
// Price: "$X.XX ($Y.YY)" - unit price and total for 20 boards
313-
let price_str = match (avail.price_single, avail.price_20x) {
314-
(Some(p1), Some(p20)) => {
315-
format!("${:.2} (${:.2})", p1, p20)
303+
// Price: "$X.XX ($Y.YY)" - unit price and total for NUM_BOARDS boards
304+
let price_str = match (avail.price_single, avail.price_boards) {
305+
(Some(unit), Some(total)) => {
306+
format!("${:.2} (${:.2})", unit, total)
316307
}
317-
(Some(p1), None) => format!("${:.2}", p1),
308+
(Some(unit), None) => format!("${:.2}", unit),
318309
_ => String::new(),
319310
};
320311

321312
// Color stock cell based on availability
322-
let qty_for_20_boards = (qty as i32) * 20;
313+
let qty_for_boards = (qty as i32) * NUM_BOARDS;
323314
let stock_cell = if is_dnp {
324315
Cell::new(stock_str).fg(Color::DarkGrey)
325316
} else if stock == 0 {
326317
Cell::new(stock_str).fg(Color::Red)
327-
} else if stock < qty_for_20_boards {
318+
} else if stock < qty_for_boards {
328319
Cell::new(stock_str).fg(Color::Yellow)
329320
} else {
330321
Cell::new(stock_str).fg(Color::Green)
@@ -336,18 +327,13 @@ impl Bom {
336327
Cell::new(price_str)
337328
};
338329

339-
let lcsc_pn = avail.lcsc_part_id.as_deref().unwrap_or("");
340-
341-
// Make LCSC part ID clickable if product_url is available
342-
let lcsc_display = if let Some(url) = &avail.product_url {
343-
if !lcsc_pn.is_empty() {
344-
hyperlink(url, lcsc_pn)
345-
} else {
346-
String::new()
347-
}
348-
} else {
349-
lcsc_pn.to_string()
350-
};
330+
// Make all LCSC part IDs clickable with their URLs
331+
let lcsc_display = avail
332+
.lcsc_part_ids
333+
.iter()
334+
.map(|(id, url)| hyperlink(url, id))
335+
.collect::<Vec<_>>()
336+
.join(", ");
351337

352338
let lcsc_cell = if is_dnp {
353339
Cell::new(lcsc_display).fg(Color::DarkGrey)
@@ -376,7 +362,6 @@ impl Bom {
376362
row.extend(vec![
377363
designators_cell,
378364
mpn_cell,
379-
alternatives_cell,
380365
manufacturer_cell,
381366
package_cell,
382367
]);
@@ -394,23 +379,18 @@ impl Bom {
394379
}
395380

396381
// Set headers with Stock as 2nd column when available
382+
let price_header = format!("Price ({}x boards)", NUM_BOARDS);
397383
let mut headers = vec!["Qty"];
398384

399385
if has_availability {
400386
headers.push("Stock");
401387
}
402388

403-
headers.extend(vec![
404-
"Designators",
405-
"MPN",
406-
"Alternatives",
407-
"Manufacturer",
408-
"Package",
409-
]);
389+
headers.extend(vec!["Designators", "MPN", "Manufacturer", "Package"]);
410390

411391
if has_availability {
412392
headers.push("LCSC");
413-
headers.push("Price (20x)");
393+
headers.push(&price_header);
414394
}
415395

416396
headers.push("Description");

0 commit comments

Comments
 (0)