fix(hold-history): align KPI cards with trend data, improve filters and UX across pages

Use Daily Trend as single source of truth for On Hold and New Hold KPI cards
instead of separate snapshot SQL queries, eliminating value mismatches. Fix
timezone bug in default date range (toISOString UTC offset), add 1st-of-month
fallback to previous month, replace Hold Type radio buttons with select dropdown,
reorder/relabel summary cards with 累計 prefix, add job-query MultiSelect for
equipment filter, and fix heatmap chart X-axis overlap with visualMap legend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-12 08:13:07 +08:00
parent 35d83d424c
commit 8550f6dc3e
13 changed files with 100 additions and 144 deletions

View File

@@ -15,14 +15,14 @@
{
"route": "/hold-overview",
"name": "Hold 即時概況",
"status": "dev",
"status": "released",
"drawer_id": "reports",
"order": 2
},
{
"route": "/hold-history",
"name": "Hold 歷史績效",
"status": "dev",
"status": "released",
"drawer_id": "drawer-2",
"order": 3
},

View File

@@ -50,7 +50,10 @@ const errorMessage = ref('');
let activeRequestId = 0;
function toDateString(value) {
return value.toISOString().slice(0, 10);
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function getUrlParam(name) {
@@ -75,8 +78,19 @@ function parseRecordTypeCsv(value) {
function setDefaultDateRange() {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
let year = now.getFullYear();
let month = now.getMonth();
if (now.getDate() === 1) {
month -= 1;
if (month < 0) {
month = 11;
year -= 1;
}
}
const start = new Date(year, month, 1);
const end = new Date(year, month + 1, 0);
filterBar.startDate = toDateString(start);
filterBar.endDate = toDateString(end);
}
@@ -359,14 +373,18 @@ const summary = computed(() => {
const netChange = releaseQty - newHoldQty - futureHoldQty;
const avgHoldHours = estimateAvgHoldHours(durationData.value?.items || []);
const counts = trendData.value?.stillOnHoldCount || {};
const stillOnHoldCount = Number(counts[trendTypeKey.value] || 0);
const today = new Date().toISOString().slice(0, 10);
const pastDays = days.filter((d) => d.date <= today);
const lastDay = pastDays.length > 0 ? pastDays[pastDays.length - 1] : {};
const stillOnHoldCount = Number(lastDay.holdQty || 0);
const newHoldSnapshotCount = Number(lastDay.newHoldQty || 0);
return {
releaseQty,
newHoldQty,
futureHoldQty,
stillOnHoldCount,
newHoldSnapshotCount,
netChange,
avgHoldHours,
};

View File

@@ -83,21 +83,17 @@ const holdTypeModel = computed({
</div>
<div class="filter-group hold-type-group">
<span class="filter-label">Hold Type</span>
<div class="radio-group">
<label class="radio-option" :class="{ active: holdTypeModel === 'quality' }">
<input v-model="holdTypeModel" type="radio" value="quality" :disabled="disabled" />
<span>品質異常</span>
</label>
<label class="radio-option" :class="{ active: holdTypeModel === 'non-quality' }">
<input v-model="holdTypeModel" type="radio" value="non-quality" :disabled="disabled" />
<span>非品質異常</span>
</label>
<label class="radio-option" :class="{ active: holdTypeModel === 'all' }">
<input v-model="holdTypeModel" type="radio" value="all" :disabled="disabled" />
<span>全部</span>
</label>
</div>
<label class="filter-label" for="hold-history-hold-type">Hold Type</label>
<select
id="hold-history-hold-type"
v-model="holdTypeModel"
class="hold-type-select"
:disabled="disabled"
>
<option value="quality">品質異常</option>
<option value="non-quality">非品質異常</option>
<option value="all">全部</option>
</select>
</div>
</section>
</template>

View File

@@ -7,6 +7,7 @@ const props = defineProps({
newHoldQty: 0,
futureHoldQty: 0,
stillOnHoldCount: 0,
newHoldSnapshotCount: 0,
netChange: 0,
avgHoldHours: 0,
}),
@@ -24,28 +25,33 @@ function formatHours(value) {
<template>
<section class="summary-row hold-history-summary-row">
<article class="summary-card stat-positive">
<div class="summary-label">Release 數量</div>
<div class="summary-value">{{ formatNumber(summary.releaseQty) }}</div>
</article>
<article class="summary-card stat-negative-red">
<div class="summary-label">New Hold 數量</div>
<div class="summary-value">{{ formatNumber(summary.newHoldQty) }}</div>
</article>
<article class="summary-card stat-negative-orange">
<div class="summary-label">Future Hold 數量</div>
<div class="summary-value">{{ formatNumber(summary.futureHoldQty) }}</div>
</article>
<article class="summary-card stat-negative-red">
<div class="summary-label">On Hold 數量</div>
<div class="summary-value">{{ formatNumber(summary.stillOnHoldCount) }}</div>
</article>
<article class="summary-card stat-negative-orange">
<div class="summary-label">最末日新增 Hold</div>
<div class="summary-value">{{ formatNumber(summary.newHoldSnapshotCount) }}</div>
</article>
<article class="summary-card stat-negative-red">
<div class="summary-label">累計新增 Hold</div>
<div class="summary-value">{{ formatNumber(summary.newHoldQty) }}</div>
</article>
<article class="summary-card stat-positive">
<div class="summary-label">累計 Release</div>
<div class="summary-value">{{ formatNumber(summary.releaseQty) }}</div>
</article>
<article class="summary-card stat-negative-orange">
<div class="summary-label">累計 Future Hold</div>
<div class="summary-value">{{ formatNumber(summary.futureHoldQty) }}</div>
</article>
<article class="summary-card">
<div class="summary-label">淨變動 (Release - New - Future)</div>
<div class="summary-label">累計淨變動 (Release - New - Future)</div>
<div class="summary-value" :class="{ positive: summary.netChange >= 0, negative: summary.netChange < 0 }">
{{ formatNumber(summary.netChange) }}
</div>
@@ -53,7 +59,7 @@ function formatHours(value) {
<article class="summary-card">
<div class="summary-label">平均 Hold 時長</div>
<div class="summary-value small">{{ formatHours(summary.avgHoldHours) }}</div>
<div class="summary-value">{{ formatHours(summary.avgHoldHours) }}</div>
</article>
</section>
</template>

View File

@@ -85,14 +85,27 @@
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18);
}
.radio-group,
.hold-type-select {
min-width: 140px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
background: #ffffff;
}
.hold-type-select:focus {
outline: none;
border-color: #0ea5e9;
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18);
}
.checkbox-group {
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
}
.radio-option,
.checkbox-option {
display: inline-flex;
align-items: center;
@@ -105,7 +118,6 @@
font-size: 13px;
}
.radio-option.active,
.checkbox-option.active {
border-color: #0284c7;
background: #e0f2fe;
@@ -120,7 +132,7 @@
}
.hold-history-summary-row {
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.summary-card {
@@ -290,7 +302,7 @@
@media (max-width: 1440px) {
.hold-history-summary-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}

View File

@@ -1,6 +1,7 @@
<script setup>
import { onMounted } from 'vue';
import { computed, onMounted } from 'vue';
import MultiSelect from '../resource-shared/components/MultiSelect.vue';
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
import SectionCard from '../shared-ui/components/SectionCard.vue';
import StatusBadge from '../shared-ui/components/StatusBadge.vue';
@@ -20,18 +21,23 @@ const {
selectedJobId,
txnRows,
txnColumns,
filteredResources,
selectedResourceCount,
resetDateRangeToLast90Days,
hydrateFiltersFromUrl,
loadResources,
toggleResource,
queryJobs,
loadTxn,
exportCsv,
getStatusTone,
} = useJobQueryData();
const resourceOptions = computed(() =>
resources.value.map((item) => ({
value: item.RESOURCEID,
label: item.RESOURCENAME || item.RESOURCEID,
})),
);
function formatCellValue(value) {
if (value === null || value === undefined || value === '') {
return '-';
@@ -77,8 +83,15 @@ onMounted(async () => {
<input v-model="filters.endDate" type="date" />
</label>
<label class="job-query-filter">
<span>設備搜尋</span>
<input v-model="filters.searchText" type="text" placeholder="輸入設備/站點/群組..." />
<span>設備複選</span>
<MultiSelect
:model-value="filters.resourceIds"
:options="resourceOptions"
:disabled="loadingResources"
placeholder="全部設備"
searchable
@update:model-value="filters.resourceIds = $event"
/>
</label>
<template #actions>
@@ -90,27 +103,6 @@ onMounted(async () => {
</button>
</template>
</FilterToolbar>
<div class="job-query-resource-grid">
<div v-if="loadingResources" class="job-query-empty">載入設備中...</div>
<label
v-for="resource in filteredResources"
:key="resource.RESOURCEID"
class="job-query-resource"
:class="{ selected: filters.resourceIds.includes(resource.RESOURCEID) }"
>
<input
type="checkbox"
:checked="filters.resourceIds.includes(resource.RESOURCEID)"
@change="toggleResource(resource.RESOURCEID)"
/>
<div class="job-query-resource-meta">
<strong>{{ resource.RESOURCENAME || resource.RESOURCEID }}</strong>
<span>{{ resource.WORKCENTERNAME || '-' }} / {{ resource.RESOURCEFAMILYNAME || '-' }}</span>
</div>
</label>
<div v-if="!loadingResources && filteredResources.length === 0" class="job-query-empty">無可用設備</div>
</div>
</SectionCard>
<p v-if="errorMessage" class="job-query-error">{{ errorMessage }}</p>

View File

@@ -48,7 +48,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
),
'/job-query': createNativeLoader(
() => import('../job-query/App.vue'),
[() => import('../job-query/style.css')],
[() => import('../resource-shared/styles.css'), () => import('../job-query/style.css')],
),
'/excel-query': createNativeLoader(
() => import('../excel-query/App.vue'),

View File

@@ -64,7 +64,7 @@ const chartOption = computed(() => {
left: 110,
right: 20,
top: 20,
bottom: 64,
bottom: 100,
},
xAxis: {
type: 'category',
@@ -88,7 +88,7 @@ const chartOption = computed(() => {
max: 100,
orient: 'horizontal',
left: 'center',
bottom: 10,
bottom: 4,
inRange: {
color: ['#ef4444', '#f59e0b', '#22c55e'],
},

View File

@@ -15,7 +15,6 @@ from mes_dashboard.services.hold_history_service import (
get_hold_history_list,
get_hold_history_reason_pareto,
get_hold_history_trend,
get_still_on_hold_count,
)
hold_history_bp = Blueprint('hold_history', __name__)
@@ -114,9 +113,6 @@ def api_hold_history_trend():
result = get_hold_history_trend(start_date, end_date)
if result is not None:
count = get_still_on_hold_count()
if count is not None:
result['stillOnHoldCount'] = count
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '查詢失敗'}), 500

View File

@@ -341,28 +341,6 @@ def get_hold_history_trend(start_date: str, end_date: str) -> Optional[Dict[str,
return None
def get_still_on_hold_count() -> Optional[Dict[str, int]]:
"""Count all holds that are still unreleased (factory-wide), by hold type."""
try:
sql = _load_hold_history_sql('still_on_hold_count')
df = read_sql_df(sql, {})
if df is None or df.empty:
return {'quality': 0, 'non_quality': 0, 'all': 0}
row = df.iloc[0]
return {
'quality': _safe_int(row.get('QUALITY_COUNT')),
'non_quality': _safe_int(row.get('NON_QUALITY_COUNT')),
'all': _safe_int(row.get('ALL_COUNT')),
}
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise
except Exception as exc:
logger.error('Hold history still-on-hold count query failed: %s', exc)
return None
def get_hold_history_reason_pareto(
start_date: str,
end_date: str,

View File

@@ -1,16 +0,0 @@
SELECT
SUM(
CASE
WHEN h.HOLDREASONNAME NOT IN ({{ NON_QUALITY_REASONS }}) THEN NVL(h.QTY, 0)
ELSE 0
END
) AS quality_count,
SUM(
CASE
WHEN h.HOLDREASONNAME IN ({{ NON_QUALITY_REASONS }}) THEN NVL(h.QTY, 0)
ELSE 0
END
) AS non_quality_count,
SUM(NVL(h.QTY, 0)) AS all_count
FROM DWH.DW_MES_HOLDRELEASEHISTORY h
WHERE h.RELEASETXNDATE IS NULL

View File

@@ -41,9 +41,8 @@ class TestHoldHistoryPageRoute(TestHoldHistoryRoutesBase):
class TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/trend endpoint."""
@patch('mes_dashboard.routes.hold_history_routes.get_still_on_hold_count')
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_trend')
def test_trend_passes_date_range(self, mock_trend, mock_count):
def test_trend_passes_date_range(self, mock_trend):
mock_trend.return_value = {
'days': [
{
@@ -54,16 +53,14 @@ class TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase):
}
]
}
mock_count.return_value = {'quality': 4, 'non_quality': 2, 'all': 6}
response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(payload['success'])
self.assertEqual(payload['data']['stillOnHoldCount'], {'quality': 4, 'non_quality': 2, 'all': 6})
self.assertIn('days', payload['data'])
mock_trend.assert_called_once_with('2026-02-01', '2026-02-07')
mock_count.assert_called_once_with()
def test_trend_invalid_date_returns_400(self):
response = self.client.get('/api/hold-history/trend?start_date=2026/02/01&end_date=2026-02-07')
@@ -72,10 +69,9 @@ class TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase):
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.get_still_on_hold_count')
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_trend')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 8))
def test_trend_rate_limited_returns_429(self, _mock_limit, mock_service, _mock_count):
def test_trend_rate_limited_returns_429(self, _mock_limit, mock_service):
response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07')
payload = json.loads(response.data)

View File

@@ -339,28 +339,6 @@ class TestHoldHistoryServiceFunctions(unittest.TestCase):
self.assertEqual(result['pagination']['total'], 3)
self.assertEqual(result['pagination']['totalPages'], 2)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_still_on_hold_count_formats_response(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame(
[{'QUALITY_COUNT': 4, 'NON_QUALITY_COUNT': 2, 'ALL_COUNT': 6}]
)
result = hold_history_service.get_still_on_hold_count()
self.assertIsNotNone(result)
self.assertEqual(result['quality'], 4)
self.assertEqual(result['non_quality'], 2)
self.assertEqual(result['all'], 6)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_still_on_hold_count_empty_returns_zeros(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame()
result = hold_history_service.get_still_on_hold_count()
self.assertIsNotNone(result)
self.assertEqual(result, {'quality': 0, 'non_quality': 0, 'all': 0})
def test_trend_sql_contains_shift_boundary_logic(self):
sql = hold_history_service._load_hold_history_sql('trend')