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:
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user