diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6b5d8cb..1bb1854 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(uv run *)", - "Bash(uv sync *)" + "Bash(uv sync *)", + "Bash(git stash *)" ] } } diff --git a/app/crud/iso_thread.py b/app/crud/iso_thread.py index cfd19f4..2d5341d 100644 --- a/app/crud/iso_thread.py +++ b/app/crud/iso_thread.py @@ -8,6 +8,26 @@ async def get(db: AsyncSession, iso_thread_id: int) -> IsoThread | None: return await db.get(IsoThread, iso_thread_id) +async def get_by_values( + db: AsyncSession, + *, + diameter_mm: float, + pitch_mm: float, + depth_mm: float | None, +) -> IsoThread | None: + depth_clause = ( + IsoThread.depth_mm.is_(None) if depth_mm is None else IsoThread.depth_mm == depth_mm + ) + result = await db.execute( + select(IsoThread).where( + IsoThread.diameter_mm == diameter_mm, + IsoThread.pitch_mm == pitch_mm, + depth_clause, + ) + ) + return result.scalar_one_or_none() + + async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[IsoThread]: result = await db.execute( select(IsoThread).order_by(IsoThread.id).offset(skip).limit(limit) diff --git a/app/crud/unc_thread.py b/app/crud/unc_thread.py index ffe9ef2..41bb55d 100644 --- a/app/crud/unc_thread.py +++ b/app/crud/unc_thread.py @@ -8,6 +8,28 @@ async def get(db: AsyncSession, unc_thread_id: int) -> UncThread | None: return await db.get(UncThread, unc_thread_id) +async def get_by_values( + db: AsyncSession, + *, + diameter_fraction: str, + tpi: int, + series: UncSeries, + depth_in: float | None, +) -> UncThread | None: + depth_clause = ( + UncThread.depth_in.is_(None) if depth_in is None else UncThread.depth_in == depth_in + ) + result = await db.execute( + select(UncThread).where( + UncThread.diameter_fraction == diameter_fraction, + UncThread.tpi == tpi, + UncThread.series == series, + depth_clause, + ) + ) + return result.scalar_one_or_none() + + async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[UncThread]: result = await db.execute( select(UncThread).order_by(UncThread.id).offset(skip).limit(limit) diff --git a/app/services/augmented_part_sync.py b/app/services/augmented_part_sync.py index b7a6b43..027ac1d 100644 --- a/app/services/augmented_part_sync.py +++ b/app/services/augmented_part_sync.py @@ -17,20 +17,149 @@ Phase 2 – create entries for new ImportedParts • Type ← first PartType whose name is a substring of the description (longest name wins; skipped if no match) • SubType ← null + • Attachment point ← always named "Occular"; one of: + - ISO/UNC thread parsed from TBXThreadAttribute (using the + matching ProductMaxThreadDepth mm/in if populated), or + - StaticConnection from ProductODmm/ProductODin (whichever + unit is missing is derived via mm/in conversion) if the + thread attribute is absent or unparseable + Skipped entirely if none of those fields are usable. """ +import re + from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.crud import attachment_point as attachment_point_crud +from app.crud import attachment_point_name as attachment_point_name_crud +from app.crud import iso_thread as iso_thread_crud from app.crud import manufacturer as manufacturer_crud from app.crud import part_colour as part_colour_crud from app.crud import part_type as part_type_crud +from app.crud import static_connection as static_connection_crud +from app.crud import unc_thread as unc_thread_crud from app.models.associations import augmented_part_imported_parts from app.models.augmented_part import AugmentedPart from app.models.imported_part import ImportedPart from app.models.part_type import PartType +from app.models.unc_thread import UncSeries from app.schemas.augmented_part import AugmentedPartSyncResult +ATTACHMENT_POINT_NAME = "Occular" +MM_PER_INCH = 25.4 + +_ISO_THREAD_RE = re.compile( + r"^M\s*(?P\d+(?:\.\d+)?)\s*[xX]\s*(?P\d+(?:\.\d+)?)$" +) +_UNC_THREAD_RE = re.compile( + r"^(?P\d+(?:\.\d+)?(?:/\d+)?)\s*-\s*(?P\d+)" + r"\s*(?PUNEF|UNC|UNF|UNS|UN)?$", + re.IGNORECASE, +) + + +def _parse_float(value: str | None) -> float | None: + if value is None: + return None + value = value.strip() + if not value: + return None + try: + return float(value) + except ValueError: + return None + + +def _parse_thread_attribute(value: str) -> tuple[str, dict] | None: + """Parse a TBXThreadAttribute string as an ISO metric or UNC/UNF thread spec.""" + m = _ISO_THREAD_RE.match(value) + if m: + return "iso", { + "diameter_mm": float(m.group("diameter")), + "pitch_mm": float(m.group("pitch")), + } + + m = _UNC_THREAD_RE.match(value) + if m: + # Source data often omits the series letters; UNS ("unified special") is the + # correct designation when the diameter/TPI pairing isn't a standard pitch. + series = UncSeries((m.group("series") or "UNS").upper()) + return "unc", { + "diameter_fraction": m.group("diameter"), + "tpi": int(m.group("tpi")), + "series": series, + } + + return None + + +async def _get_or_create_attachment_point_name(db: AsyncSession, name: str): + apn = await attachment_point_name_crud.get_by_name(db, name) + if apn is None: + apn = await attachment_point_name_crud.create(db, name=name) + return apn + + +async def _add_attachment_point(db: AsyncSession, ap: AugmentedPart, ip: ImportedPart) -> None: + """Infer and create an attachment point for a newly-synced AugmentedPart. + + Preference order: TBXThreadAttribute (ISO/UNC thread) then ProductODmm/in + (StaticConnection). Falls through to the OD-based connection if the thread + attribute is present but unparseable. + """ + thread_value = (ip.TBXThreadAttribute or "").strip() + parsed = _parse_thread_attribute(thread_value) if thread_value else None + + if parsed is not None: + kind, fields = parsed + apn = await _get_or_create_attachment_point_name(db, ATTACHMENT_POINT_NAME) + + if kind == "iso": + depth_mm = _parse_float(ip.ProductMaxThreadDepthmm) + thread = await iso_thread_crud.get_by_values(db, depth_mm=depth_mm, **fields) + if thread is None: + thread = await iso_thread_crud.create(db, depth_mm=depth_mm, **fields) + await attachment_point_crud.create( + db, + augmented_part_id=ap.id, + attachment_point_name_id=apn.id, + iso_thread_id=thread.id, + ) + else: + depth_in = _parse_float(ip.ProductMaxThreadDepthin) + thread = await unc_thread_crud.get_by_values(db, depth_in=depth_in, **fields) + if thread is None: + thread = await unc_thread_crud.create(db, depth_in=depth_in, **fields) + await attachment_point_crud.create( + db, + augmented_part_id=ap.id, + attachment_point_name_id=apn.id, + unc_thread_id=thread.id, + ) + return + + od_mm = _parse_float(ip.ProductODmm) + od_in = _parse_float(ip.ProductODin) + if od_mm is None and od_in is None: + return + if od_mm is None: + od_mm = od_in * MM_PER_INCH + elif od_in is None: + od_in = od_mm / MM_PER_INCH + + apn = await _get_or_create_attachment_point_name(db, ATTACHMENT_POINT_NAME) + sc = await static_connection_crud.get_by_od_mm(db, od_mm) + if sc is None: + sc = await static_connection_crud.create(db, od_mm=od_mm, od_in=od_in) + + await attachment_point_crud.create( + db, + augmented_part_id=ap.id, + attachment_point_name_id=apn.id, + static_connection_id=sc.id, + ) + async def sync_one( db: AsyncSession, @@ -73,6 +202,7 @@ async def sync_one( imported_part_id=ip.InventoryID, ) ) + await _add_attachment_point(db, ap, ip) await db.commit() return ap @@ -196,6 +326,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult: imported_part_id=ip.InventoryID, ) ) + await _add_attachment_point(db, ap, ip) created += 1 await db.commit()