Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/opal/api/routes/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions src/opal/api/routes/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 28 additions & 2 deletions src/opal/web/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
},
}
Expand Down Expand Up @@ -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
Expand All @@ -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()
)
Expand Down Expand Up @@ -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()
)
Expand 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,
Expand All @@ -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,
}
Expand Down
20 changes: 17 additions & 3 deletions src/opal/web/static/js/execdoc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -1049,11 +1061,12 @@
if (!item.is_available) availCell.classList.add('text-red');
const select = row.querySelector('.inv-select');
if (!select) continue;
select.innerHTML = '<option value="">Select location...</option>';
select.innerHTML = '<option value="">Select source...</option>';
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);
Expand Down Expand Up @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions src/opal/web/templates/executions/tabs/bom.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<tbody>
{% for item in bom_items %}
<tr>
<td><a href="/parts/{{ item.part_id }}">{{ item.part_name }}</a></td>
<td><a href="/parts/{{ item.part_id }}" class="mono">{{ item.part_pn }}</a> {{ item.part_name }}</td>
<td class="mono" style="text-align:right;">{{ item.qty_required }}</td>
<td class="mono" style="text-align:right;">{{ item.qty_consumed }}</td>
<td class="mono" style="text-align:right;{% if item.variance > 0 %}color:var(--accent-yellow);{% elif item.variance < 0 %}color:var(--accent-red);{% endif %}">{% if item.variance > 0 %}+{% endif %}{{ item.variance }}</td>
Expand All @@ -29,7 +29,7 @@
<tbody>
{% for item in unplanned_consumptions %}
<tr>
<td><a href="/parts/{{ item.part_id }}">{{ item.part_name }}</a></td>
<td>{% if item.part_pn %}<a href="/parts/{{ item.part_id }}" class="mono">{{ item.part_pn }}</a> {{ item.part_name }}{% else %}<a href="/parts/{{ item.part_id }}">{{ item.part_name }}</a>{% endif %}</td>
<td class="mono" style="text-align:right;">{{ item.qty_consumed }}</td>
</tr>
{% endfor %}
Expand Down
26 changes: 16 additions & 10 deletions src/opal/web/templates/executions/tabs/kitting.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,29 @@
</div>
<div class="panel-body">
{% if consumptions %}
{# Consumed rows carry the physical-item identity: part number and
OPAL # trace each deduction to the exact stock record it came from. #}
<table class="data-table">
<thead>
<tr>
<th>PART</th>
<th style="width: 100px; text-align: right;">QTY</th>
<th style="width: 150px;">LOCATION</th>
<th style="width: 100px;">LOT</th>
<th style="width: 80px;">STEP</th>
<th style="width: 130px;">PART</th>
<th>NAME</th>
<th style="width: 130px;">OPAL #</th>
<th style="width: 80px; text-align: right;">QTY</th>
<th style="width: 120px;">LOT</th>
<th style="width: 130px;">LOCATION</th>
<th style="width: 70px;">STEP</th>
</tr>
</thead>
<tbody>
{% for c in consumptions %}
<tr>
<td><a href="/parts/{{ c.inventory_record.part_id }}">{{ c.inventory_record.part.name }}</a></td>
<td><a href="/parts/{{ c.inventory_record.part_id }}" class="mono">{{ c.inventory_record.part.internal_pn }}</a></td>
<td>{{ c.inventory_record.part.name }}</td>
<td class="mono">{% if c.inventory_record.opal_number %}<a href="/inventory/opal/{{ c.inventory_record.opal_number }}">{{ c.inventory_record.opal_number }}</a>{% else %}<span class="text-muted">—</span>{% endif %}</td>
<td class="mono" style="text-align: right;">{{ c.quantity }}</td>
<td class="mono">{{ c.inventory_record.location }}</td>
<td class="mono">{{ c.inventory_record.lot_number or '-' }}</td>
<td class="mono">{{ c.inventory_record.location }}</td>
<td class="mono">{% if c.step_execution_id %}{{ step_exec_lookup.get(c.step_execution_id, '-') }}{% else %}-{% endif %}</td>
</tr>
{% endfor %}
Expand All @@ -50,7 +56,7 @@
<tbody>
{% for kit in kit_items %}
<tr data-part-id="{{ kit.part_id }}" data-required="{{ kit.quantity_required }}">
<td><a href="/parts/{{ kit.part_id }}">{{ kit.part.name }}</a></td>
<td><a href="/parts/{{ kit.part_id }}" class="mono">{{ kit.part.internal_pn }}</a> {{ kit.part.name }}</td>
<td class="mono" style="text-align: right;">{{ kit.quantity_required }}</td>
<td class="mono availability-cell" style="text-align: right;">-</td>
<td>
Expand Down Expand Up @@ -114,7 +120,7 @@
{% for p in productions %}
{% set pstatus = p.status.value if p.status.value is defined else p.status %}
<tr>
<td><a href="/parts/{{ p.inventory_record.part_id }}">{{ p.inventory_record.part.name }}</a></td>
<td><a href="/parts/{{ p.inventory_record.part_id }}" class="mono">{{ p.inventory_record.part.internal_pn }}</a> {{ p.inventory_record.part.name }}</td>
<td class="mono">{{ p.serial_number or '-' }}</td>
<td class="mono">{% if p.produced_opal_number %}<a href="/inventory/opal/{{ p.produced_opal_number }}">{{ p.produced_opal_number }}</a>{% else %}-{% endif %}</td>
<td class="mono" style="text-align: right;">{% if pstatus == 'completed' %}{{ p.quantity }}{% else %}({{ p.quantity }}){% endif %}</td>
Expand Down Expand Up @@ -149,7 +155,7 @@
<tbody>
{% for out in output_items %}
<tr data-part-id="{{ out.part_id }}">
<td><a href="/parts/{{ out.part_id }}">{{ out.part.name }}</a></td>
<td><a href="/parts/{{ out.part_id }}" class="mono">{{ out.part.internal_pn }}</a> {{ out.part.name }}</td>
<td class="mono" style="text-align:right;">{{ out.quantity_produced }}</td>
<td><input type="number" name="out_{{ out.part_id }}_qty" class="form-input" step="0.0001" min="0" value="{{ out.quantity_produced }}"></td>
<td><input type="text" name="out_{{ out.part_id }}_loc" class="form-input" placeholder="Location"></td>
Expand Down
Loading