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