Adding UAC Diameter Range MM to augmented parts
This commit is contained in:
1 parent
597cf68157
commit
d23f5b9e82
7 files changed
+263
-2
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
[ ] Drop 'id' from UI tables, add 'date added / updated'
|
[ ] Drop 'id' from UI tables, add 'date added / updated'
|
||||||
[ ] Adapter Rings should get two attachment points
|
[x] Adapter Rings should get two attachment points
|
||||||
[ ] Add 'on_website' boolean to augmented part
|
[ ] Add 'on_website' boolean to augmented part
|
||||||
[ ] API: allow updating parts by ID or by SKU
|
[x] API: allow updating parts by ID or by SKU
|
||||||
[ ] Add `preferred_part` to augmented parts, used to order matches (e.g. double tab vs single tab)
|
[ ] Add `preferred_part` to augmented parts, used to order matches (e.g. double tab vs single tab)
|
||||||
[ ] Adapter Rings
|
[ ] Adapter Rings
|
||||||
[ ] Should be married to a specific flip cover (a, b), not (a)x(b) cartesian product
|
[ ] Should be married to a specific flip cover (a, b), not (a)x(b) cartesian product
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""augmented_parts_uac_diameter_range
|
||||||
|
|
||||||
|
Revision ID: 3c4d5e6f7a8b
|
||||||
|
Revises: 2b3c4d5e6f7a
|
||||||
|
Create Date: 2026-09-21 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "3c4d5e6f7a8b"
|
||||||
|
down_revision: str | None = "2b3c4d5e6f7a"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("uac_diameter_range_mm", sa.String(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("uac_diameter_range_mm")
|
||||||
@@ -22,6 +22,17 @@ async def list_all(
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_values(
|
||||||
|
db: AsyncSession, *, min_mm: float | None, max_mm: float | None
|
||||||
|
) -> ClampConnection | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ClampConnection).where(
|
||||||
|
ClampConnection.min_mm == min_mm, ClampConnection.max_mm == max_mm
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
async def create(
|
async def create(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ class AugmentedPart(Base):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
|
# "##.##-##.##" mm, derived from ImportedPart.UACDiameterRangemm by the sync
|
||||||
|
# service. Read-only — never set outside app/services/augmented_part_sync.py.
|
||||||
|
uac_diameter_range_mm: Mapped[str | None] = mapped_column(
|
||||||
|
String, nullable=True, default=None
|
||||||
|
)
|
||||||
# Set instead of deleting the row when the last backing ImportedPart is
|
# Set instead of deleting the row when the last backing ImportedPart is
|
||||||
# removed, so fit/attachment-point history is preserved. Hidden from list
|
# removed, so fit/attachment-point history is preserved. Hidden from list
|
||||||
# views by default.
|
# views by default.
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class AugmentedPartRead(AugmentedPartBase):
|
|||||||
|
|
||||||
id: int
|
id: int
|
||||||
archived: bool
|
archived: bool
|
||||||
|
uac_diameter_range_mm: str | None
|
||||||
colour: PartColourRead
|
colour: PartColourRead
|
||||||
manufacturer: ManufacturerRead
|
manufacturer: ManufacturerRead
|
||||||
scope_model: ScopeModelRead | None
|
scope_model: ScopeModelRead | None
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ Phase 2 – create entries for new ImportedParts
|
|||||||
unit is missing is derived via mm/in conversion) if the
|
unit is missing is derived via mm/in conversion) if the
|
||||||
thread attribute is absent or unparseable
|
thread attribute is absent or unparseable
|
||||||
Skipped entirely if none of those fields are usable.
|
Skipped entirely if none of those fields are usable.
|
||||||
|
UAC diameter range ← ImportedPart.UACDiameterRangemm, an 8-digit string with
|
||||||
|
no separators (2 leading digits + 2 decimal digits for the
|
||||||
|
minimum, then the same for the maximum) reformatted as
|
||||||
|
AugmentedPart.uac_diameter_range_mm = "##.##-##.##". When
|
||||||
|
present, also adds a second attachment point named
|
||||||
|
"Objective" with a ClampConnection spanning that range.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -49,6 +55,7 @@ from app.models.unc_thread import UncSeries
|
|||||||
from app.schemas.augmented_part import AugmentedPartSyncResult
|
from app.schemas.augmented_part import AugmentedPartSyncResult
|
||||||
|
|
||||||
ATTACHMENT_POINT_NAME = "Ocular"
|
ATTACHMENT_POINT_NAME = "Ocular"
|
||||||
|
UAC_ATTACHMENT_POINT_NAME = "Objective"
|
||||||
MM_PER_INCH = 25.4
|
MM_PER_INCH = 25.4
|
||||||
|
|
||||||
# e.g.
|
# e.g.
|
||||||
@@ -118,6 +125,49 @@ def _parse_thread_attribute(value: str) -> tuple[str, dict] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_uac_diameter_range(value: str | None) -> tuple[str, float, float] | None:
|
||||||
|
"""Parse an 8-digit UACDiameterRangemm value into ("##.##-##.##", min, max).
|
||||||
|
|
||||||
|
The source field is 8 digits with no separators: the first 4 are the
|
||||||
|
minimum diameter (2 whole + 2 decimal), the last 4 the maximum, e.g.
|
||||||
|
"03505500" -> ("03.50-55.00", 3.5, 55.0).
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
digits = "".join(re.findall(r"\d", value))
|
||||||
|
if len(digits) != 8:
|
||||||
|
return None
|
||||||
|
min_str = f"{digits[0:2]}.{digits[2:4]}"
|
||||||
|
max_str = f"{digits[4:6]}.{digits[6:8]}"
|
||||||
|
return f"{min_str}-{max_str}", float(min_str), float(max_str)
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_uac_clamp_attachment_point(
|
||||||
|
db: AsyncSession, ap: AugmentedPart, ip: ImportedPart
|
||||||
|
) -> None:
|
||||||
|
"""Add a second, "Objective"-named clamp attachment point derived from
|
||||||
|
ImportedPart.UACDiameterRangemm, if that field is populated."""
|
||||||
|
parsed = _parse_uac_diameter_range(ip.UACDiameterRangemm)
|
||||||
|
if parsed is None:
|
||||||
|
return
|
||||||
|
_, min_mm, max_mm = parsed
|
||||||
|
min_in = min_mm / MM_PER_INCH
|
||||||
|
max_in = max_mm / MM_PER_INCH
|
||||||
|
|
||||||
|
apn = await _get_or_create_attachment_point_name(db, UAC_ATTACHMENT_POINT_NAME)
|
||||||
|
clamp = await clamp_connection_crud.get_by_values(db, min_mm=min_mm, max_mm=max_mm)
|
||||||
|
if clamp is None:
|
||||||
|
clamp = await clamp_connection_crud.create(
|
||||||
|
db, min_mm=min_mm, max_mm=max_mm, min_in=min_in, max_in=max_in
|
||||||
|
)
|
||||||
|
await attachment_point_crud.create(
|
||||||
|
db,
|
||||||
|
augmented_part_id=ap.id,
|
||||||
|
attachment_point_name_id=apn.id,
|
||||||
|
clamp_connection_id=clamp.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_attachment_point_name(db: AsyncSession, name: str):
|
async def _get_or_create_attachment_point_name(db: AsyncSession, name: str):
|
||||||
apn = await attachment_point_name_crud.get_by_name(db, name)
|
apn = await attachment_point_name_crud.get_by_name(db, name)
|
||||||
if apn is None:
|
if apn is None:
|
||||||
@@ -233,6 +283,8 @@ async def sync_one(
|
|||||||
if manufacturer is None:
|
if manufacturer is None:
|
||||||
manufacturer = await manufacturer_crud.create(db, name="Armament", url="")
|
manufacturer = await manufacturer_crud.create(db, name="Armament", url="")
|
||||||
|
|
||||||
|
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
|
||||||
|
|
||||||
ap = AugmentedPart(
|
ap = AugmentedPart(
|
||||||
sku=ip.InventoryID,
|
sku=ip.InventoryID,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -241,6 +293,7 @@ async def sync_one(
|
|||||||
scope_model_id=None,
|
scope_model_id=None,
|
||||||
part_type_id=part_type_id,
|
part_type_id=part_type_id,
|
||||||
sub_part_type_id=sub_part_type_id,
|
sub_part_type_id=sub_part_type_id,
|
||||||
|
uac_diameter_range_mm=uac_range[0] if uac_range else None,
|
||||||
)
|
)
|
||||||
db.add(ap)
|
db.add(ap)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -252,6 +305,7 @@ async def sync_one(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await _add_attachment_point(db, ap, ip)
|
await _add_attachment_point(db, ap, ip)
|
||||||
|
await _add_uac_clamp_attachment_point(db, ap, ip)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return ap
|
return ap
|
||||||
|
|
||||||
@@ -416,6 +470,8 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
|||||||
if colour is None:
|
if colour is None:
|
||||||
colour = await part_colour_crud.create(db, name=colour_name)
|
colour = await part_colour_crud.create(db, name=colour_name)
|
||||||
|
|
||||||
|
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
|
||||||
|
|
||||||
ap = AugmentedPart(
|
ap = AugmentedPart(
|
||||||
sku=ip.InventoryID,
|
sku=ip.InventoryID,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -424,6 +480,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
|||||||
scope_model_id=None,
|
scope_model_id=None,
|
||||||
part_type_id=part_type.id,
|
part_type_id=part_type.id,
|
||||||
sub_part_type_id=None,
|
sub_part_type_id=None,
|
||||||
|
uac_diameter_range_mm=uac_range[0] if uac_range else None,
|
||||||
)
|
)
|
||||||
db.add(ap)
|
db.add(ap)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -435,6 +492,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await _add_attachment_point(db, ap, ip)
|
await _add_attachment_point(db, ap, ip)
|
||||||
|
await _add_uac_clamp_attachment_point(db, ap, ip)
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""UACDiameterRangemm handling in the AugmentedPart sync service: the
|
||||||
|
reformatted range field on AugmentedPart, and the "Objective" clamp
|
||||||
|
attachment point it should produce."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.crud import attachment_point as attachment_point_crud
|
||||||
|
from app.crud import clamp_connection as clamp_connection_crud
|
||||||
|
from app.models.augmented_part import AugmentedPart
|
||||||
|
from app.models.imported_part import ImportedPart
|
||||||
|
from app.models.manufacturer import Manufacturer
|
||||||
|
from app.models.part_colour import PartColour
|
||||||
|
from app.models.part_type import PartType
|
||||||
|
from app.services import augmented_part_sync
|
||||||
|
from app.services.augmented_part_sync import _parse_uac_diameter_range
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_uac_diameter_range_valid_8_digits():
|
||||||
|
assert _parse_uac_diameter_range("03505500") == ("03.50-55.00", 3.5, 55.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [None, "", "1234567", "123456789", "abcdefgh"])
|
||||||
|
def test_parse_uac_diameter_range_rejects_non_8_digit(value):
|
||||||
|
assert _parse_uac_diameter_range(value) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_uac_diameter_range_strips_non_digit_separators():
|
||||||
|
# Defensive: pull digits out even if the feed adds punctuation.
|
||||||
|
assert _parse_uac_diameter_range("0350-5500") == ("03.50-55.00", 3.5, 55.0)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_reference_data(db):
|
||||||
|
manufacturer = Manufacturer(name="Acme", url="https://acme.test")
|
||||||
|
colour = PartColour(name="Black")
|
||||||
|
part_type = PartType(name="Scope")
|
||||||
|
db.add_all([manufacturer, colour, part_type])
|
||||||
|
await db.flush()
|
||||||
|
return manufacturer, colour, part_type
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_populates_range_and_creates_objective_clamp_point(
|
||||||
|
db_sessionmaker,
|
||||||
|
):
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
await _seed_reference_data(db)
|
||||||
|
db.add(
|
||||||
|
ImportedPart(
|
||||||
|
InventoryID="INV-UAC-1",
|
||||||
|
Description="Scope widget",
|
||||||
|
UACDiameterRangemm="03505500",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
result = await augmented_part_sync.sync(db)
|
||||||
|
assert result.created == 1
|
||||||
|
|
||||||
|
ap = (
|
||||||
|
await db.execute(
|
||||||
|
select(AugmentedPart).where(AugmentedPart.sku == "INV-UAC-1")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert ap.uac_diameter_range_mm == "03.50-55.00"
|
||||||
|
|
||||||
|
points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
|
||||||
|
objective_points = [
|
||||||
|
p for p in points if p.attachment_point_name.name == "Objective"
|
||||||
|
]
|
||||||
|
assert len(objective_points) == 1
|
||||||
|
clamp = await clamp_connection_crud.get(
|
||||||
|
db, objective_points[0].clamp_connection_id
|
||||||
|
)
|
||||||
|
assert clamp.min_mm == 3.5
|
||||||
|
assert clamp.max_mm == 55.0
|
||||||
|
assert clamp.min_in == pytest.approx(3.5 / 25.4)
|
||||||
|
assert clamp.max_in == pytest.approx(55.0 / 25.4)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_leaves_range_and_objective_point_unset_when_field_blank(
|
||||||
|
db_sessionmaker,
|
||||||
|
):
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
await _seed_reference_data(db)
|
||||||
|
db.add(ImportedPart(InventoryID="INV-NO-UAC", Description="Scope widget"))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
result = await augmented_part_sync.sync(db)
|
||||||
|
assert result.created == 1
|
||||||
|
|
||||||
|
ap = (
|
||||||
|
await db.execute(
|
||||||
|
select(AugmentedPart).where(AugmentedPart.sku == "INV-NO-UAC")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert ap.uac_diameter_range_mm is None
|
||||||
|
|
||||||
|
points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
|
||||||
|
assert all(p.attachment_point_name.name != "Objective" for p in points)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_reuses_existing_clamp_connection_for_same_range(db_sessionmaker):
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
await _seed_reference_data(db)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
ImportedPart(
|
||||||
|
InventoryID="INV-UAC-A",
|
||||||
|
Description="Scope widget A",
|
||||||
|
UACDiameterRangemm="03505500",
|
||||||
|
),
|
||||||
|
ImportedPart(
|
||||||
|
InventoryID="INV-UAC-B",
|
||||||
|
Description="Scope widget B",
|
||||||
|
UACDiameterRangemm="03505500",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
result = await augmented_part_sync.sync(db)
|
||||||
|
assert result.created == 2
|
||||||
|
|
||||||
|
clamps = await clamp_connection_crud.list_all(db)
|
||||||
|
assert len(clamps) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_one_populates_range_and_objective_point(db_sessionmaker):
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
manufacturer, colour, part_type = await _seed_reference_data(db)
|
||||||
|
db.add(
|
||||||
|
ImportedPart(
|
||||||
|
InventoryID="INV-UAC-MANUAL",
|
||||||
|
Description="Odd widget",
|
||||||
|
UACDiameterRangemm="10002000",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
ap = await augmented_part_sync.sync_one(
|
||||||
|
db,
|
||||||
|
inventory_id="INV-UAC-MANUAL",
|
||||||
|
part_type_id=part_type.id,
|
||||||
|
sub_part_type_id=None,
|
||||||
|
)
|
||||||
|
assert ap.uac_diameter_range_mm == "10.00-20.00"
|
||||||
|
|
||||||
|
points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
|
||||||
|
objective_points = [
|
||||||
|
p for p in points if p.attachment_point_name.name == "Objective"
|
||||||
|
]
|
||||||
|
assert len(objective_points) == 1
|
||||||
Reference in new issue
Block a user