diff --git a/src/opal/api/routes/execution.py b/src/opal/api/routes/execution.py index 8270c20..547e5ee 100644 --- a/src/opal/api/routes/execution.py +++ b/src/opal/api/routes/execution.py @@ -1013,6 +1013,7 @@ class KitAvailabilityItem(BaseModel): part_id: int part_name: str + uom: str | None = None quantity_required: float quantity_available: float is_available: bool @@ -1090,12 +1091,14 @@ def check_kit_availability( KitAvailabilityItem( part_id=kit_item.part_id, part_name=kit_item.part.name, + uom=kit_item.part.unit_of_measure, quantity_required=qty_required, quantity_available=total_available, is_available=is_available, available_locations=[ { "inventory_record_id": r.id, + "opal_number": r.opal_number, "location": r.location, "lot_number": r.lot_number, "quantity": float(r.quantity), diff --git a/src/opal/api/routes/inventory.py b/src/opal/api/routes/inventory.py index ddeda1c..6e2b1d2 100644 --- a/src/opal/api/routes/inventory.py +++ b/src/opal/api/routes/inventory.py @@ -78,6 +78,7 @@ class InventoryResponse(BaseModel): part_id: int part_name: str part_external_pn: str | None + part_uom: str | None = None quantity: Decimal location: str lot_number: str | None @@ -149,6 +150,7 @@ def inventory_to_response(record: InventoryRecord) -> InventoryResponse: part_id=record.part_id, part_name=record.part.name, part_external_pn=record.part.external_pn, + part_uom=record.part.unit_of_measure, quantity=record.quantity, location=record.location, lot_number=record.lot_number, diff --git a/src/opal/web/routes.py b/src/opal/web/routes.py index 10c8c11..046e27e 100644 --- a/src/opal/web/routes.py +++ b/src/opal/web/routes.py @@ -1142,11 +1142,19 @@ def inventory_opal_detail( opal_number: str, ) -> HTMLResponse: """OPAL item detail page with full traceability history.""" - from opal.db.models.inventory import InventoryProduction + from sqlalchemy.orm import joinedload, selectinload + + from opal.db.models.inventory import InventoryConsumption, InventoryProduction from opal.db.models.purchase import PurchaseLine record = ( db.query(InventoryRecord) + .options( + joinedload(InventoryRecord.part), + selectinload(InventoryRecord.consumptions).joinedload( + InventoryConsumption.procedure_instance + ), + ) .join(Part) .filter(InventoryRecord.opal_number == opal_number, Part.deleted_at.is_(None)) .first() @@ -1212,6 +1220,9 @@ def inventory_opal_detail( if hasattr(c.usage_type, "value") else c.usage_type, "procedure_instance_id": c.procedure_instance_id, + "work_order_number": c.procedure_instance.work_order_number + if c.procedure_instance + else None, "notes": c.notes, }, } @@ -2168,7 +2179,14 @@ def _se_status(se): context["version_steps_map"] = {s["order"]: s for s in version_steps} # Get kit information - kit_items = db.query(Kit).filter(Kit.procedure_id == instance.procedure_id).all() + from sqlalchemy.orm import joinedload + + kit_items = ( + db.query(Kit) + .options(joinedload(Kit.part)) + .filter(Kit.procedure_id == instance.procedure_id) + .all() + ) context["kit_items"] = kit_items # Get existing consumptions @@ -2180,6 +2198,9 @@ def _se_status(se): consumptions = ( db.query(InventoryConsumption) + .options( + joinedload(InventoryConsumption.inventory_record).joinedload(InventoryRecord.part) + ) .filter(InventoryConsumption.procedure_instance_id == instance.id) .all() ) @@ -2209,6 +2230,9 @@ def _se_status(se): # Get existing productions productions = ( db.query(InventoryProduction) + .options( + joinedload(InventoryProduction.inventory_record).joinedload(InventoryRecord.part) + ) .filter(InventoryProduction.procedure_instance_id == instance.id) .all() ) @@ -2233,6 +2257,7 @@ def _se_status(se): bom_items.append( { "part_id": k.part_id, + "part_pn": k.part.internal_pn, "part_name": k.part.name, "qty_required": qty_required, "qty_consumed": qty_consumed, @@ -2246,6 +2271,7 @@ def _se_status(se): unplanned.append( { "part_id": pid, + "part_pn": inv_c.inventory_record.part.internal_pn if inv_c else None, "part_name": inv_c.inventory_record.part.name if inv_c else "Unknown", "qty_consumed": qty, } diff --git a/src/opal/web/static/js/execdoc.js b/src/opal/web/static/js/execdoc.js index d6bbd2e..e1c9b43 100644 --- a/src/opal/web/static/js/execdoc.js +++ b/src/opal/web/static/js/execdoc.js @@ -1031,6 +1031,18 @@ // ---------- kit availability (KITTING tab + step kits) ---------- + // The one home for the consume-from option label: the physical-item + // identity first, then where it sits and how much it is. + // "OPAL-00164 · STORE-A2 · 5 EA · LOT-2026-03" (lot only when present). + function invSourceLabel(opalNumber, location, quantity, uom, lotNumber) { + const parts = []; + if (opalNumber) parts.push(opalNumber); + parts.push(location); + parts.push(`${Number(quantity)} ${(uom || 'EA').toUpperCase()}`); + if (lotNumber) parts.push(lotNumber); + return parts.join(' · '); + } + async function loadKitAvailability() { if (!document.getElementById('kit-table')) return; try { @@ -1049,11 +1061,12 @@ if (!item.is_available) availCell.classList.add('text-red'); const select = row.querySelector('.inv-select'); if (!select) continue; - select.innerHTML = ''; + select.innerHTML = ''; for (const loc of item.available_locations) { const opt = document.createElement('option'); opt.value = loc.inventory_record_id; - opt.textContent = `${loc.location} (${loc.quantity.toFixed(4)})${loc.lot_number ? ' - Lot: ' + loc.lot_number : ''}`; + opt.textContent = invSourceLabel( + loc.opal_number, loc.location, loc.quantity, item.uom, loc.lot_number); select.appendChild(opt); } const required = parseFloat(row.dataset.required); @@ -1156,7 +1169,8 @@ if (parseFloat(rec.quantity) <= 0) continue; const opt = document.createElement('option'); opt.value = rec.id; - opt.textContent = `${rec.location} (${parseFloat(rec.quantity).toFixed(4)})${rec.lot_number ? ' - Lot: ' + rec.lot_number : ''}`; + opt.textContent = invSourceLabel( + rec.opal_number, rec.location, rec.quantity, rec.part_uom, rec.lot_number); select.appendChild(opt); } const row = select.closest('tr'); diff --git a/src/opal/web/templates/executions/tabs/bom.html b/src/opal/web/templates/executions/tabs/bom.html index ad70527..793fbc0 100644 --- a/src/opal/web/templates/executions/tabs/bom.html +++ b/src/opal/web/templates/executions/tabs/bom.html @@ -9,7 +9,7 @@ {% for item in bom_items %} - {{ item.part_name }} + {{ item.part_pn }} {{ item.part_name }} {{ item.qty_required }} {{ item.qty_consumed }} {% if item.variance > 0 %}+{% endif %}{{ item.variance }} @@ -29,7 +29,7 @@ {% for item in unplanned_consumptions %} - {{ item.part_name }} + {% if item.part_pn %}{{ item.part_pn }} {{ item.part_name }}{% else %}{{ item.part_name }}{% endif %} {{ item.qty_consumed }} {% endfor %} diff --git a/src/opal/web/templates/executions/tabs/kitting.html b/src/opal/web/templates/executions/tabs/kitting.html index d048ff9..49abf7c 100644 --- a/src/opal/web/templates/executions/tabs/kitting.html +++ b/src/opal/web/templates/executions/tabs/kitting.html @@ -14,23 +14,29 @@
{% if consumptions %} + {# Consumed rows carry the physical-item identity: part number and + OPAL # trace each deduction to the exact stock record it came from. #} - - - - - + + + + + + + {% for c in consumptions %} - + + + - + {% endfor %} @@ -50,7 +56,7 @@ {% for kit in kit_items %} - + - + @@ -149,7 +155,7 @@ {% for out in output_items %} - + diff --git a/src/opal/web/templates/inventory/opal_detail.html b/src/opal/web/templates/inventory/opal_detail.html index 52fb575..07bc163 100644 --- a/src/opal/web/templates/inventory/opal_detail.html +++ b/src/opal/web/templates/inventory/opal_detail.html @@ -8,269 +8,212 @@ {% endblock %} {% block content %} -
+{# ============ THE PAGE — the part-page grammar: a panel with actions in + its header bar, detail rows for facts, headed tables for collections. + TESTS relevance is declared by the part's test templates; data always + renders. ============ #} +
- {{ opal_number }} - Physical Item Traceability + OPAL ITEM
{% if record.part.is_tooling %} - {{ ok.btn("RECORD CALIBRATION", variant="primary", attrs='onclick="recordCalibration()"') }} + {{ ok.btn("RECORD CALIBRATION", variant="primary", size="sm", attrs='onclick="recordCalibration()"') }} {% endif %} - {{ ok.btn("PRINT LABEL", attrs='onclick="window.open(\'/label?type=inventory&id=' ~ record.id ~ '\', \'_blank\', \'width=500,height=300\')"') }} - {{ ok.btn("ADJUST QTY", href="/inventory/" ~ record.id ~ "/adjust") }} + {{ ok.btn("PRINT LABEL", size="sm", attrs='onclick="window.open(\'/label?type=inventory&id=' ~ record.id ~ '\', \'_blank\', \'width=500,height=300\')"') }} + {{ ok.btn("ADJUST QTY", size="sm", href="/inventory/" ~ record.id ~ "/adjust") }} {% if record.quantity > 0 %} - {{ ok.btn("SCRAP", variant="danger", attrs='onclick="showScrapModal()"') }} + {{ ok.btn("SCRAP", variant="danger", size="sm", attrs='onclick="showScrapModal()"') }} {% endif %}
-
QR Code
+
{{ opal_number }}
- -
-
- -
- {{ record.part.name }} - {% if record.part.external_pn %} -
{{ record.part.external_pn }} - {% endif %} -
-
-
- -
{{ ok.mono(record.location) }}
-
-
- -
{{ ok.mono(record.quantity) }}
-
-
- -
{{ ok.mono(record.lot_number) }}
-
-
- -
- {% if record.source_type %} - {% set source = record.source_type.value if record.source_type.value is defined else record.source_type %} - {% if source == 'purchase' and source_po %} - - {{ ok.status("PO-" ~ source_po.number, "info") }} - - {% elif source == 'purchase' %} - {{ ok.status("From PO", "info") }} - {% elif source == 'production' %} - {{ ok.status("Produced", "ok") }} - {% elif source == 'manual' %} - {{ ok.status("Manual Entry", "warn") }} - {% else %} - {{ source }} - {% endif %} + {# Fact readout — detail rows, always rendered; absence is a muted dash #} +
PARTQTYLOCATIONLOTSTEPPARTNAMEOPAL #QTYLOTLOCATIONSTEP
{{ c.inventory_record.part.name }}{{ c.inventory_record.part.internal_pn }}{{ c.inventory_record.part.name }}{% if c.inventory_record.opal_number %}{{ c.inventory_record.opal_number }}{% else %}{% endif %} {{ c.quantity }}{{ c.inventory_record.location }} {{ c.inventory_record.lot_number or '-' }}{{ c.inventory_record.location }} {% if c.step_execution_id %}{{ step_exec_lookup.get(c.step_execution_id, '-') }}{% else %}-{% endif %}
{{ kit.part.name }}{{ kit.part.internal_pn }} {{ kit.part.name }} {{ kit.quantity_required }} - @@ -114,7 +120,7 @@ {% for p in productions %} {% set pstatus = p.status.value if p.status.value is defined else p.status %}
{{ p.inventory_record.part.name }}{{ p.inventory_record.part.internal_pn }} {{ p.inventory_record.part.name }} {{ p.serial_number or '-' }} {% if p.produced_opal_number %}{{ p.produced_opal_number }}{% else %}-{% endif %} {% if pstatus == 'completed' %}{{ p.quantity }}{% else %}({{ p.quantity }}){% endif %}
{{ out.part.name }}{{ out.part.internal_pn }} {{ out.part.name }} {{ out.quantity_produced }}
+ + + + + + + {% set source = record.source_type.value if record.source_type and record.source_type.value is defined else record.source_type %} + + {% if source_wo %} + + {% endif %} + + {% if record.part.is_tooling %} + + + {% endif %} + + +
PART{{ record.part.internal_pn }} {{ record.part.name }}
EXTERNAL PN{% if record.part.external_pn %}{{ record.part.external_pn }}{% else %}{% endif %}
LOCATION{{ record.location }}
QUANTITY{{ '%g' % record.quantity }} {{ (record.part.unit_of_measure or 'EA') | upper }}
LOT{% if record.lot_number %}{{ record.lot_number }}{% else %}{% endif %}
SOURCE + {% if source == 'purchase' and source_po %} + PO-{{ source_po.number }} + {% elif source == 'purchase' %} + PURCHASE + {% elif source == 'production' and source_wo %} + {{ source_wo.work_order_number or 'PRODUCED' }} + {% elif source == 'production' %} + PRODUCED + {% elif source == 'manual' %} + MANUAL + {% elif source %} + {{ source | upper }} {% else %} - Unknown + {% endif %} - - - {% if record.expiration_date %} -
- -
+
SERIAL{% if source_wo.serial_number %}{{ source_wo.serial_number }}{% else %}{% endif %}
EXPIRATION + {% if record.expiration_date %} {{ record.expiration_date }} {% if record.expiration_date < today %} {{ ok.status("EXPIRED", "error") }} {% elif (record.expiration_date - today).days <= 30 %} {{ ok.status("EXPIRING", "warn") }} {% endif %} - - - {% endif %} - {% if record.part.is_tooling %} -
- -
- {% if record.last_calibrated_at %} - {{ ok.timestamp(record.last_calibrated_at) }} {% else %} - Never + {% endif %} -
-
-
- -
+
LAST CALIBRATED{% if record.last_calibrated_at %}{{ ok.timestamp(record.last_calibrated_at) }}{% else %}{% endif %}
CALIBRATION DUE {% if record.calibration_due_at %} {{ ok.timestamp(record.calibration_due_at) }} {% if record.calibration_due_at <= now %} {{ ok.status("OVERDUE", "error") }} {% elif (record.calibration_due_at - now).days <= 30 %} {{ ok.status("CAL DUE", "warn") }} - {% else %} - {{ ok.status("CAL OK", "ok") }} {% endif %} {% else %} - Not set + {% endif %} - - - {% endif %} -
- -
{{ ok.timestamp(record.created_at) }}
-
- - - - {% if source_po %} -
- Source: - PO-{{ source_po.number }} - Line #{{ record.source_purchase_line_id }} -
- {% endif %} +
RECEIVED{{ ok.timestamp(record.created_at) }}
+
+ - -

TRACEABILITY HISTORY

-
+{# --- Traceability history — the receipt and every deduction, newest first --- #} +
+
+ TRACEABILITY HISTORY + {{ history | length }} +
+ + + + {{ ok.th("TIMESTAMP", width="180px") }} + {{ ok.th("EVENT", width="110px") }} + {{ ok.th("QTY", width="80px", align="right") }} + {{ ok.th("CONTEXT") }} + + + {% for event in history %} -
-
- {{ ok.timestamp(event.timestamp) }} -
-
- {% if event.event == 'created' %} - RECEIVED -
- - Quantity: {{ event.details.quantity }} - {% if event.details.source_type == 'purchase' %} - from PO {{ event.details.po_number or '' }} - {% elif event.details.source_type == 'production' %} - (produced) - {% elif event.details.source_type == 'manual' %} - (manual entry) - {% endif %} - - - {% elif event.event == 'consumed' %} - {% if event.details.usage_type == 'scrap' %} - SCRAPPED +
+ + {% if event.event == 'created' %} + + + + {% elif event.event == 'consumed' %} + {% if event.details.usage_type == 'scrap' %} + + + {% elif event.details.usage_type == 'tooling' %} + + + {% else %} + + + {% endif %} + + {% else %} + + + + {% endif %} + {% endfor %} - - {% if not history %} - {{ ok.empty("No history available") }} - {% endif %} - - + +
{{ ok.timestamp(event.timestamp) }}RECEIVED+{{ '%g' % event.details.quantity }} + {% if event.details.source_type == 'purchase' and event.details.po_number %} + PO-{{ event.details.po_number }} + {% elif event.details.source_type == 'production' and source_wo %} + {{ source_wo.work_order_number or 'PRODUCED' }} + {% elif event.details.source_type == 'manual' %} + MANUAL {% else %} - CONSUMED + {% endif %} -
- - Quantity: {{ event.details.quantity }} - {% if event.details.procedure_instance_id %} - in Execution #{{ event.details.procedure_instance_id }} - {% endif %} - {% if event.details.usage_type == 'tooling' %} - (tooling - returned) - {% endif %} - {% if event.details.notes %} -
{{ event.details.notes }} - {% endif %} -
- - {% elif event.event == 'transferred' %} - TRANSFERRED -
- {{ event.details }} - - {% else %} - {{ event.event|upper }} -
- {{ event.details }} +
SCRAPPED-{{ '%g' % event.details.quantity }}TOOLING{{ '%g' % event.details.quantity }}CONSUMED-{{ '%g' % event.details.quantity }} + {% if event.details.procedure_instance_id %} + {{ event.details.work_order_number or 'WORK ORDER' }} {% endif %} - - + {% if event.details.notes %}{{ event.details.notes }} + {% elif not event.details.procedure_instance_id %} + {% endif %} + {{ event.event | upper }}{% if event.details.quantity is defined %}{{ '%g' % event.details.quantity }}{% else %}{% endif %}{{ event.details }}
- -
+{# --- Tests — relevance is declared by the part's test templates; + recorded results always render --- #} +{% if test_results %} +
TESTS - + {{ test_results | length }}
- {{ ok.btn("+ RUN TEST", variant="primary", attrs='onclick="showRunTest()"') }} + + RUN
-
-
- {% if test_results %} - {% call ok.table() %} - - - {{ ok.th("TEST NAME") }} - {{ ok.th("RESULT", width="70px") }} - {{ ok.th("VALUE", width="120px") }} - {{ ok.th("TESTED BY", width="120px") }} - {{ ok.th("TESTED AT", width="160px") }} - {{ ok.th("", width="60px") }} - - - - {% for tr in test_results %} - - {{ tr.test_name }} - - {% set r = tr.result.value if tr.result.value is defined else tr.result %} - {% if r == 'pass' %} - {{ ok.status("PASS", "ok") }} - {% elif r == 'fail' %} - {{ ok.status("FAIL", "error") }} - {% else %} - {{ ok.status("PENDING", "warn") }} - {% endif %} - - {{ ok.td(tr.value or '-', mono=true) }} - {% if tr.tested_by_user %}{{ tr.tested_by_user.display_name or tr.tested_by_user.email }}{% else %}-{% endif %} - {% if tr.tested_at %}{{ ok.timestamp(tr.tested_at) }}{% else %}-{% endif %} - - {{ ok.btn("EDIT", size="sm", attrs='onclick="showEditTest(' ~ tr.id ~ ')"') }} - - - {% endfor %} - - {% endcall %} - {% else %} - {{ ok.empty("No test results recorded") }} - {% endif %} +
+
-
- -{% if source_wo %} -{% call ok.panel("SOURCE PRODUCTION", margin_top="var(--space-md)") %} - {% call ok.table() %} + + + + {{ ok.th("TEST NAME") }} + {{ ok.th("RESULT", width="70px") }} + {{ ok.th("VALUE", width="120px") }} + {{ ok.th("TESTED BY", width="120px") }} + {{ ok.th("TESTED AT", width="180px") }} + {{ ok.th("", width="60px") }} + + + + {% for tr in test_results %} - + + {{ ok.td(tr.value or '-', mono=true) }} + + + - {% if source_wo.serial_number %} - {{ ok.detail_row("SERIAL NUMBER", source_wo.serial_number, mono=true, th_width="140px") }} - {% endif %} - {% endcall %} -{% endcall %} + {% endfor %} + +
WORK ORDER{{ tr.test_name }} - {% if source_wo.work_order_number %} - {{ ok.link(source_wo.work_order_number, "/executions/" ~ source_wo.instance_id, mono=true) }} + {% set r = tr.result.value if tr.result.value is defined else tr.result %} + {% if r == 'pass' %} + {{ ok.status("PASS", "ok") }} + {% elif r == 'fail' %} + {{ ok.status("FAIL", "error") }} {% else %} - {{ ok.link("Execution #" ~ source_wo.instance_id, "/executions/" ~ source_wo.instance_id) }} + {{ ok.status("PENDING", "warn") }} {% endif %} {% if tr.tested_by_user %}{{ tr.tested_by_user.display_name or tr.tested_by_user.email }}{% else %}{% endif %}{% if tr.tested_at %}{{ ok.timestamp(tr.tested_at) }}{% else %}{% endif %}EDIT
+
+{% elif test_templates %} +{% set required_count = test_templates | selectattr('required') | list | length %} +
+{{ ok.empty_line("TESTS", onclick="showRunTest()", flag=(required_count ~ " REQUIRED") if required_count else none) }} +
{% endif %} {% if genealogy_components or genealogy_assemblies %} -{% call ok.panel("GENEALOGY", margin_top="var(--space-md)") %} +{% call ok.panel("GENEALOGY", margin_top="var(--space-md)", max_width="860px") %} {% if genealogy_components %}

COMPONENTS IN THIS ASSEMBLY

{% call ok.table() %} @@ -319,19 +262,6 @@

ASSEMBLIES CONTAINING THIS COMPONENT {% endcall %} {% endif %} - -