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 %}| PART | -QTY | -LOCATION | -LOT | -STEP | +PART | +NAME | +OPAL # | +QTY | +LOT | +LOCATION | +STEP | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ 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 %} | @@ -149,7 +155,7 @@|||||||||||||||||||||||||||||||||||||||||||||||
| {{ out.part.name }} | +{{ out.part.internal_pn }} {{ out.part.name }} | {{ out.quantity_produced }} | 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 %} - |
| 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 %}
-
-
-
-
-
-
- {% if source_po %}
- {{ ok.timestamp(record.created_at) }}
-
- Source:
- PO-{{ source_po.number }}
- Line #{{ record.source_purchase_line_id }}
-
- {% endif %}
+ |
| RECEIVED | {{ ok.timestamp(record.created_at) }} |
| {{ ok.timestamp(event.timestamp) }} | + {% if event.event == 'created' %} +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 }} + |
+ {% elif event.event == 'consumed' %}
+ {% if event.details.usage_type == 'scrap' %}
+ SCRAPPED | +-{{ '%g' % event.details.quantity }} | + {% elif event.details.usage_type == 'tooling' %} +TOOLING | +{{ '%g' % event.details.quantity }} | + {% else %} +CONSUMED | +-{{ '%g' % event.details.quantity }} | + {% endif %} ++ {% 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 %} + | + {% else %} +{{ event.event | upper }} | +{% if event.details.quantity is defined %}{{ '%g' % event.details.quantity }}{% else %}—{% endif %} | +{{ event.details }} | + {% endif %} +
| 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 %} | + {{ 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 %} | +EDIT |
|---|