Adding Manual connection point to allow undeclared fits
This commit is contained in:
1 parent
7e246be39c
commit
b4dcb68bea
15 files changed
+227
-23
No files matched your search
@@ -27,7 +27,7 @@ def _connection_label(ap) -> str:
|
||||
return f"{cc.min_in}–{cc.max_in} in"
|
||||
if ap.static_connection:
|
||||
return f"{ap.static_connection.od_mm} mm OD"
|
||||
return "—"
|
||||
return "Manual"
|
||||
|
||||
|
||||
@router.get("", response_model=list[AttachmentPointRead])
|
||||
@@ -54,10 +54,14 @@ async def create_attachment_point(
|
||||
payload.clamp_connection_id,
|
||||
payload.static_connection_id,
|
||||
]
|
||||
if sum(v is not None for v in conn_ids) != 1:
|
||||
if sum(v is not None for v in conn_ids) > 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Exactly one connection (iso_thread, unc_thread, clamp_connection, or static_connection) must be provided.",
|
||||
detail=(
|
||||
"At most one connection (iso_thread, unc_thread, clamp_connection, "
|
||||
"or static_connection) may be provided. Leave all unset for a "
|
||||
"manual attachment point."
|
||||
),
|
||||
)
|
||||
ap = await attachment_point_crud.create(db, **payload.model_dump())
|
||||
await audit_crud.log(
|
||||
|
||||
@@ -21,7 +21,10 @@ class AttachmentPoint(Base):
|
||||
ForeignKey("attachment_point_names.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
|
||||
# Exactly one of the four below must be non-null (enforced in application layer)
|
||||
# At most one of the four below is non-null (enforced in application layer).
|
||||
# All four null means a "Manual" attachment point: no connection details,
|
||||
# used to let a user assert a Fit by hand without the fit-matching algorithm
|
||||
# (app.services.fit_matching) ever auto-matching it.
|
||||
iso_thread_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None
|
||||
)
|
||||
|
||||
@@ -76,7 +76,12 @@ def _static_clamp_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
|
||||
|
||||
|
||||
def attachment_points_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
|
||||
"""True if these two attachment points satisfy the fit-matching algorithm."""
|
||||
"""True if these two attachment points satisfy the fit-matching algorithm.
|
||||
|
||||
A "Manual" attachment point (all four connections null) always returns
|
||||
False here — it never participates in algorithmic matching, only in
|
||||
fits a user asserts by hand.
|
||||
"""
|
||||
# Disabled for now, the attachment_point_names should not be considered for the purposes of matching
|
||||
# if ap_a.attachment_point_name_id == ap_b.attachment_point_name_id:
|
||||
# return False
|
||||
|
||||
@@ -27,9 +27,13 @@ Fits are created with label POSSIBLE. The referenced part is looked up by
|
||||
``product_number`` and linked to its first attachment point (chosen answer:
|
||||
"sole/first AP"). If the referenced part has no attachment points at all, one
|
||||
is created for it that mirrors the scope's attachment point's connection, so
|
||||
the fit has something valid to attach to. Both directions are resolved in a
|
||||
second pass so a fit between two scopes in the same spreadsheet resolves
|
||||
regardless of row order.
|
||||
the fit has something valid to attach to. If the scope itself has no Ocular/
|
||||
Objective attachment point (no ocular_outer_diameter_mm/objective_thread, or
|
||||
an unparseable one), a Manual attachment point — no connection details, never
|
||||
picked up by the fit-matching algorithm — is created on the scope under the
|
||||
appropriate label so the fit still has somewhere to attach. Both directions
|
||||
are resolved in a second pass so a fit between two scopes in the same
|
||||
spreadsheet resolves regardless of row order.
|
||||
|
||||
Bad rows (missing key, unknown fit target, unparseable thread) are skipped and
|
||||
reported per-row; good rows are committed as they go.
|
||||
@@ -207,6 +211,36 @@ async def _mirrored_ap(db: AsyncSession, *, augmented_part_id: int, scope_ap):
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_manual_scope_ap(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
scope_ap,
|
||||
augmented_part_id: int,
|
||||
label: str,
|
||||
messages: list[str],
|
||||
):
|
||||
"""Return ``scope_ap``, creating a Manual attachment point named ``label``
|
||||
on the scope if it's None.
|
||||
|
||||
Used so an ocular_fit/objective_fit reference still has something to link
|
||||
from even when the scope has no ocular_outer_diameter_mm/objective_thread
|
||||
of its own.
|
||||
"""
|
||||
if scope_ap is not None:
|
||||
return scope_ap
|
||||
apn = await apn_crud.get_by_name(db, label)
|
||||
if apn is None:
|
||||
apn = await apn_crud.create(db, name=label)
|
||||
messages.append(
|
||||
f"scope has no {label.lower()} attachment point; created a manual one to record the fit"
|
||||
)
|
||||
return await attachment_point_crud.create(
|
||||
db,
|
||||
augmented_part_id=augmented_part_id,
|
||||
attachment_point_name_id=apn.id,
|
||||
)
|
||||
|
||||
|
||||
async def _link_fit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -218,14 +252,11 @@ async def _link_fit(
|
||||
) -> None:
|
||||
"""Create a POSSIBLE fit from the scope's AP to the referenced part's first AP.
|
||||
|
||||
If the referenced part has no attachment points at all, one is created
|
||||
mirroring the scope's attachment point's connection.
|
||||
``scope_ap`` must already exist — callers ensure this via
|
||||
``_ensure_manual_scope_ap`` before calling. If the referenced part has no
|
||||
attachment points at all, one is created mirroring the scope's attachment
|
||||
point's connection.
|
||||
"""
|
||||
if scope_ap is None:
|
||||
messages.append(
|
||||
f"{scope_role}_fit '{target_sku}' skipped: scope has no {scope_role} attachment point"
|
||||
)
|
||||
return
|
||||
target = await augmented_part_crud.get_by_sku(db, target_sku)
|
||||
if target is None:
|
||||
messages.append(f"{scope_role}_fit references unknown product '{target_sku}'")
|
||||
@@ -406,6 +437,13 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
|
||||
try:
|
||||
ocular_fit = _text(row.get("ocular_fit"))
|
||||
if ocular_fit:
|
||||
ocular_ap = await _ensure_manual_scope_ap(
|
||||
db,
|
||||
scope_ap=ocular_ap,
|
||||
augmented_part_id=part_id,
|
||||
label=OCULAR_LABEL,
|
||||
messages=result.messages,
|
||||
)
|
||||
fits = ocular_fit
|
||||
if "/" in fits:
|
||||
fits = [x.strip() for x in fits.split("/")]
|
||||
@@ -422,6 +460,13 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
|
||||
)
|
||||
objective_fit = _text(row.get("objective_fit"))
|
||||
if objective_fit:
|
||||
objective_ap = await _ensure_manual_scope_ap(
|
||||
db,
|
||||
scope_ap=objective_ap,
|
||||
augmented_part_id=part_id,
|
||||
label=OBJECTIVE_LABEL,
|
||||
messages=result.messages,
|
||||
)
|
||||
fits = objective_fit
|
||||
if "/" in fits:
|
||||
fits = [x.strip() for x in fits.split("/")]
|
||||
|
||||
@@ -48,4 +48,6 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% elif connection_type == "manual" %}
|
||||
<p>No connection details — fits for this attachment point must be asserted manually.</p>
|
||||
{% endif %}
|
||||
@@ -15,7 +15,7 @@
|
||||
{% set conn_type = "Static Connection" %}
|
||||
{% set conn_label = ap.static_connection.od_mm ~ " mm OD" %}
|
||||
{% else %}
|
||||
{% set conn_type = "—" %}
|
||||
{% set conn_type = "Manual" %}
|
||||
{% set conn_label = "—" %}
|
||||
{% endif %}
|
||||
<tr id="attachment-point-{{ ap.id }}">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% elif ap.unc_thread_id %}{% set current_type = "unc_thread" %}
|
||||
{% elif ap.clamp_connection_id %}{% set current_type = "clamp_connection" %}
|
||||
{% elif ap.static_connection_id %}{% set current_type = "static_connection" %}
|
||||
{% else %}{% set current_type = "" %}{% endif %}
|
||||
{% else %}{% set current_type = "manual" %}{% endif %}
|
||||
<tr id="attachment-point-{{ ap.id }}">
|
||||
<td colspan="6">
|
||||
<form hx-post="/admin/attachment-points/{{ ap.id }}"
|
||||
@@ -41,6 +41,7 @@
|
||||
<option value="unc_thread" {% if current_type == "unc_thread" %}selected{% endif %}>UNC Thread</option>
|
||||
<option value="clamp_connection" {% if current_type == "clamp_connection" %}selected{% endif %}>Clamp Connection</option>
|
||||
<option value="static_connection" {% if current_type == "static_connection" %}selected{% endif %}>Static Connection</option>
|
||||
<option value="manual" {% if current_type == "manual" %}selected{% endif %}>Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
{# Render the current connection select pre-filled #}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{% set conn_type = "Static" %}
|
||||
{% set conn_label = apoint.static_connection.od_mm ~ " mm OD" %}
|
||||
{% else %}
|
||||
{% set conn_type = "—" %}
|
||||
{% set conn_type = "Manual" %}
|
||||
{% set conn_label = "—" %}
|
||||
{% endif %}
|
||||
<div id="augmented-part-ap-{{ apoint.id }}" class="ap-item">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% elif apoint.unc_thread_id %}{% set current_type = "unc_thread" %}
|
||||
{% elif apoint.clamp_connection_id %}{% set current_type = "clamp_connection" %}
|
||||
{% elif apoint.static_connection_id %}{% set current_type = "static_connection" %}
|
||||
{% else %}{% set current_type = "" %}{% endif %}
|
||||
{% else %}{% set current_type = "manual" %}{% endif %}
|
||||
{# _attachment_point_connection_select.html expects `ap` for pre-selection #}
|
||||
{% set ap = apoint %}
|
||||
{% set connection_type = current_type %}
|
||||
@@ -29,6 +29,7 @@
|
||||
<option value="unc_thread" {{ "selected" if current_type == "unc_thread" }}>UNC Thread</option>
|
||||
<option value="clamp_connection" {{ "selected" if current_type == "clamp_connection" }}>Clamp Connection</option>
|
||||
<option value="static_connection" {{ "selected" if current_type == "static_connection" }}>Static Connection</option>
|
||||
<option value="manual" {{ "selected" if current_type == "manual" }}>Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="edit-ap-{{ apoint.id }}-connection-options">
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<option value="unc_thread">UNC Thread</option>
|
||||
<option value="clamp_connection">Clamp Connection</option>
|
||||
<option value="static_connection">Static Connection</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="ap-{{ augmented_part_id }}-add-connection-options"></div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{%- elif ap.static_connection -%}
|
||||
Static {{ ap.static_connection.od_mm }} mm OD
|
||||
{%- else -%}
|
||||
—
|
||||
Manual
|
||||
{%- endif -%}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<option value="unc_thread">UNC Thread</option>
|
||||
<option value="clamp_connection">Clamp Connection</option>
|
||||
<option value="static_connection">Static Connection</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="add-connection-options"></div>
|
||||
|
||||
+3
-3
@@ -2387,7 +2387,7 @@ async def edit_augmented_part_attachment_point(
|
||||
elif apoint.static_connection_id:
|
||||
ctx["connection_type"] = "static_connection"
|
||||
else:
|
||||
ctx["connection_type"] = ""
|
||||
ctx["connection_type"] = "manual"
|
||||
return templates.TemplateResponse(
|
||||
request, "admin/_augmented_part_ap_row_edit.html", ctx
|
||||
)
|
||||
@@ -3242,7 +3242,7 @@ def _ap_connection_label(ap) -> str:
|
||||
return f"{cc.min_in}–{cc.max_in} in"
|
||||
if ap.static_connection:
|
||||
return f"{ap.static_connection.od_mm} mm OD"
|
||||
return "—"
|
||||
return "Manual"
|
||||
|
||||
|
||||
@router.get("/attachment-points", response_class=HTMLResponse)
|
||||
@@ -3339,7 +3339,7 @@ async def edit_attachment_point_row(
|
||||
elif ap.static_connection_id:
|
||||
ctx["connection_type"] = "static_connection"
|
||||
else:
|
||||
ctx["connection_type"] = ""
|
||||
ctx["connection_type"] = "manual"
|
||||
return templates.TemplateResponse(
|
||||
request, "admin/_attachment_point_row_edit.html", ctx
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user