feat(query-tool): 改進前後批查詢邏輯與產品資訊顯示

- 前後批查詢改為依 PJ_TYPE 搜尋,移除 SPECNAME 限制
- 時間窗口從 24 小時擴大至 168 小時 (1 週)
- 生產歷程與前後批新增產品資訊欄位 (PJ_TYPE, BOP, Wafer Lot)
- 前後批 Modal 顯示設備名稱而非 ID
- 整合站點篩選器與批次選擇器至統一選擇列

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-02-06 11:11:58 +08:00
parent d468adaf3b
commit e5504dea26
7 changed files with 241 additions and 73 deletions

View File

@@ -135,25 +135,26 @@ def query_lot_history():
@query_tool_bp.route('/api/query-tool/adjacent-lots', methods=['GET'])
def query_adjacent_lots():
"""Query adjacent lots (前後批) for a specific equipment and spec.
"""Query adjacent lots (前後批) for a specific equipment.
Finds lots before/after target on same equipment until different PJ_TYPE,
with minimum 3 lots in each direction.
Query params:
equipment_id: Equipment ID
spec_name: Spec name
target_time: Target lot's TRACKINTIMESTAMP (ISO format)
time_window: Time window in hours (optional, default 24)
Returns adjacent lots with relative position.
"""
equipment_id = request.args.get('equipment_id')
spec_name = request.args.get('spec_name')
target_time = request.args.get('target_time')
time_window = request.args.get('time_window', 24, type=int)
if not all([equipment_id, spec_name, target_time]):
return jsonify({'error': '請指定設備、規格和目標時間'}), 400
if not all([equipment_id, target_time]):
return jsonify({'error': '請指定設備和目標時間'}), 400
result = get_adjacent_lots(equipment_id, spec_name, target_time, time_window)
result = get_adjacent_lots(equipment_id, target_time, time_window)
if 'error' in result:
return jsonify(result), 400
@@ -395,7 +396,6 @@ def export_csv():
elif export_type == 'adjacent_lots':
result = get_adjacent_lots(
params.get('equipment_id'),
params.get('spec_name'),
params.get('target_time'),
params.get('time_window', 24)
)

View File

@@ -35,7 +35,7 @@ MAX_SERIAL_NUMBERS = 50
MAX_WORK_ORDERS = 10
MAX_EQUIPMENTS = 20
MAX_DATE_RANGE_DAYS = 90
DEFAULT_TIME_WINDOW_HOURS = 24
DEFAULT_TIME_WINDOW_HOURS = 168 # 1 week for better PJ_TYPE detection
ADJACENT_LOTS_COUNT = 3
@@ -498,26 +498,24 @@ def get_lot_history(
def get_adjacent_lots(
equipment_id: str,
spec_name: str,
target_trackin_time: str,
time_window_hours: int = DEFAULT_TIME_WINDOW_HOURS
) -> Dict[str, Any]:
"""Get adjacent lots (前後批) for a specific equipment and spec.
"""Get adjacent lots (前後批) for a specific equipment.
Finds lots processed before and after the target lot on the same
equipment with the same spec, limited to ±time_window_hours.
Finds lots processed before and after the target lot on the same equipment.
Searches until finding a different PJ_TYPE, with minimum 3 lots in each direction.
Args:
equipment_id: Target equipment ID
spec_name: Target spec name
target_trackin_time: Target lot's TRACKINTIMESTAMP (ISO format)
time_window_hours: Time window in hours (default 24)
Returns:
Dict with 'data' (adjacent lots with relative_position) and metadata.
"""
if not all([equipment_id, spec_name, target_trackin_time]):
return {'error': '請指定設備、規格和目標時間'}
if not all([equipment_id, target_trackin_time]):
return {'error': '請指定設備和目標時間'}
try:
# Parse target time
@@ -526,13 +524,9 @@ def get_adjacent_lots(
else:
target_time = target_trackin_time
time_start = target_time - timedelta(hours=time_window_hours)
time_end = target_time + timedelta(hours=time_window_hours)
sql = SQLLoader.load("query_tool/adjacent_lots")
params = {
'equipment_id': equipment_id,
'spec_name': spec_name,
'target_trackin_time': target_time,
'time_window_hours': time_window_hours,
}
@@ -540,13 +534,12 @@ def get_adjacent_lots(
df = read_sql_df(sql, params)
data = _df_to_records(df)
logger.debug(f"Adjacent lots: {len(data)} records for {equipment_id}/{spec_name}")
logger.debug(f"Adjacent lots: {len(data)} records for {equipment_id}")
return {
'data': data,
'total': len(data),
'equipment_id': equipment_id,
'spec_name': spec_name,
'target_time': target_trackin_time,
'time_window_hours': time_window_hours,
}

View File

@@ -1,14 +1,24 @@
-- Adjacent Lots Query (前後批查詢)
-- Finds lots processed before and after a target lot on same equipment with same spec
-- Finds lots processed before and after a target lot on the same equipment
-- Searches until finding a different PJ_TYPE, with minimum 3 lots in each direction
--
-- Parameters:
-- :equipment_id - Target equipment ID
-- :spec_name - Target spec name
-- :target_trackin_time - Target lot's TRACKINTIMESTAMP
-- :time_window_hours - Time window in hours (default 24)
--
-- Note: Uses ROW_NUMBER() to identify relative position
-- Limited to ±time_window_hours to control result set
-- Output columns:
-- PJ_TYPE - Product type (from DW_MES_CONTAINER)
-- PJ_BOP - BOP code (from DW_MES_CONTAINER)
-- WAFER_LOT_ID - Wafer lot ID, mapped from FIRSTNAME (from DW_MES_CONTAINER)
--
-- Logic:
-- 1. Only filter by EQUIPMENTID (no SPECNAME restriction)
-- 2. Search forward/backward until finding a different PJ_TYPE
-- 3. Minimum 3 lots in each direction (even if different PJ_TYPE found earlier)
-- 4. Stop at first different PJ_TYPE if found beyond 3 lots
--
-- Note: Deduplicates multiple track-out records for same track-in (takes latest track-out)
WITH time_bounds AS (
SELECT
@@ -16,13 +26,14 @@ WITH time_bounds AS (
:target_trackin_time + INTERVAL '1' HOUR * :time_window_hours AS time_end
FROM DUAL
),
ranked_lots AS (
-- Step 1: Get all records and deduplicate
-- Multiple track-out records for same track-in -> take the latest track-out time
raw_lots AS (
SELECT
h.CONTAINERID,
h.EQUIPMENTID,
h.EQUIPMENTNAME,
h.SPECNAME,
h.PJ_TYPE,
h.TRACKINTIMESTAMP,
h.TRACKOUTTIMESTAMP,
h.TRACKINQTY,
@@ -30,28 +41,75 @@ ranked_lots AS (
h.FINISHEDRUNCARD,
h.PJ_WORKORDER,
c.CONTAINERNAME,
c.PJ_TYPE,
c.PJ_BOP,
c.FIRSTNAME AS WAFER_LOT_ID,
ROW_NUMBER() OVER (
PARTITION BY h.EQUIPMENTID, h.SPECNAME
ORDER BY h.TRACKINTIMESTAMP
) AS rn
PARTITION BY h.CONTAINERID, h.EQUIPMENTID, h.TRACKINTIMESTAMP
ORDER BY h.TRACKOUTTIMESTAMP DESC NULLS LAST
) AS dedup_rn
FROM DWH.DW_MES_LOTWIPHISTORY h
LEFT JOIN DWH.DW_MES_CONTAINER c ON h.CONTAINERID = c.CONTAINERID
CROSS JOIN time_bounds tb
WHERE h.EQUIPMENTID = :equipment_id
AND h.SPECNAME = :spec_name
AND h.TRACKINTIMESTAMP BETWEEN tb.time_start AND tb.time_end
),
-- Step 2: Keep only deduplicated records
deduped_lots AS (
SELECT *
FROM raw_lots
WHERE dedup_rn = 1
),
-- Step 3: Rank by track-in time (partitioned by EQUIPMENTID only)
ranked_lots AS (
SELECT
d.*,
ROW_NUMBER() OVER (
PARTITION BY d.EQUIPMENTID
ORDER BY d.TRACKINTIMESTAMP
) AS rn
FROM deduped_lots d
),
-- Step 4: Find target lot position and PJ_TYPE
target_lot AS (
SELECT rn AS target_rn
SELECT rn AS target_rn, PJ_TYPE AS target_pj_type
FROM ranked_lots
WHERE TRACKINTIMESTAMP = :target_trackin_time
),
-- Step 5: Find first lot BEFORE target with different PJ_TYPE
-- (highest rn that is less than target_rn and has different PJ_TYPE)
first_diff_before AS (
SELECT MAX(r.rn) AS rn
FROM ranked_lots r
CROSS JOIN target_lot t
WHERE r.rn < t.target_rn
AND (
(r.PJ_TYPE IS NULL AND t.target_pj_type IS NOT NULL)
OR (r.PJ_TYPE IS NOT NULL AND t.target_pj_type IS NULL)
OR (r.PJ_TYPE != t.target_pj_type)
)
),
-- Step 6: Find first lot AFTER target with different PJ_TYPE
-- (lowest rn that is greater than target_rn and has different PJ_TYPE)
first_diff_after AS (
SELECT MIN(r.rn) AS rn
FROM ranked_lots r
CROSS JOIN target_lot t
WHERE r.rn > t.target_rn
AND (
(r.PJ_TYPE IS NULL AND t.target_pj_type IS NOT NULL)
OR (r.PJ_TYPE IS NOT NULL AND t.target_pj_type IS NULL)
OR (r.PJ_TYPE != t.target_pj_type)
)
)
-- Step 7: Select lots within calculated range
-- Before: MIN(first_diff_before, target - 3) to ensure minimum 3 and stop at different PJ_TYPE
-- After: MAX(first_diff_after, target + 3) to ensure minimum 3 and stop at different PJ_TYPE
SELECT
r.CONTAINERID,
r.EQUIPMENTID,
r.EQUIPMENTNAME,
r.SPECNAME,
r.PJ_TYPE,
r.TRACKINTIMESTAMP,
r.TRACKOUTTIMESTAMP,
r.TRACKINQTY,
@@ -59,8 +117,14 @@ SELECT
r.FINISHEDRUNCARD,
r.PJ_WORKORDER,
r.CONTAINERNAME,
r.PJ_TYPE,
r.PJ_BOP,
r.WAFER_LOT_ID,
r.rn - t.target_rn AS RELATIVE_POSITION
FROM ranked_lots r
CROSS JOIN target_lot t
WHERE r.rn BETWEEN (t.target_rn - 3) AND (t.target_rn + 3)
CROSS JOIN first_diff_before b
CROSS JOIN first_diff_after a
WHERE r.rn >= LEAST(NVL(b.rn, t.target_rn - 3), t.target_rn - 3)
AND r.rn <= GREATEST(NVL(a.rn, t.target_rn + 3), t.target_rn + 3)
ORDER BY r.rn

View File

@@ -5,6 +5,11 @@
-- :container_id - CONTAINERID to query (16-char hex)
-- {{ WORKCENTER_FILTER }} - Optional workcenter name filter (replaced by service)
--
-- Output columns:
-- PJ_TYPE - Product type (from DW_MES_CONTAINER)
-- PJ_BOP - BOP code (from DW_MES_CONTAINER)
-- WAFER_LOT_ID - Wafer lot ID, mapped from FIRSTNAME (from DW_MES_CONTAINER)
--
-- Note: Uses EQUIPMENTID/EQUIPMENTNAME (NOT RESOURCEID/RESOURCENAME)
-- Time fields: TRACKINTIMESTAMP/TRACKOUTTIMESTAMP (NOT TXNDATETIME)
-- Partial track-out: Same LOT may have multiple records with same track-in
@@ -25,6 +30,9 @@ WITH ranked_history AS (
h.FINISHEDRUNCARD,
h.PJ_WORKORDER,
c.CONTAINERNAME,
c.PJ_TYPE,
c.PJ_BOP,
c.FIRSTNAME AS WAFER_LOT_ID,
ROW_NUMBER() OVER (
PARTITION BY h.CONTAINERID, h.EQUIPMENTID, h.SPECNAME, h.TRACKINTIMESTAMP
ORDER BY h.TRACKOUTTIMESTAMP DESC NULLS LAST
@@ -48,7 +56,10 @@ SELECT
TRACKOUTQTY,
FINISHEDRUNCARD,
PJ_WORKORDER,
CONTAINERNAME
CONTAINERNAME,
PJ_TYPE,
PJ_BOP,
WAFER_LOT_ID
FROM ranked_history
WHERE rn = 1
ORDER BY TRACKINTIMESTAMP

View File

@@ -53,9 +53,9 @@ function clearQueryState() {
// Clear workcenter group selection (keep workcenterGroups as it's reused)
QueryToolState.selectedWorkcenterGroups = new Set();
// Hide workcenter group selector
const wcContainer = document.getElementById('workcenterGroupSelectorContainer');
if (wcContainer) wcContainer.style.display = 'none';
// Hide selection bar (contains LOT selector and workcenter filter)
const selectionBar = document.getElementById('selectionBar');
if (selectionBar) selectionBar.style.display = 'none';
// Clear equipment query state
QueryToolState.equipmentResults = null;
@@ -86,13 +86,10 @@ function clearQueryState() {
lotEmptyState.style.display = 'block';
}
// Hide LOT info bar and selector
// Hide LOT info bar
const lotInfoBar = document.getElementById('lotInfoBar');
if (lotInfoBar) lotInfoBar.style.display = 'none';
const lotSelectorContainer = document.getElementById('lotSelectorContainer');
if (lotSelectorContainer) lotSelectorContainer.style.display = 'none';
console.log('[QueryTool] State cleared for memory management');
}
@@ -240,9 +237,10 @@ async function executeLotQuery() {
</div>
`;
// Hide LOT info bar and selector during loading
// Hide LOT info bar and selection bar during loading
document.getElementById('lotInfoBar').style.display = 'none';
document.getElementById('lotSelectorContainer').style.display = 'none';
const selectionBar = document.getElementById('selectionBar');
if (selectionBar) selectionBar.style.display = 'none';
document.getElementById('lotQueryBtn').disabled = true;
@@ -290,11 +288,18 @@ async function executeLotQuery() {
if (resolveResult.data.length === 1) {
// Single result - auto-select and show directly
QueryToolState.timelineSelectedLots.add(0);
document.getElementById('lotSelectorContainer').style.display = 'none';
// Hide LOT selector (not needed for single result), but show workcenter filter
const lotSelector = document.getElementById('lotSelectorContainer');
if (lotSelector) lotSelector.style.display = 'none';
// Update hint for single LOT
const hint = document.getElementById('selectionHint');
if (hint) hint.innerHTML = '<span>選擇站點後點擊「套用篩選」重新載入</span>';
// Load and show the single lot's data
confirmLotSelection();
} else {
// Multiple results - show selector for user to choose
const lotSelector = document.getElementById('lotSelectorContainer');
if (lotSelector) lotSelector.style.display = 'block';
showLotSelector(resolveResult.data);
// Render empty state
renderLotResults(resolveResult);
@@ -599,6 +604,9 @@ function renderCombinedLotView(selectedIndices) {
<th style="min-width: 100px;">站點</th>
<th style="min-width: 100px;">設備</th>
<th style="min-width: 120px;">規格</th>
<th style="min-width: 80px;">產品類型</th>
<th style="min-width: 80px;">BOP</th>
<th style="min-width: 100px;">Wafer Lot</th>
<th style="min-width: 150px;">上機時間</th>
<th style="min-width: 150px;">下機時間</th>
<th style="width: 70px;">入數</th>
@@ -616,12 +624,15 @@ function renderCombinedLotView(selectedIndices) {
<td>${step.WORKCENTERNAME || ''}</td>
<td>${step.EQUIPMENTNAME || ''}</td>
<td title="${step.SPECNAME || ''}">${truncateText(step.SPECNAME, 15)}</td>
<td>${step.PJ_TYPE || '-'}</td>
<td>${step.PJ_BOP || '-'}</td>
<td>${step.WAFER_LOT_ID || '-'}</td>
<td>${formatDateTime(step.TRACKINTIMESTAMP)}</td>
<td>${formatDateTime(step.TRACKOUTTIMESTAMP)}</td>
<td>${step.TRACKINQTY || ''}</td>
<td>${step.TRACKOUTQTY || ''}</td>
<td style="position: sticky; right: 0; background: white; box-shadow: -2px 0 4px rgba(0,0,0,0.1);">
<button class="btn btn-secondary btn-sm" onclick="showAdjacentLots('${step.EQUIPMENTID}', '${step.SPECNAME}', '${step.TRACKINTIMESTAMP}')" title="查詢前後批">
<button class="btn btn-secondary btn-sm" onclick="showAdjacentLots('${step.EQUIPMENTID}', '${step.EQUIPMENTNAME || step.EQUIPMENTID}', '${step.TRACKINTIMESTAMP}')" title="查詢前後批">
前後批
</button>
</td>
@@ -1104,6 +1115,9 @@ function renderLotDetail(index) {
<th style="min-width: 100px;">站點</th>
<th style="min-width: 100px;">設備</th>
<th style="min-width: 120px;">規格</th>
<th style="min-width: 80px;">產品類型</th>
<th style="min-width: 80px;">BOP</th>
<th style="min-width: 100px;">Wafer Lot</th>
<th style="min-width: 150px;">上機時間</th>
<th style="min-width: 150px;">下機時間</th>
<th style="width: 70px;">入數</th>
@@ -1121,12 +1135,15 @@ function renderLotDetail(index) {
<td>${step.WORKCENTERNAME || ''}</td>
<td>${step.EQUIPMENTNAME || ''}</td>
<td title="${step.SPECNAME || ''}">${truncateText(step.SPECNAME, 12)}</td>
<td>${step.PJ_TYPE || '-'}</td>
<td>${step.PJ_BOP || '-'}</td>
<td>${step.WAFER_LOT_ID || '-'}</td>
<td>${formatDateTime(step.TRACKINTIMESTAMP)}</td>
<td>${formatDateTime(step.TRACKOUTTIMESTAMP)}</td>
<td>${step.TRACKINQTY || ''}</td>
<td>${step.TRACKOUTQTY || ''}</td>
<td style="position: sticky; right: 0; background: white; box-shadow: -2px 0 4px rgba(0,0,0,0.1);">
<button class="btn btn-secondary btn-sm" onclick="showAdjacentLots('${step.EQUIPMENTID}', '${step.SPECNAME}', '${step.TRACKINTIMESTAMP}')" title="查詢前後批">
<button class="btn btn-secondary btn-sm" onclick="showAdjacentLots('${step.EQUIPMENTID}', '${step.EQUIPMENTNAME || step.EQUIPMENTID}', '${step.TRACKINTIMESTAMP}')" title="查詢前後批">
前後批
</button>
</td>
@@ -2039,7 +2056,7 @@ function showTimelineDetail(containerId, stepIndex) {
}
}
async function showAdjacentLots(equipmentId, specName, targetTime) {
async function showAdjacentLots(equipmentId, equipmentName, targetTime) {
// Open modal or expand section to show adjacent lots
const modal = document.createElement('div');
modal.className = 'modal-overlay';
@@ -2051,7 +2068,7 @@ async function showAdjacentLots(equipmentId, specName, targetTime) {
</div>
<div class="modal-body">
<div class="info-box" style="margin-bottom: 15px;">
設備: ${equipmentId} | 規格: ${specName} | 基準時間: ${formatDateTime(targetTime)}
設備: ${equipmentName} | 基準時間: ${formatDateTime(targetTime)}
</div>
<div id="adjacentLotsContent">
<div class="loading"><div class="loading-spinner"></div></div>
@@ -2117,7 +2134,6 @@ async function showAdjacentLots(equipmentId, specName, targetTime) {
const result = await MesApi.get('/api/query-tool/adjacent-lots', {
params: {
equipment_id: equipmentId,
spec_name: specName,
target_time: targetTime,
time_window: 24
}
@@ -2143,6 +2159,8 @@ async function showAdjacentLots(equipmentId, specName, targetTime) {
<th>相對位置</th>
<th>LOT ID</th>
<th>產品類型</th>
<th>BOP</th>
<th>Wafer Lot</th>
<th>工單</th>
<th>批次號</th>
<th>上機時間</th>
@@ -2164,6 +2182,8 @@ async function showAdjacentLots(equipmentId, specName, targetTime) {
<td><strong>${posLabel}</strong></td>
<td>${lot.CONTAINERNAME || '-'}</td>
<td>${lot.PJ_TYPE || '-'}</td>
<td>${lot.PJ_BOP || '-'}</td>
<td>${lot.WAFER_LOT_ID || '-'}</td>
<td>${lot.PJ_WORKORDER || '-'}</td>
<td>${lot.FINISHEDRUNCARD || ''}</td>
<td>${formatDateTime(lot.TRACKINTIMESTAMP)}</td>
@@ -2252,7 +2272,7 @@ function renderWorkcenterGroupSelector() {
</div>
<div class="wc-group-footer">
<button type="button" class="btn btn-sm btn-secondary" onclick="clearWorkcenterGroups()">清除</button>
<button type="button" class="btn btn-sm btn-primary" onclick="closeWorkcenterGroupDropdown()">確定</button>
<button type="button" class="btn btn-sm btn-primary" onclick="applyWorkcenterFilter()">套用篩選</button>
</div>
</div>
</div>
@@ -2328,6 +2348,27 @@ function closeWorkcenterGroupDropdown() {
if (dropdown) dropdown.classList.remove('show');
}
function applyWorkcenterFilter() {
// Close dropdown
closeWorkcenterGroupDropdown();
// Check if we have selected lots
if (QueryToolState.timelineSelectedLots.size === 0) {
Toast.warning('請先選擇批次');
return;
}
const wcGroups = QueryToolState.selectedWorkcenterGroups;
if (wcGroups.size > 0) {
Toast.info(`套用 ${wcGroups.size} 個站點群組篩選...`);
} else {
Toast.info('顯示全部站點資料...');
}
// Re-run confirmLotSelection to apply the filter
confirmLotSelection();
}
function filterWorkcenterGroups(searchText) {
const items = document.querySelectorAll('.wc-group-item');
const search = searchText.toLowerCase();
@@ -2339,9 +2380,14 @@ function filterWorkcenterGroups(searchText) {
}
function showWorkcenterGroupSelector() {
const container = document.getElementById('workcenterGroupSelectorContainer');
if (container && QueryToolState.workcenterGroups.length > 0) {
container.style.display = 'block';
// Show the entire selection bar
const selectionBar = document.getElementById('selectionBar');
if (selectionBar) {
selectionBar.style.display = 'flex';
}
// Render the workcenter group selector if groups are available
if (QueryToolState.workcenterGroups.length > 0) {
renderWorkcenterGroupSelector();
}
}
@@ -2753,7 +2799,7 @@ async function exportCombinedResults() {
}
// Generate CSV
const headers = ['LOT_ID', 'WORKCENTERNAME', 'EQUIPMENTNAME', 'SPECNAME', 'TRACKINTIMESTAMP', 'TRACKOUTTIMESTAMP', 'TRACKINQTY', 'TRACKOUTQTY'];
const headers = ['LOT_ID', 'WORKCENTERNAME', 'EQUIPMENTNAME', 'SPECNAME', 'PJ_TYPE', 'PJ_BOP', 'WAFER_LOT_ID', 'TRACKINTIMESTAMP', 'TRACKOUTTIMESTAMP', 'TRACKINQTY', 'TRACKOUTQTY'];
let csv = headers.join(',') + '\n';
allHistory.forEach(row => {

View File

@@ -101,6 +101,47 @@
border-color: #4e54c8;
}
/* Sticky selection bar for LOT and workcenter filter */
.selection-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 20px;
background: linear-gradient(135deg, #f8f9ff 0%, #eef1ff 100%);
border: 1px solid #d4d9ff;
border-radius: 8px;
position: sticky;
top: 0;
z-index: 100;
flex-wrap: wrap;
}
.selection-bar .wc-filter-container {
display: flex;
align-items: center;
gap: 8px;
}
.selection-bar .wc-filter-label {
font-size: 13px;
font-weight: 500;
color: #555;
white-space: nowrap;
}
.selection-bar .selection-hint {
margin-left: auto;
font-size: 12px;
color: #888;
display: flex;
align-items: center;
gap: 6px;
}
.selection-bar .selection-hint::before {
content: "💡";
}
/* LOT selector dropdown */
.lot-selector {
position: relative;
@@ -1110,9 +1151,12 @@
<button class="btn btn-primary" onclick="executeLotQuery()" id="lotQueryBtn">
查詢
</button>
</div>
<!-- LOT Selector (appears after query) -->
<div class="lot-selector" id="lotSelectorContainer" style="display: none;">
<!-- Sticky Sub-bar for LOT Selection and Workcenter Filter -->
<div class="selection-bar" id="selectionBar" style="display: none;">
<!-- LOT Selector -->
<div class="lot-selector" id="lotSelectorContainer">
<div class="lot-selector-btn" onclick="toggleLotSelector()">
<span id="lotSelectorDisplay">選擇 LOT</span>
<span class="lot-count-badge" id="lotCountBadge">0</span>
@@ -1122,13 +1166,18 @@
</div>
</div>
<!-- Workcenter Group Filter (appears after query) -->
<div class="filter-item" id="workcenterGroupSelectorContainer" style="display: none;">
<label>站點篩選</label>
<!-- Workcenter Group Filter -->
<div class="wc-filter-container" id="workcenterGroupSelectorContainer">
<span class="wc-filter-label">站點篩選:</span>
<div id="workcenterGroupSelector">
<!-- Rendered by JS -->
</div>
</div>
<!-- Quick action hint -->
<div class="selection-hint" id="selectionHint">
<span>選擇批次和站點後點擊「套用篩選」</span>
</div>
</div>
<!-- Equipment Query Filter Bar (hidden by default) -->

View File

@@ -197,17 +197,7 @@ class TestAdjacentLotsEndpoint:
"""Should return error without equipment_id."""
response = client.get(
'/api/query-tool/adjacent-lots?'
'spec_name=SPEC-001&trackin_time=2024-01-15T10:30:00'
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
def test_missing_spec_name(self, client):
"""Should return error without spec_name."""
response = client.get(
'/api/query-tool/adjacent-lots?'
'equipment_id=EQ001&trackin_time=2024-01-15T10:30:00'
'target_time=2024-01-15T10:30:00'
)
assert response.status_code == 400
data = json.loads(response.data)
@@ -217,7 +207,17 @@ class TestAdjacentLotsEndpoint:
"""Should return error without target_time."""
response = client.get(
'/api/query-tool/adjacent-lots?'
'equipment_id=EQ001&spec_name=SPEC-001'
'equipment_id=EQ001'
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
def test_with_only_equipment_id(self, client):
"""Should return error with only equipment_id (no target_time)."""
response = client.get(
'/api/query-tool/adjacent-lots?'
'equipment_id=EQ001'
)
assert response.status_code == 400
data = json.loads(response.data)
@@ -249,12 +249,17 @@ class TestAdjacentLotsEndpoint:
response = client.get(
'/api/query-tool/adjacent-lots?'
'equipment_id=EQ001&spec_name=SPEC-001&target_time=2024-01-15T10:30:00'
'equipment_id=EQ001&target_time=2024-01-15T10:30:00'
)
assert response.status_code == 200
data = json.loads(response.data)
assert 'data' in data
assert data['total'] == 3
# Verify service was called without spec_name
mock_query.assert_called_once()
call_args = mock_query.call_args
assert call_args[0][0] == 'EQ001' # equipment_id
assert '2024-01-15' in call_args[0][1] # target_time
class TestLotAssociationsEndpoint: