feat(hold-overview): add Hold Lot Overview page with TreeMap, Matrix, and cascade filtering

Provide managers with a dedicated page to analyze hold lots across all stations.
Extends existing service functions (get_hold_detail_summary, get_hold_detail_lots,
get_wip_matrix) with optional parameters for backward compatibility, adds one new
function (get_hold_overview_treemap), and registers the page in the portal navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-10 13:02:24 +08:00
parent af59031f95
commit 8225863a85
31 changed files with 3414 additions and 44 deletions

View File

@@ -12,6 +12,13 @@
"drawer_id": "reports", "drawer_id": "reports",
"order": 1 "order": 1
}, },
{
"route": "/hold-overview",
"name": "Hold 即時概況",
"status": "dev",
"drawer_id": "reports",
"order": 2
},
{ {
"route": "/wip-detail", "route": "/wip-detail",
"name": "WIP 明細", "name": "WIP 明細",
@@ -27,14 +34,14 @@
"name": "設備歷史績效", "name": "設備歷史績效",
"status": "released", "status": "released",
"drawer_id": "reports", "drawer_id": "reports",
"order": 3 "order": 4
}, },
{ {
"route": "/qc-gate", "route": "/qc-gate",
"name": "QC-GATE 狀態", "name": "QC-GATE 狀態",
"status": "released", "status": "released",
"drawer_id": "reports", "drawer_id": "reports",
"order": 4 "order": 5
}, },
{ {
"route": "/tables", "route": "/tables",
@@ -48,7 +55,7 @@
"name": "設備即時概況", "name": "設備即時概況",
"status": "released", "status": "released",
"drawer_id": "reports", "drawer_id": "reports",
"order": 2 "order": 3
}, },
{ {
"route": "/excel-query", "route": "/excel-query",

View File

@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.html", "build": "vite build && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-overview/index.html ../src/mes_dashboard/static/dist/hold-overview.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.html",
"test": "node --test tests/*.test.js" "test": "node --test tests/*.test.js"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,521 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js';
import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js';
import SummaryCards from '../hold-detail/components/SummaryCards.vue';
import FilterBar from './components/FilterBar.vue';
import FilterIndicator from './components/FilterIndicator.vue';
import HoldMatrix from './components/HoldMatrix.vue';
import HoldTreeMap from './components/HoldTreeMap.vue';
import LotTable from './components/LotTable.vue';
const API_TIMEOUT = 60000;
const DEFAULT_PER_PAGE = 50;
const summary = ref(null);
const matrix = ref(null);
const treemapItems = ref([]);
const lots = ref([]);
const filterBar = reactive({
holdType: 'quality',
reason: '',
});
const matrixFilter = ref(null);
const treemapFilter = ref(null);
const reasonOptions = ref([]);
const pagination = ref({
page: 1,
perPage: DEFAULT_PER_PAGE,
total: 0,
totalPages: 1,
});
const page = ref(1);
const initialLoading = ref(true);
const lotsLoading = ref(false);
const refreshing = ref(false);
const refreshSuccess = ref(false);
const refreshError = ref(false);
const errorMessage = ref('');
const lotsError = ref('');
let activeRequestId = 0;
const holdTypeLabel = computed(() => {
if (filterBar.holdType === 'non-quality') {
return '非品質異常';
}
if (filterBar.holdType === 'all') {
return '全部';
}
return '品質異常';
});
const hasCascadeFilters = computed(() => Boolean(matrixFilter.value || treemapFilter.value));
const lotFilterText = computed(() => {
const parts = [];
if (matrixFilter.value?.workcenter) {
parts.push(`Workcenter=${matrixFilter.value.workcenter}`);
}
if (matrixFilter.value?.package) {
parts.push(`Package=${matrixFilter.value.package}`);
}
if (treemapFilter.value?.reason) {
parts.push(`TreeMap Reason=${treemapFilter.value.reason}`);
}
return parts.join(', ');
});
const hasLotFilterText = computed(() => Boolean(lotFilterText.value));
const lastUpdate = computed(() => {
const value = summary.value?.dataUpdateDate;
return value ? `Last Update: ${value}` : '';
});
function nextRequestId() {
activeRequestId += 1;
return activeRequestId;
}
function isStaleRequest(requestId) {
return requestId !== activeRequestId;
}
function unwrapApiResult(result, fallbackMessage) {
if (result?.success) {
return result.data;
}
if (result?.success === false) {
throw new Error(result.error || fallbackMessage);
}
if (result?.data !== undefined) {
return result.data;
}
return result;
}
function buildFilterBarParams() {
const params = {
hold_type: filterBar.holdType || 'quality',
};
if (filterBar.reason) {
params.reason = filterBar.reason;
}
return params;
}
function buildMatrixFilterParams() {
const params = {};
if (matrixFilter.value?.workcenter) {
params.workcenter = matrixFilter.value.workcenter;
}
if (matrixFilter.value?.package) {
params.package = matrixFilter.value.package;
}
return params;
}
function buildTreemapParams() {
return {
...buildFilterBarParams(),
...buildMatrixFilterParams(),
};
}
function buildLotsParams() {
const params = {
...buildFilterBarParams(),
...buildMatrixFilterParams(),
page: page.value,
per_page: Number(pagination.value?.perPage || DEFAULT_PER_PAGE),
};
if (treemapFilter.value?.workcenter) {
params.workcenter = treemapFilter.value.workcenter;
}
if (treemapFilter.value?.reason) {
params.treemap_reason = treemapFilter.value.reason;
}
return params;
}
async function fetchSummary(signal) {
const result = await apiGet('/api/hold-overview/summary', {
params: buildFilterBarParams(),
timeout: API_TIMEOUT,
signal,
});
return unwrapApiResult(result, 'Failed to fetch hold summary');
}
async function fetchMatrix(signal) {
const result = await apiGet('/api/hold-overview/matrix', {
params: buildFilterBarParams(),
timeout: API_TIMEOUT,
signal,
});
return unwrapApiResult(result, 'Failed to fetch hold matrix');
}
async function fetchTreemap(signal) {
const result = await apiGet('/api/hold-overview/treemap', {
params: buildTreemapParams(),
timeout: API_TIMEOUT,
signal,
});
return unwrapApiResult(result, 'Failed to fetch hold treemap');
}
async function fetchLots(signal) {
const result = await apiGet('/api/hold-overview/lots', {
params: buildLotsParams(),
timeout: API_TIMEOUT,
signal,
});
return unwrapApiResult(result, 'Failed to fetch hold lots');
}
function updateLotsState(payload) {
lots.value = Array.isArray(payload?.lots) ? payload.lots : [];
pagination.value = {
page: Number(payload?.pagination?.page || page.value || 1),
perPage: Number(payload?.pagination?.perPage || DEFAULT_PER_PAGE),
total: Number(payload?.pagination?.total || 0),
totalPages: Number(payload?.pagination?.totalPages || 1),
};
page.value = pagination.value.page;
}
function updateReasonOptions(items) {
const unique = new Set();
const nextReasons = [];
(items || []).forEach((item) => {
const reason = String(item?.reason || '').trim();
if (!reason || unique.has(reason)) {
return;
}
unique.add(reason);
nextReasons.push(reason);
});
reasonOptions.value = nextReasons.sort((a, b) => a.localeCompare(b, 'zh-Hant'));
if (filterBar.reason && !unique.has(filterBar.reason)) {
filterBar.reason = '';
}
}
function showRefreshSuccess() {
refreshSuccess.value = true;
window.setTimeout(() => {
refreshSuccess.value = false;
}, 1500);
}
const { createAbortSignal, clearAbortController, triggerRefresh } = useAutoRefresh({
onRefresh: () => loadAllData(false),
autoStart: true,
});
async function loadAllData(showOverlay = true) {
const requestId = nextRequestId();
clearAbortController('hold-overview-treemap-lots');
clearAbortController('hold-overview-lots');
const signal = createAbortSignal('hold-overview-all');
if (showOverlay) {
initialLoading.value = true;
}
lotsLoading.value = true;
refreshing.value = true;
refreshError.value = false;
errorMessage.value = '';
lotsError.value = '';
try {
const [summaryData, matrixData, treemapData, lotsData] = await Promise.all([
fetchSummary(signal),
fetchMatrix(signal),
fetchTreemap(signal),
fetchLots(signal),
]);
if (isStaleRequest(requestId)) {
return;
}
summary.value = summaryData;
matrix.value = matrixData;
treemapItems.value = Array.isArray(treemapData?.items) ? treemapData.items : [];
updateLotsState(lotsData);
if (!matrixFilter.value) {
updateReasonOptions(treemapItems.value);
}
showRefreshSuccess();
} catch (error) {
if (error?.name === 'AbortError' || isStaleRequest(requestId)) {
return;
}
refreshError.value = true;
const message = error?.message || '載入資料失敗';
errorMessage.value = message;
lotsError.value = message;
} finally {
if (isStaleRequest(requestId)) {
return;
}
refreshing.value = false;
lotsLoading.value = false;
initialLoading.value = false;
}
}
async function loadTreemapAndLots() {
const requestId = nextRequestId();
clearAbortController('hold-overview-all');
clearAbortController('hold-overview-lots');
const signal = createAbortSignal('hold-overview-treemap-lots');
refreshing.value = true;
lotsLoading.value = true;
refreshError.value = false;
errorMessage.value = '';
lotsError.value = '';
try {
const [treemapData, lotsData] = await Promise.all([
fetchTreemap(signal),
fetchLots(signal),
]);
if (isStaleRequest(requestId)) {
return;
}
treemapItems.value = Array.isArray(treemapData?.items) ? treemapData.items : [];
updateLotsState(lotsData);
showRefreshSuccess();
} catch (error) {
if (error?.name === 'AbortError' || isStaleRequest(requestId)) {
return;
}
refreshError.value = true;
const message = error?.message || '載入 TreeMap/Lot 資料失敗';
errorMessage.value = message;
lotsError.value = message;
} finally {
if (isStaleRequest(requestId)) {
return;
}
refreshing.value = false;
lotsLoading.value = false;
}
}
async function loadLots() {
const requestId = nextRequestId();
clearAbortController('hold-overview-all');
clearAbortController('hold-overview-treemap-lots');
const signal = createAbortSignal('hold-overview-lots');
refreshing.value = true;
lotsLoading.value = true;
refreshError.value = false;
errorMessage.value = '';
lotsError.value = '';
try {
const lotsData = await fetchLots(signal);
if (isStaleRequest(requestId)) {
return;
}
updateLotsState(lotsData);
showRefreshSuccess();
} catch (error) {
if (error?.name === 'AbortError' || isStaleRequest(requestId)) {
return;
}
refreshError.value = true;
const message = error?.message || '載入 Lot 資料失敗';
errorMessage.value = message;
lotsError.value = message;
} finally {
if (isStaleRequest(requestId)) {
return;
}
refreshing.value = false;
lotsLoading.value = false;
}
}
function handleFilterChange(next) {
const nextHoldType = next?.holdType || 'quality';
const nextReason = next?.reason || '';
if (filterBar.holdType === nextHoldType && filterBar.reason === nextReason) {
return;
}
filterBar.holdType = nextHoldType;
filterBar.reason = nextReason;
matrixFilter.value = null;
treemapFilter.value = null;
page.value = 1;
void loadAllData(false);
}
function handleMatrixSelect(nextFilter) {
matrixFilter.value = nextFilter;
treemapFilter.value = null;
page.value = 1;
void loadTreemapAndLots();
}
function handleTreemapSelect(nextFilter) {
treemapFilter.value = nextFilter;
page.value = 1;
void loadLots();
}
function clearMatrixFilter() {
if (!matrixFilter.value) {
return;
}
matrixFilter.value = null;
treemapFilter.value = null;
page.value = 1;
void loadTreemapAndLots();
}
function clearTreemapFilter() {
if (!treemapFilter.value) {
return;
}
treemapFilter.value = null;
page.value = 1;
void loadLots();
}
function clearAllFilters() {
if (!hasCascadeFilters.value) {
return;
}
matrixFilter.value = null;
treemapFilter.value = null;
page.value = 1;
void loadTreemapAndLots();
}
function prevPage() {
if (page.value <= 1) {
return;
}
page.value -= 1;
void loadLots();
}
function nextPage() {
if (page.value >= Number(pagination.value?.totalPages || 1)) {
return;
}
page.value += 1;
void loadLots();
}
async function manualRefresh() {
await triggerRefresh({ resetTimer: true, force: true });
}
onMounted(() => {
void loadAllData(true);
});
</script>
<template>
<div class="dashboard hold-overview-page">
<header class="header">
<div class="header-left">
<a href="/wip-overview" class="btn btn-back">&larr; WIP Overview</a>
<h1>Hold Lot Overview</h1>
<span class="hold-type-badge">{{ holdTypeLabel }}</span>
</div>
<div class="header-right">
<span class="last-update">
<span class="refresh-indicator" :class="{ active: refreshing }"></span>
<span class="refresh-success" :class="{ active: refreshSuccess }">&#10003;</span>
<span class="refresh-error" :class="{ active: refreshError }"></span>
<span>{{ lastUpdate }}</span>
</span>
<button type="button" class="btn btn-light" @click="manualRefresh">重新整理</button>
</div>
</header>
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
<FilterBar
:hold-type="filterBar.holdType"
:reason="filterBar.reason"
:reasons="reasonOptions"
:disabled="refreshing && initialLoading"
@change="handleFilterChange"
/>
<SummaryCards :summary="summary" />
<section class="card">
<div class="card-header">
<div class="card-title">Workcenter x Package Matrix (QTY)</div>
</div>
<div class="card-body matrix-container">
<HoldMatrix :data="matrix" :active-filter="matrixFilter" @select="handleMatrixSelect" />
</div>
</section>
<FilterIndicator
:matrix-filter="matrixFilter"
:show-clear-all="true"
@clear-matrix="clearMatrixFilter"
@clear-treemap="clearTreemapFilter"
@clear-all="clearAllFilters"
/>
<section class="card">
<div class="card-header">
<div class="card-title">Workcenter Hold Reason TreeMap</div>
</div>
<div class="card-body treemap-body">
<HoldTreeMap
:items="treemapItems"
:active-filter="treemapFilter"
@select="handleTreemapSelect"
/>
</div>
</section>
<FilterIndicator
:treemap-filter="treemapFilter"
:show-clear-all="true"
@clear-matrix="clearMatrixFilter"
@clear-treemap="clearTreemapFilter"
@clear-all="clearAllFilters"
/>
<LotTable
:lots="lots"
:pagination="pagination"
:loading="lotsLoading"
:error-message="lotsError"
:has-active-filters="hasLotFilterText"
:filter-text="lotFilterText"
@clear-filters="clearAllFilters"
@prev-page="prevPage"
@next-page="nextPage"
/>
</div>
<div v-if="initialLoading" class="loading-overlay">
<span class="loading-spinner"></span>
<span>Loading...</span>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
holdType: {
type: String,
default: 'quality',
},
reason: {
type: String,
default: '',
},
reasons: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['change']);
const holdTypeModel = computed({
get() {
return props.holdType || 'quality';
},
set(nextValue) {
emit('change', {
holdType: nextValue || 'quality',
reason: props.reason || '',
});
},
});
const reasonModel = computed({
get() {
return props.reason || '';
},
set(nextValue) {
emit('change', {
holdType: props.holdType || 'quality',
reason: nextValue || '',
});
},
});
const reasonOptions = computed(() => {
const unique = new Set();
const items = [];
(props.reasons || []).forEach((reason) => {
const value = String(reason || '').trim();
if (!value || unique.has(value)) {
return;
}
unique.add(value);
items.push(value);
});
return items;
});
</script>
<template>
<section class="filter-bar card">
<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>
</div>
<div class="filter-group reason-group">
<label class="filter-label" for="hold-overview-reason">Reason</label>
<select
id="hold-overview-reason"
v-model="reasonModel"
class="reason-select"
:disabled="disabled"
>
<option value="">全部</option>
<option v-for="item in reasonOptions" :key="item" :value="item">
{{ item }}
</option>
</select>
</div>
</section>
</template>

View File

@@ -0,0 +1,77 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
matrixFilter: {
type: Object,
default: null,
},
treemapFilter: {
type: Object,
default: null,
},
showClearAll: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['clear-matrix', 'clear-treemap', 'clear-all']);
const matrixLabel = computed(() => {
const filter = props.matrixFilter;
if (!filter) {
return '';
}
const parts = [];
if (filter.workcenter) {
parts.push(`Workcenter=${filter.workcenter}`);
}
if (filter.package) {
parts.push(`Package=${filter.package}`);
}
return parts.join(', ');
});
const treemapLabel = computed(() => {
const filter = props.treemapFilter;
if (!filter) {
return '';
}
const parts = [];
if (filter.workcenter) {
parts.push(`Workcenter=${filter.workcenter}`);
}
if (filter.reason) {
parts.push(`Reason=${filter.reason}`);
}
return parts.join(', ');
});
const hasMatrixFilter = computed(() => Boolean(matrixLabel.value));
const hasTreemapFilter = computed(() => Boolean(treemapLabel.value));
const hasAnyFilter = computed(() => hasMatrixFilter.value || hasTreemapFilter.value);
</script>
<template>
<section v-if="hasAnyFilter" class="cascade-filter-indicator">
<div v-if="hasMatrixFilter" class="filter-chip matrix">
<span>Matrix 篩選{{ matrixLabel }}</span>
<button type="button" class="chip-clear" @click="emit('clear-matrix')">×</button>
</div>
<div v-if="hasTreemapFilter" class="filter-chip treemap">
<span>TreeMap 篩選{{ treemapLabel }}</span>
<button type="button" class="chip-clear" @click="emit('clear-treemap')">×</button>
</div>
<button
v-if="showClearAll"
type="button"
class="btn btn-secondary clear-all-btn"
@click="emit('clear-all')"
>
清除所有篩選
</button>
</section>
</template>

View File

@@ -0,0 +1,149 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
data: {
type: Object,
default: null,
},
activeFilter: {
type: Object,
default: null,
},
});
const emit = defineEmits(['select']);
const workcenters = computed(() => props.data?.workcenters || []);
const packages = computed(() => (props.data?.packages || []).slice(0, 15));
const normalizedActiveFilter = computed(() => normalizeFilter(props.activeFilter));
function normalizeFilter(filter) {
if (!filter || typeof filter !== 'object') {
return null;
}
const workcenter = String(filter.workcenter || '').trim() || null;
const pkg = String(filter.package || '').trim() || null;
if (!workcenter && !pkg) {
return null;
}
return { workcenter, package: pkg };
}
function isSameFilter(target) {
const current = normalizedActiveFilter.value;
const next = normalizeFilter(target);
if (!current && !next) {
return true;
}
if (!current || !next) {
return false;
}
return current.workcenter === next.workcenter && current.package === next.package;
}
function emitSelection(target) {
if (isSameFilter(target)) {
emit('select', null);
return;
}
emit('select', normalizeFilter(target));
}
function formatNumber(value) {
if (!value) {
return '-';
}
return Number(value).toLocaleString('zh-TW');
}
function getMatrixValue(workcenter, pkg) {
return Number(props.data?.matrix?.[workcenter]?.[pkg] || 0);
}
function isRowActive(workcenter) {
const filter = normalizedActiveFilter.value;
return Boolean(filter && filter.workcenter === workcenter && !filter.package);
}
function isColumnActive(pkg) {
const filter = normalizedActiveFilter.value;
return Boolean(filter && filter.package === pkg && !filter.workcenter);
}
function isCellActive(workcenter, pkg) {
const filter = normalizedActiveFilter.value;
return Boolean(filter && filter.workcenter === workcenter && filter.package === pkg);
}
function onCellClick(workcenter, pkg) {
emitSelection({ workcenter, package: pkg });
}
function onWorkcenterClick(workcenter) {
emitSelection({ workcenter });
}
function onPackageClick(pkg) {
emitSelection({ package: pkg });
}
</script>
<template>
<div v-if="workcenters.length === 0" class="placeholder">No data available</div>
<table v-else class="matrix-table hold-matrix-table">
<thead>
<tr>
<th>Workcenter</th>
<th
v-for="pkg in packages"
:key="pkg"
:class="['clickable', { active: isColumnActive(pkg) }]"
@click="onPackageClick(pkg)"
>
{{ pkg }}
</th>
<th class="total-col clickable" @click="emitSelection(null)">Total</th>
</tr>
</thead>
<tbody>
<tr v-for="workcenter in workcenters" :key="workcenter" :class="{ active: isRowActive(workcenter) }">
<td class="clickable row-name" @click="onWorkcenterClick(workcenter)">
{{ workcenter }}
</td>
<td
v-for="pkg in packages"
:key="`${workcenter}-${pkg}`"
:class="['clickable', { active: isCellActive(workcenter, pkg) }]"
@click="onCellClick(workcenter, pkg)"
>
{{ formatNumber(getMatrixValue(workcenter, pkg)) }}
</td>
<td
class="total-col clickable"
:class="{ active: isRowActive(workcenter) }"
@click="onWorkcenterClick(workcenter)"
>
{{ formatNumber(data?.workcenter_totals?.[workcenter]) }}
</td>
</tr>
<tr class="total-row">
<td class="clickable" @click="emitSelection(null)">Total</td>
<td
v-for="pkg in packages"
:key="`total-${pkg}`"
class="clickable"
:class="{ active: isColumnActive(pkg) }"
@click="onPackageClick(pkg)"
>
{{ formatNumber(data?.package_totals?.[pkg]) }}
</td>
<td class="total-col clickable" @click="emitSelection(null)">
{{ formatNumber(data?.grand_total) }}
</td>
</tr>
</tbody>
</table>
</template>

View File

@@ -0,0 +1,225 @@
<script setup>
import { computed } from 'vue';
import VChart from 'vue-echarts';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { TreemapChart } from 'echarts/charts';
import { TooltipComponent, VisualMapComponent } from 'echarts/components';
use([CanvasRenderer, TreemapChart, TooltipComponent, VisualMapComponent]);
const props = defineProps({
items: {
type: Array,
default: () => [],
},
activeFilter: {
type: Object,
default: null,
},
});
const emit = defineEmits(['select']);
const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0);
const normalizedActiveFilter = computed(() => normalizeFilter(props.activeFilter));
function normalizeFilter(filter) {
if (!filter || typeof filter !== 'object') {
return null;
}
const workcenter = String(filter.workcenter || '').trim() || null;
const reason = String(filter.reason || '').trim() || null;
if (!workcenter || !reason) {
return null;
}
return { workcenter, reason };
}
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
function buildLeafItem(item, activeFilter) {
const workcenter = String(item?.workcenter || '').trim();
const reason = String(item?.reason || '').trim();
const lots = Number(item?.lots || 0);
const qty = Number(item?.qty || 0);
const avgAge = Number(item?.avgAge || 0);
const isActive = Boolean(
activeFilter &&
activeFilter.workcenter === workcenter &&
activeFilter.reason === reason,
);
const isInactive = Boolean(activeFilter && !isActive);
return {
name: reason,
value: [qty, avgAge],
workcenter,
reason,
lots,
qty,
avgAge,
itemStyle: {
borderColor: isActive ? '#111827' : '#ffffff',
borderWidth: isActive ? 3 : 1,
opacity: isInactive ? 0.72 : 1,
},
};
}
const treeData = computed(() => {
const activeFilter = normalizedActiveFilter.value;
const workcenterMap = new Map();
(props.items || []).forEach((item) => {
const workcenter = String(item?.workcenter || '').trim();
const reason = String(item?.reason || '').trim();
if (!workcenter || !reason) {
return;
}
if (!workcenterMap.has(workcenter)) {
workcenterMap.set(workcenter, {
name: workcenter,
children: [],
});
}
const parent = workcenterMap.get(workcenter);
parent.children.push(buildLeafItem(item, activeFilter));
});
const data = Array.from(workcenterMap.values());
data.forEach((parent) => {
parent.value = parent.children.reduce((sum, child) => sum + Number(child.qty || 0), 0);
parent.children.sort((a, b) => Number(b.qty || 0) - Number(a.qty || 0));
});
return data.sort((a, b) => Number(b.value || 0) - Number(a.value || 0));
});
const chartOption = computed(() => ({
tooltip: {
confine: true,
formatter(params) {
const node = params?.data || {};
if (!node?.reason) {
return `<strong>${params?.name || ''}</strong>`;
}
return [
`<strong>${node.workcenter || '-'}</strong>`,
`Reason: ${node.reason || '-'}`,
`Lots: ${formatNumber(node.lots)}`,
`QTY: ${formatNumber(node.qty)}`,
`平均滯留: ${Number(node.avgAge || 0).toFixed(1)}`,
].join('<br/>');
},
},
visualMap: {
type: 'piecewise',
show: false,
dimension: 1,
pieces: [
{ lt: 1, color: '#22c55e' },
{ gte: 1, lt: 3, color: '#eab308' },
{ gte: 3, lt: 7, color: '#f97316' },
{ gte: 7, color: '#ef4444' },
],
},
series: [
{
name: 'Hold TreeMap',
type: 'treemap',
roam: false,
nodeClick: false,
breadcrumb: { show: false },
leafDepth: 1,
visualDimension: 1,
upperLabel: {
show: true,
height: 24,
color: '#0f172a',
fontWeight: 600,
},
label: {
show: true,
formatter(params) {
const reason = params?.data?.reason || params?.name || '';
return reason.length > 14 ? `${reason.slice(0, 14)}` : reason;
},
},
itemStyle: {
borderColor: '#ffffff',
borderWidth: 1,
gapWidth: 2,
},
levels: [
{
itemStyle: {
borderColor: '#d1d5db',
borderWidth: 1,
gapWidth: 2,
},
upperLabel: {
show: true,
},
},
{
itemStyle: {
borderColor: '#ffffff',
borderWidth: 1,
gapWidth: 1,
},
},
],
data: treeData.value,
},
],
}));
function handleChartClick(params) {
if (params.componentType !== 'series' || params.seriesType !== 'treemap') {
return;
}
const node = params?.data;
if (!node || !node.workcenter || !node.reason) {
return;
}
const next = {
workcenter: String(node.workcenter || ''),
reason: String(node.reason || ''),
};
const current = normalizedActiveFilter.value;
if (
current &&
current.workcenter === next.workcenter &&
current.reason === next.reason
) {
emit('select', null);
return;
}
emit('select', next);
}
</script>
<template>
<section class="treemap-section">
<div class="treemap-legend">
<span><i class="legend-color" style="--legend-color: #22c55e"></i>(&lt;1)</span>
<span><i class="legend-color" style="--legend-color: #eab308"></i>(1-3)</span>
<span><i class="legend-color" style="--legend-color: #f97316"></i>(3-7)</span>
<span><i class="legend-color" style="--legend-color: #ef4444"></i>(&gt;7)</span>
</div>
<VChart
v-if="hasData"
class="treemap-chart"
:option="chartOption"
autoresize
@click="handleChartClick"
/>
<div v-else class="placeholder treemap-empty">目前無 Hold 資料</div>
</section>
</template>

View File

@@ -0,0 +1,132 @@
<script setup>
import { computed } from 'vue';
import Pagination from '../../wip-shared/components/Pagination.vue';
const props = defineProps({
lots: {
type: Array,
default: () => [],
},
pagination: {
type: Object,
default: () => ({ page: 1, perPage: 50, total: 0, totalPages: 1 }),
},
loading: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
hasActiveFilters: {
type: Boolean,
default: false,
},
filterText: {
type: String,
default: '',
},
});
const emit = defineEmits(['clear-filters', 'prev-page', 'next-page']);
function formatNumber(value) {
if (value === null || value === undefined || value === '-') {
return '-';
}
return Number(value).toLocaleString('zh-TW');
}
function formatAge(value) {
if (value === null || value === undefined || value === '-') {
return '-';
}
return `${value}`;
}
const tableInfo = computed(() => {
const page = Number(props.pagination?.page || 1);
const perPage = Number(props.pagination?.perPage || 50);
const total = Number(props.pagination?.total || 0);
if (total <= 0) {
return 'No data';
}
const start = (page - 1) * perPage + 1;
const end = Math.min(page * perPage, total);
return `顯示 ${start} - ${end} / ${formatNumber(total)}`;
});
const pageInfo = computed(() => {
const page = Number(props.pagination?.page || 1);
const totalPages = Number(props.pagination?.totalPages || 1);
return `Page ${page} / ${totalPages}`;
});
</script>
<template>
<section class="table-section">
<div class="table-header">
<div class="table-title">Hold Lot Details</div>
<div v-if="hasActiveFilters" class="filter-indicator">
<span>篩選: {{ filterText }}</span>
<span class="clear-btn" @click="emit('clear-filters')">×</span>
</div>
<div class="table-info">{{ tableInfo }}</div>
</div>
<div class="table-container">
<table class="lot-table">
<thead>
<tr>
<th>LOTID</th>
<th>WORKORDER</th>
<th>QTY</th>
<th>Package</th>
<th>Workcenter</th>
<th>Hold Reason</th>
<th>Age</th>
<th>Hold By</th>
<th>Dept</th>
<th>Hold Comment</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="10" class="placeholder">Loading...</td>
</tr>
<tr v-else-if="errorMessage">
<td colspan="10" class="placeholder">{{ errorMessage }}</td>
</tr>
<tr v-else-if="lots.length === 0">
<td colspan="10" class="placeholder">No data</td>
</tr>
<tr v-for="lot in lots" v-else :key="lot.lotId">
<td>{{ lot.lotId || '-' }}</td>
<td>{{ lot.workorder || '-' }}</td>
<td>{{ formatNumber(lot.qty) }}</td>
<td>{{ lot.package || '-' }}</td>
<td>{{ lot.workcenter || '-' }}</td>
<td>{{ lot.holdReason || '-' }}</td>
<td>{{ formatAge(lot.age) }}</td>
<td>{{ lot.holdBy || '-' }}</td>
<td>{{ lot.dept || '-' }}</td>
<td>{{ lot.holdComment || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:visible="Number(pagination.totalPages || 1) > 1"
:page="Number(pagination.page || 1)"
:total-pages="Number(pagination.totalPages || 1)"
:info-text="pageInfo"
@prev="emit('prev-page')"
@next="emit('next-page')"
/>
</section>
</template>

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hold Overview</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import App from './App.vue';
import '../wip-shared/styles.css';
import './style.css';
createApp(App).mount('#app');

View File

@@ -0,0 +1,374 @@
.hold-overview-page .header h1 {
font-size: 22px;
}
.hold-type-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.error-banner {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 6px;
background: #fef2f2;
color: #991b1b;
font-size: 13px;
}
.hold-summary-row {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.card {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 14px;
}
.card-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: #fafbfc;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.card-body {
padding: 16px;
}
.filter-bar {
display: flex;
align-items: flex-end;
gap: 20px;
padding: 16px 20px;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.radio-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.radio-option {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
background: #fff;
}
.radio-option.active {
border-color: var(--primary);
background: #eef2ff;
color: var(--primary-dark);
font-weight: 600;
}
.radio-option input {
margin: 0;
}
.reason-select {
min-width: 240px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
background: #fff;
}
.reason-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.matrix-container {
padding: 0;
max-height: 460px;
overflow: auto;
}
.hold-matrix-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.hold-matrix-table th,
.hold-matrix-table td {
padding: 8px 10px;
text-align: right;
border: 1px solid #e5e7eb;
white-space: nowrap;
}
.hold-matrix-table th {
background: #f3f4f6;
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
border-bottom: 2px solid #cbd5e1;
}
.hold-matrix-table th:first-child {
text-align: left;
position: sticky;
left: 0;
z-index: 3;
background: #e5e7eb;
border-right: 2px solid #cbd5e1;
}
.hold-matrix-table td:first-child {
text-align: left;
font-weight: 600;
position: sticky;
left: 0;
z-index: 1;
background: #f9fafb;
border-right: 2px solid #cbd5e1;
}
.hold-matrix-table .clickable {
cursor: pointer;
}
.hold-matrix-table .clickable:hover {
background: #eef2ff;
}
.hold-matrix-table .total-row td,
.hold-matrix-table .total-col {
background: #e5e7eb;
font-weight: 700;
}
.hold-matrix-table tr.active td {
background: #e0e7ff;
}
.hold-matrix-table tr.active td:first-child {
background: #c7d2fe;
}
.hold-matrix-table td.active,
.hold-matrix-table th.active {
background: #c7d2fe;
color: #1e3a8a;
font-weight: 700;
}
.cascade-filter-indicator {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin: 2px 0 14px;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.filter-chip.matrix {
background: #e0e7ff;
color: #3730a3;
}
.filter-chip.treemap {
background: #fee2e2;
color: #991b1b;
}
.chip-clear {
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
line-height: 1;
color: inherit;
}
.clear-all-btn {
padding: 6px 12px;
font-size: 12px;
}
.treemap-body {
padding: 12px 14px 14px;
}
.treemap-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.treemap-chart {
width: 100%;
height: clamp(300px, 44vh, 540px);
}
.treemap-legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 12px;
color: #475569;
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 5px;
background: var(--legend-color);
}
.treemap-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
}
.table-section {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: #fafbfc;
flex-wrap: wrap;
gap: 12px;
}
.table-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.table-info {
font-size: 13px;
color: var(--muted);
}
.table-container {
overflow-x: auto;
max-height: 520px;
}
.lot-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.lot-table th,
.lot-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
white-space: nowrap;
}
.lot-table th {
background: #f8f9fa;
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
.lot-table th:nth-child(3),
.lot-table th:nth-child(7),
.lot-table td:nth-child(3),
.lot-table td:nth-child(7) {
text-align: right;
}
.lot-table tbody tr:hover {
background: #f8f9fc;
}
@media (max-width: 1450px) {
.hold-summary-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1000px) {
.hold-summary-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.reason-select {
width: 100%;
min-width: 0;
}
}
@media (max-width: 768px) {
.dashboard.hold-overview-page {
padding: 14px;
}
.hold-summary-row {
grid-template-columns: 1fr;
}
.header {
padding: 14px;
}
}

View File

@@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => ({
'wip-overview': resolve(__dirname, 'src/wip-overview/index.html'), 'wip-overview': resolve(__dirname, 'src/wip-overview/index.html'),
'wip-detail': resolve(__dirname, 'src/wip-detail/index.html'), 'wip-detail': resolve(__dirname, 'src/wip-detail/index.html'),
'hold-detail': resolve(__dirname, 'src/hold-detail/index.html'), 'hold-detail': resolve(__dirname, 'src/hold-detail/index.html'),
'hold-overview': resolve(__dirname, 'src/hold-overview/index.html'),
'resource-status': resolve(__dirname, 'src/resource-status/index.html'), 'resource-status': resolve(__dirname, 'src/resource-status/index.html'),
'resource-history': resolve(__dirname, 'src/resource-history/index.html'), 'resource-history': resolve(__dirname, 'src/resource-history/index.html'),
'job-query': resolve(__dirname, 'src/job-query/main.js'), 'job-query': resolve(__dirname, 'src/job-query/main.js'),

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-10

View File

@@ -0,0 +1,127 @@
## Context
MES Dashboard 已有 WIP Overview全局 RUN/QUEUE/HOLD 概況)和 Hold Detail單一 Hold Reason 明細)。主管需要一個專用頁面,聚焦在 Hold Lot 的全局分析。
現有架構Vue 3 SFC + Flask + OracleDWH.DW_MES_LOT_V使用 Redis cache + snapshot indexes 加速查詢。經審計wip_service.py 中已有大量可複用的函數和 cache 基礎設施:
**可直接呼叫**:
- `get_wip_matrix(status='HOLD', hold_type=...)` — Matrix 查詢,零改動
- `_select_with_snapshot_indexes(status='HOLD', hold_type=...)` — 已有 `wip_status['HOLD']``hold_type['quality'|'non-quality']` snapshot indexes
- `_get_wip_dataframe()` — L1 process cache (30s) → L2 Redis cache`AGEBYDAYS` 欄位已在 View 中預先計算
**可擴充(向後相容)**:
- `get_hold_detail_summary(reason)` — 已有 totalLots/totalQty/avgAge/maxAge/workcenterCountreason 改 optional 即可
- `get_hold_detail_lots(reason, ...)` — 已有完整分頁邏輯 + workcenter/package/age_range 過濾reason 改 optional + 加 hold_type 即可
**前端可直接 import**:
- `hold-detail/SummaryCards.vue` — props `{ totalLots, totalQty, avgAge, maxAge, workcenterCount }` 完全相容
- `wip-shared/Pagination.vue``useAutoRefresh``core/api.js``wip-shared/constants.js`
## Goals / Non-Goals
**Goals:**
- 提供主管一覽各站 Hold Lot 情況的獨立頁面
- TreeMap 視覺化讓嚴重程度一目了然(面積=QTY顏色=滯留天數)
- Matrix + TreeMap + Table 三層 cascade 篩選,互動流暢
- 預設品質異常 Hold可切換
- 最大化複用現有 service 函數、cache 基礎設施和前端元件
**Non-Goals:**
- 不取代或修改現有 Hold Detail 頁面(擴充 service 函數需向後相容)
- 不新增資料庫 view 或 table — 完全複用 DWH.DW_MES_LOT_V
- 不新增 SQL 模板 — 複用現有 summary.sql / matrix.sql / detail.sql + QueryBuilder WHERE clause
- 不實作 autocomplete 搜尋(篩選僅 Hold Type + Reason dropdown
- 不實作 Lot 點擊展開的 detail panel明細表為純展示
## Decisions
### D1: 擴充現有 service 函數,非新建
**決定**: 擴充 `get_hold_detail_summary()``get_hold_detail_lots()` 的參數簽名,而非建立新的 `get_hold_overview_summary()` / `get_hold_overview_lots()`。唯一全新的函數是 `get_hold_overview_treemap()`WC × Reason 聚合邏輯不存在於現有函數)。
**擴充方式**:
- `get_hold_detail_summary(reason)``get_hold_detail_summary(reason=None, hold_type=None)`
- `reason=None` 時聚合所有 HOLD lots
- `hold_type='quality'|'non-quality'` 進一步過濾
- 原有 Hold Detail 呼叫 `get_hold_detail_summary(reason='xxx')` 行為不變
- `get_hold_detail_lots(reason, ...)``get_hold_detail_lots(reason=None, hold_type=None, treemap_reason=None, ...)`
- `reason=None` 時返回所有 HOLD lots
- `treemap_reason` 支援 TreeMap 點擊篩選
- 現有 Hold Detail 呼叫簽名不受影響
**理由**: 這兩個函數的核心邏輯cache path + Oracle fallback + 分頁 + 過濾)完全相同,差異僅在 reason 是否為必填。新建函數會複製 80%+ 相同邏輯,增加維護負擔。
**替代方案**: 新建獨立函數 — 但會造成大量重複的 cache 查詢路徑和 Oracle fallback 邏輯。
### D2: Matrix API 直接呼叫 get_wip_matrix僅擴充 reason 參數
**決定**: Hold Overview 的 Matrix API 直接呼叫現有 `get_wip_matrix(status='HOLD', hold_type=...)` 函數。唯一需要擴充的是新增 optional `reason` 參數,支援 Filter Bar 的 Reason 篩選。
**理由**: `get_wip_matrix` 已支援 `status``hold_type` 參數,能完全滿足 Hold Overview Matrix 的需求。擴充 reason 參數的改動量極小,且 reason=None 時行為與現有完全一致。
**替代方案**: 複製 matrix 邏輯到新函數 — 違反 DRY且 matrix 排序/分頁邏輯會分散維護。
### D3: TreeMap 聚合是唯一需要全新建立的 service 函數
**決定**: 新增 `get_hold_overview_treemap()` 函數,後端返回已聚合的 `{ workcenter, reason, qty, lots, avgAge }` 陣列。此函數使用與其他函數相同的 `_select_with_snapshot_indexes()` + Oracle fallback 模式。
**理由**: 現有函數都沒有「按 (WORKCENTER_GROUP, HOLDREASONNAME) 二維聚合」的邏輯。這是 TreeMap 視覺化特有的需求值得獨立建立。Hold Lot 可能數千筆,前端 groupBy 聚合會造成不必要的資料傳輸和 CPU 開銷。
**替代方案**: 前端從 lots API 取全部資料後自行聚合 — 資料量大時效能差,且需一次載入所有 lot。
### D4: TreeMap 顏色映射使用固定 4 級色階
**決定**: 平均滯留天數映射到 4 個顏色等級:
- `< 1 天` → 綠色 (#22c55e)
- `1-3 天` → 黃色 (#eab308)
- `3-7 天` → 橙色 (#f97316)
- `> 7 天` → 紅色 (#ef4444)
ECharts TreeMap 使用 `visualMap` 組件實現連續色階。
**理由**: 與 Hold Detail 的 Age Distribution 分段一致0-1, 1-3, 3-7, 7+),主管認知模型統一。
### D5: Filter cascade 為前端狀態管理,不影響 Summary 和 Matrix 的 API 呼叫
**決定**:
- Filter BarHold Type / Reason變更 → 呼叫全部 4 支 API
- Matrix 點擊 → 前端設定 `matrixFilter`,僅呼叫 treemap + lots API
- TreeMap 點擊 → 前端設定 `treemapFilter`,僅呼叫 lots API
**理由**: Summary 和 Matrix 反映全局數據,不應被 Matrix/TreeMap 的 drilldown 操作影響。這與 WIP Overview 的 StatusCards 不影響 Summary 的模式一致。
### D6: 路由結構與 Blueprint 獨立
**決定**: 新建 `hold_overview_routes.py` 作為獨立 Blueprint`hold_overview_bp`),路由前綴 `/api/hold-overview/`。頁面路由 `GET /hold-overview` 由此 Blueprint 提供。
**理由**: 與 `hold_routes.py`Hold Detail`wip_routes.py`WIP Overview平行職責分離。
### D7: 前端元件複用策略 — import > 擴充 > 新建
**決定**:
| 元件 | 策略 | 理由 |
|------|------|------|
| `hold-detail/SummaryCards.vue` | **直接 import** | props 形狀 `{ totalLots, totalQty, avgAge, maxAge, workcenterCount }` 完全相容 |
| `wip-shared/Pagination.vue` | **直接 import** | 已由 hold-detail/LotTable 使用,通用元件 |
| `wip-overview/MatrixTable.vue` | **參考新建 `HoldMatrix.vue`** | 需要 cell click + column click + active highlight — 原有只有 row drilldown改動幅度大不適合直接修改原件 |
| `hold-detail/LotTable.vue` | **參考新建 `LotTable.vue`** | 需加 Hold Reason 欄位 + 移除 Spec 欄位 — hold-detail 不需要 reason 欄(已在 URL 參數中),直接修改會破壞現有頁面 |
| `HoldTreeMap.vue` | **全新** | 無現有 TreeMap 元件 |
| `FilterBar.vue` | **全新** | Hold Type radio + Reason dropdown 是此頁獨有的 UI |
| `FilterIndicator.vue` | **全新** | cascade filter 指示器是此頁獨有的 UI |
**理由**: 直接修改跨頁面共用的元件有破壞現有頁面的風險。props 完全相容的元件直接 import需要結構性改動的元件則基於現有程式碼新建保留一致的 coding pattern 但避免耦合。
### D8: ECharts TreeMap 模組 tree-shaking
**決定**: 前端使用 `import { TreemapChart } from 'echarts/charts'` 按需導入,搭配現有 `vendor-echarts` chunk。
**理由**: 現有 ECharts vendor chunk 已包含 BarChart、LineChart 等。TreemapChart 加入後仍在同一 chunk不增加額外 HTTP request。
## Risks / Trade-offs
- **[向後相容]** 擴充 `get_hold_detail_summary()``get_hold_detail_lots()` 簽名時,必須確保 reason 參數的預設行為不變 → 使用 `reason=None` 預設值,現有呼叫端傳入 reason 的行為完全不變;需補充單元測試覆蓋原有 Hold Detail 的呼叫路徑
- **[TreeMap 資料量]** 如果 Hold Reason 種類很多(>20TreeMap 小區塊會難以辨識 → 可考慮只顯示 Top N reason其餘歸為「其他」
- **[Matrix 與 TreeMap 同時篩選]** 使用者可能忘記已有 matrix 篩選,誤以為 TreeMap 是全局 → 需要明確的 active filter 指示器和一鍵清除功能
- **[ECharts TreeMap 效能]** 大量區塊時 TreeMap 渲染可能卡頓 → ECharts TreeMap 有內建 leafDepth 限制,測試時注意超過 200 個葉節點的情境
- **[Cache 一致性]** Hold Overview 與 WIP Overview 共用同一份 cacheauto-refresh 週期相同10 分鐘),不需調整 cache 策略

View File

@@ -0,0 +1,36 @@
## Why
主管需要一個獨立頁面,專注於線上 Hold Lot 的全局觀。目前 WIP Overview 的 Pareto 圖混合在所有 WIP 資料中,而 Hold Detail 只能看單一 Hold Reason 的明細。缺少一個可以「一覽各站 Hold Lot 情況」的專用分析頁面,讓主管能快速掌握哪些站別、哪些原因造成最多 Hold以及滯留嚴重程度。
## What Changes
- 新增 `/hold-overview` 頁面Vue 3 SFC + ECharts TreeMap獨立於現有 WIP Overview 和 Hold Detail
- 新增 Flask Blueprint 與 4 支 API endpointssummary / matrix / treemap / lots
- 頁面預設只顯示品質異常 Hold可切換至非品質異常或全部
- 提供 Workcenter x Package Matrix如 WIP Overview數字可點擊篩選下方所有資料
- 提供 TreeMap 視覺化WC → Reason 層級,面積=QTY顏色=平均滯留天數)
- 提供 paginated Hold Lot 明細表
- 篩選 cascade 機制Filter Bar → 全部重載Matrix 點擊 → TreeMap + TableTreeMap 點擊 → Table
- 新增 Vite multi-entry 設定
## Capabilities
### New Capabilities
- `hold-overview-page`: Hold Lot Overview 頁面的完整功能規格包含篩選器、Summary Cards、Matrix、TreeMap、明細表及 filter cascade 互動邏輯
- `hold-overview-api`: Hold Overview 後端 API 端點summary / matrix / treemap / lots從 DWH.DW_MES_LOT_V 查詢 Hold Lot 資料
### Modified Capabilities
- `vue-vite-page-architecture`: 新增 `hold-overview` 作為 Vite multi-entry HTML entry point
## Impact
- **Backend擴充現有**: 擴充 `wip_service.py``get_hold_detail_summary()``get_hold_detail_lots()` — 將 `reason` 改為 optional 並新增 `hold_type` 參數,向後相容;擴充 `get_wip_matrix()` 新增 optional `reason` 參數
- **Backend唯一新增函數**: `get_hold_overview_treemap()` — WC × Reason 聚合 + avgAge 計算
- **Backend新增路由**: `src/mes_dashboard/routes/hold_overview_routes.py`Flask Blueprint4 支 API
- **Frontend直接複用**: `hold-detail/SummaryCards.vue``wip-shared/Pagination.vue``useAutoRefresh``core/api.js``wip-shared/constants.js`
- **Frontend基於現有擴充**: 基於 `MatrixTable.vue``HoldMatrix.vue`(加 cell/column click基於 `hold-detail/LotTable.vue``LotTable.vue`(加 Hold Reason 欄位)
- **Frontend全新元件**: `HoldTreeMap.vue``FilterBar.vue``FilterIndicator.vue`
- **Vite Config**: `vite.config.js` 新增 `hold-overview` entry
- **Dependencies**: `echarts/charts``TreemapChart`(進入現有 `vendor-echarts` chunk
- **Cache**: 完全複用現有 Redis cache + snapshot indexes已有 `wip_status['HOLD']``hold_type['quality'|'non-quality']` 索引),零改動
- **SQL**: 不需新增 SQL 模板 — 複用現有 summary.sql / matrix.sql / detail.sql + QueryBuilder WHERE clause

View File

@@ -0,0 +1,137 @@
## ADDED Requirements
### Requirement: Hold Overview API SHALL provide summary statistics
The API SHALL return aggregated summary KPIs for hold lots.
#### Scenario: Summary endpoint delegates to extended get_hold_detail_summary
- **WHEN** `GET /api/hold-overview/summary` is called with `hold_type=quality`
- **THEN** the route SHALL delegate to the extended `get_hold_detail_summary(reason=None, hold_type='quality')`
- **THEN** the response SHALL return `{ success: true, data: { totalLots, totalQty, workcenterCount, avgAge, maxAge, dataUpdateDate } }`
- **THEN** only lots with EQUIPMENTCOUNT=0 AND CURRENTHOLDCOUNT>0 SHALL be included
- **THEN** hold_type classification SHALL use the same NON_QUALITY_HOLD_REASONS set as existing hold endpoints
#### Scenario: Summary with reason filter
- **WHEN** `GET /api/hold-overview/summary?hold_type=quality&reason=品質確認` is called
- **THEN** the response SHALL only include lots where HOLDREASONNAME equals the specified reason
#### Scenario: Summary hold_type=all
- **WHEN** `GET /api/hold-overview/summary?hold_type=all` is called
- **THEN** the response SHALL include both quality and non-quality hold lots
#### Scenario: Summary error
- **WHEN** the database query fails
- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500
### Requirement: Hold Overview API SHALL provide workcenter x package matrix
The API SHALL return a cross-tabulation of workcenters and packages for hold lots.
#### Scenario: Matrix endpoint
- **WHEN** `GET /api/hold-overview/matrix` is called with `hold_type=quality`
- **THEN** the response SHALL return the same matrix structure as `/api/wip/overview/matrix`: `{ workcenters, packages, matrix, workcenter_totals, package_totals, grand_total }`
- **THEN** workcenters SHALL be sorted by WORKCENTERSEQUENCE_GROUP
- **THEN** packages SHALL be sorted by total QTY descending
- **THEN** only HOLD status lots matching the hold_type SHALL be included
#### Scenario: Matrix delegates to existing get_wip_matrix
- **WHEN** the matrix endpoint is called
- **THEN** it SHALL delegate to `get_wip_matrix(status='HOLD', hold_type=...)` from wip_service.py
#### Scenario: Matrix with reason filter
- **WHEN** `GET /api/hold-overview/matrix?hold_type=quality&reason=品質確認` is called
- **THEN** the matrix SHALL only include lots where HOLDREASONNAME equals the specified reason
### Requirement: Hold Overview API SHALL provide TreeMap aggregation data
The API SHALL return aggregated data suitable for TreeMap visualization.
#### Scenario: TreeMap endpoint uses new get_hold_overview_treemap function
- **WHEN** `GET /api/hold-overview/treemap` is called with `hold_type=quality`
- **THEN** the route SHALL delegate to `get_hold_overview_treemap()` (the only new service function)
- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }`
- **THEN** each item SHALL contain `{ workcenter, reason, lots, qty, avgAge }`
- **THEN** items SHALL be grouped by (WORKCENTER_GROUP, HOLDREASONNAME)
- **THEN** avgAge SHALL be calculated using the pre-computed AGEBYDAYS column from DW_MES_LOT_V
#### Scenario: TreeMap with matrix filter
- **WHEN** `GET /api/hold-overview/treemap?hold_type=quality&workcenter=WC-MOLD&package=PKG-A` is called
- **THEN** the response SHALL only include lots matching the workcenter AND package filters
#### Scenario: TreeMap with reason filter
- **WHEN** `GET /api/hold-overview/treemap?hold_type=quality&reason=品質確認` is called
- **THEN** the response SHALL only include lots where HOLDREASONNAME equals the specified reason
#### Scenario: TreeMap empty result
- **WHEN** no hold lots match the filters
- **THEN** the response SHALL return `{ success: true, data: { items: [] } }`
### Requirement: Hold Overview API SHALL provide paginated lot details
The API SHALL return a paginated list of hold lot details.
#### Scenario: Lots endpoint delegates to extended get_hold_detail_lots
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&page=1&per_page=50` is called
- **THEN** the route SHALL delegate to the extended `get_hold_detail_lots(reason=None, hold_type='quality', ...)`
- **THEN** the response SHALL return `{ success: true, data: { lots: [...], pagination: { page, perPage, total, totalPages } } }`
- **THEN** each lot SHALL contain: lotId, workorder, qty, package, workcenter, holdReason, age, holdBy, dept, holdComment
- **THEN** lots SHALL be sorted by age descending (longest hold first)
#### Scenario: Lots with matrix filter
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&workcenter=WC-MOLD&package=PKG-A` is called
- **THEN** only lots matching the workcenter AND package filters SHALL be returned
#### Scenario: Lots with treemap filter
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&workcenter=WC-MOLD&treemap_reason=品質確認` is called
- **THEN** only lots matching the workcenter AND treemap_reason SHALL be returned
#### Scenario: Lots with all filters combined
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&reason=品質確認&workcenter=WC-MOLD&package=PKG-A&treemap_reason=品質確認` is called
- **THEN** all filters SHALL be applied as AND conditions
#### Scenario: Lots pagination bounds
- **WHEN** `page` is less than 1
- **THEN** page SHALL be treated as 1
- **WHEN** `per_page` exceeds 200
- **THEN** per_page SHALL be capped at 200
#### Scenario: Lots error
- **WHEN** the database query fails
- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500
### Requirement: Hold Overview API SHALL apply rate limiting
The API SHALL apply rate limiting to expensive endpoints.
#### Scenario: Rate limit on lots endpoint
- **WHEN** the lots endpoint receives excessive requests
- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 90 requests per 60 seconds
#### Scenario: Rate limit on matrix endpoint
- **WHEN** the matrix endpoint receives excessive requests
- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 120 requests per 60 seconds
### Requirement: Hold Overview page route SHALL serve static Vite HTML
The Flask route SHALL serve the pre-built Vite HTML file.
#### Scenario: Page route
- **WHEN** user navigates to `/hold-overview`
- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/hold-overview.html` via `send_from_directory`
- **THEN** the HTML SHALL NOT pass through Jinja2 template rendering
#### Scenario: Fallback HTML
- **WHEN** the pre-built HTML file does not exist
- **THEN** Flask SHALL return a minimal HTML page with the correct script tag and module import
### Requirement: Extended service functions SHALL maintain backward compatibility
The extended `get_hold_detail_summary()`, `get_hold_detail_lots()`, and `get_wip_matrix()` SHALL not break existing callers.
#### Scenario: Hold Detail summary backward compatibility
- **WHEN** existing Hold Detail code calls `get_hold_detail_summary(reason='xxx')`
- **THEN** the result SHALL be identical to the pre-extension behavior
- **THEN** the new `hold_type` parameter SHALL default to None (no additional filtering)
#### Scenario: Hold Detail lots backward compatibility
- **WHEN** existing Hold Detail code calls `get_hold_detail_lots(reason='xxx', workcenter=..., package=..., age_range=...)`
- **THEN** the result SHALL be identical to the pre-extension behavior
- **THEN** the new `hold_type` and `treemap_reason` parameters SHALL default to None
#### Scenario: WIP Overview matrix backward compatibility
- **WHEN** existing WIP Overview code calls `get_wip_matrix(status='HOLD', hold_type='quality')`
- **THEN** the result SHALL be identical to the pre-extension behavior
- **THEN** the new `reason` parameter SHALL default to None (no HOLDREASONNAME filtering)

View File

@@ -0,0 +1,196 @@
## ADDED Requirements
### Requirement: Hold Overview page SHALL display a filter bar with Hold Type and Reason
The page SHALL provide a filter bar for selecting hold type and hold reason.
#### Scenario: Hold Type radio default
- **WHEN** the page loads
- **THEN** the Hold Type filter SHALL default to "品質異常"
- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部
#### Scenario: Hold Type change reloads all data
- **WHEN** user changes the Hold Type selection
- **THEN** all four API calls (summary, matrix, treemap, lots) SHALL reload with the new hold_type parameter
- **THEN** any active matrix and treemap filters SHALL be cleared
#### Scenario: Reason dropdown populated from current data
- **WHEN** summary data is loaded
- **THEN** the Reason dropdown SHALL display "全部" plus all distinct hold reasons from the treemap data
- **THEN** selecting a specific reason SHALL reload all four API calls filtered by that reason
- **THEN** any active matrix and treemap filters SHALL be cleared
### Requirement: Hold Overview page SHALL display summary KPI cards
The page SHALL show summary statistics for all hold lots matching the current filter.
#### Scenario: Summary cards rendering
- **WHEN** summary data is loaded from `GET /api/hold-overview/summary`
- **THEN** five cards SHALL display: Hold Lots, Hold QTY, 站別數, 平均滯留天數, 最大滯留天數
- **THEN** lot and QTY values SHALL use zh-TW number formatting
- **THEN** age values SHALL display with "天" suffix and one decimal place
#### Scenario: Summary reflects filter bar only
- **WHEN** user clicks a matrix cell or treemap block
- **THEN** summary cards SHALL NOT change (they only respond to filter bar changes)
### Requirement: Hold Overview page SHALL display a Workcenter x Package matrix
The page SHALL display a cross-tabulation matrix of workcenters vs packages for hold lots.
#### Scenario: Matrix table rendering
- **WHEN** matrix data is loaded from `GET /api/hold-overview/matrix`
- **THEN** the table SHALL display workcenters as rows and packages as columns
- **THEN** cell values SHALL show QTY with zh-TW number formatting
- **THEN** the first column (Workcenter) SHALL be sticky on horizontal scroll
- **THEN** a Total row and Total column SHALL be displayed
- **THEN** cells with zero value SHALL display "-"
#### Scenario: Matrix cell click filters TreeMap and lot table
- **WHEN** user clicks a QTY cell in the matrix (intersection of workcenter and package)
- **THEN** `matrixFilter` SHALL be set to `{ workcenter, package }`
- **THEN** TreeMap SHALL reload showing only data for that workcenter + package combination
- **THEN** lot table SHALL reload filtered by that workcenter + package
- **THEN** the clicked cell SHALL show an active highlight
#### Scenario: Matrix workcenter row click
- **WHEN** user clicks a workcenter name or its Total cell
- **THEN** `matrixFilter` SHALL be set to `{ workcenter }` (all packages)
- **THEN** TreeMap and lot table SHALL reload filtered by that workcenter
#### Scenario: Matrix package column click
- **WHEN** user clicks a package column header or its Total cell
- **THEN** `matrixFilter` SHALL be set to `{ package }` (all workcenters)
- **THEN** TreeMap and lot table SHALL reload filtered by that package
#### Scenario: Matrix click toggle
- **WHEN** user clicks the same cell/row/column that is already active
- **THEN** `matrixFilter` SHALL be cleared
- **THEN** TreeMap and lot table SHALL reload without matrix filter
#### Scenario: Matrix reflects filter bar only
- **WHEN** user clicks a treemap block
- **THEN** matrix SHALL NOT change (it only responds to filter bar changes)
### Requirement: Hold Overview page SHALL display active filter indicators
The page SHALL show a clear indicator of active cascade filters.
#### Scenario: Matrix filter indicator
- **WHEN** a matrix filter is active
- **THEN** a filter indicator SHALL display between the matrix and TreeMap sections
- **THEN** the indicator SHALL show the active workcenter and/or package name
- **THEN** a clear button (✕) SHALL remove the matrix filter
#### Scenario: TreeMap filter indicator
- **WHEN** a treemap filter is active
- **THEN** a filter indicator SHALL display between the TreeMap and lot table sections
- **THEN** the indicator SHALL show the active workcenter and reason name
- **THEN** a clear button (✕) SHALL remove the treemap filter
#### Scenario: Clear all filters
- **WHEN** user clicks a "清除所有篩選" button
- **THEN** both matrixFilter and treemapFilter SHALL be cleared
- **THEN** TreeMap and lot table SHALL reload without cascade filters
### Requirement: Hold Overview page SHALL display a TreeMap visualization
The page SHALL display a TreeMap chart showing hold lot distribution by workcenter and reason.
#### Scenario: TreeMap rendering
- **WHEN** treemap data is loaded from `GET /api/hold-overview/treemap`
- **THEN** the TreeMap SHALL display with two levels: Workcenter (parent) → Hold Reason (child)
- **THEN** block area SHALL represent QTY
- **THEN** block color SHALL represent average age at current station using a 4-level color scale
- **THEN** the color scale legend SHALL display: 綠(<1天), (1-3天), (3-7天), (>7天)
#### Scenario: TreeMap tooltip
- **WHEN** user hovers over a TreeMap block
- **THEN** a tooltip SHALL display: Workcenter, Reason, Lots count, QTY, and average age
#### Scenario: TreeMap narrows on matrix filter (Option A)
- **WHEN** a matrix filter is active (e.g., workcenter=WC-MOLD, package=PKG-A)
- **THEN** the TreeMap SHALL only show data matching the matrix filter
- **THEN** the TreeMap API SHALL be called with workcenter and/or package parameters
#### Scenario: TreeMap click filters lot table
- **WHEN** user clicks a leaf block in the TreeMap (a specific reason within a workcenter)
- **THEN** `treemapFilter` SHALL be set to `{ workcenter, reason }`
- **THEN** lot table SHALL reload filtered by that workcenter + reason
- **THEN** the clicked block SHALL show a visual highlight (border or opacity change)
#### Scenario: TreeMap click toggle
- **WHEN** user clicks the same block that is already active
- **THEN** `treemapFilter` SHALL be cleared
- **THEN** lot table SHALL reload without treemap filter
#### Scenario: Empty TreeMap
- **WHEN** treemap data returns zero items
- **THEN** the TreeMap area SHALL display "目前無 Hold 資料"
### Requirement: Hold Overview page SHALL display paginated lot details
The page SHALL display a detailed lot table with server-side pagination.
#### Scenario: Lot table rendering
- **WHEN** lot data is loaded from `GET /api/hold-overview/lots`
- **THEN** a table SHALL display with columns: LOTID, WORKORDER, QTY, Package, Workcenter, Hold Reason, Age, Hold By, Dept, Hold Comment
- **THEN** age values SHALL display with "天" suffix
#### Scenario: Lot table responds to all cascade filters
- **WHEN** matrixFilter is `{ workcenter: WC-A, package: PKG-1 }` and treemapFilter is `{ reason: 品質確認 }`
- **THEN** lots API SHALL be called with `workcenter=WC-A&package=PKG-1&treemap_reason=品質確認`
- **THEN** only lots matching all active filters SHALL be displayed
#### Scenario: Pagination
- **WHEN** total lots exceeds per_page (50)
- **THEN** Prev/Next buttons and page info SHALL display
- **THEN** page info SHALL show "顯示 {start} - {end} / {total}"
#### Scenario: Filter changes reset pagination
- **WHEN** any filter changes (filter bar, matrix click, or treemap click)
- **THEN** pagination SHALL reset to page 1
#### Scenario: Empty lot result
- **WHEN** a query returns zero lots
- **THEN** the lot table SHALL display a "No data" placeholder
### Requirement: Hold Overview page SHALL auto-refresh and handle request cancellation
The page SHALL automatically refresh data and prevent stale request pile-up.
#### Scenario: Auto-refresh interval
- **WHEN** the page is loaded
- **THEN** data SHALL auto-refresh every 10 minutes using `useAutoRefresh` composable
- **THEN** auto-refresh SHALL be skipped when the tab is hidden
#### Scenario: Visibility change refresh
- **WHEN** the tab becomes visible after being hidden
- **THEN** data SHALL refresh immediately
#### Scenario: Request cancellation
- **WHEN** a new data load is triggered while a previous request is in-flight
- **THEN** the previous request SHALL be cancelled via AbortController
- **THEN** the cancelled request SHALL NOT update the UI
#### Scenario: Manual refresh
- **WHEN** user clicks the "重新整理" button
- **THEN** all data SHALL reload and the auto-refresh timer SHALL reset
- **THEN** all cascade filters (matrixFilter, treemapFilter) SHALL be preserved during refresh
### Requirement: Hold Overview page SHALL handle loading and error states
The page SHALL display appropriate feedback during API calls and on errors.
#### Scenario: Initial loading overlay
- **WHEN** the page first loads
- **THEN** a full-page loading overlay SHALL display until all data is loaded
#### Scenario: API error handling
- **WHEN** an API call fails
- **THEN** an error banner SHALL display with the error message
- **THEN** the page SHALL NOT crash or become unresponsive
#### Scenario: Refresh indicator
- **WHEN** data is being refreshed (not initial load)
- **THEN** a spinning refresh indicator SHALL display in the header
- **THEN** a success checkmark SHALL flash briefly on completion
### Requirement: Hold Overview page SHALL have back navigation
The page SHALL provide navigation back to WIP Overview.
#### Scenario: Back button
- **WHEN** user clicks the "← WIP Overview" button in the header
- **THEN** the page SHALL navigate to `/wip-overview`

View File

@@ -0,0 +1,40 @@
## MODIFIED Requirements
### Requirement: Vite config SHALL support Vue SFC and HTML entry points
The Vite build configuration SHALL support Vue Single File Components alongside existing vanilla JS entries.
#### Scenario: Vue plugin coexistence
- **WHEN** `vite build` is executed
- **THEN** Vue SFC (`.vue` files) SHALL be compiled by `@vitejs/plugin-vue`
- **THEN** existing vanilla JS entry points SHALL continue to build without modification
#### Scenario: HTML entry point
- **WHEN** a page uses an HTML file as its Vite entry point
- **THEN** Vite SHALL process the HTML and its referenced JS/CSS into `static/dist/`
- **THEN** the output SHALL include `<page-name>.html`, `<page-name>.js`, and `<page-name>.css`
#### Scenario: Chunk splitting
- **WHEN** Vite builds the project
- **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk
- **THEN** ECharts modules (including TreemapChart) SHALL be split into the existing `vendor-echarts` chunk
- **THEN** chunk splitting SHALL NOT affect existing page bundles
#### Scenario: Migrated page entry replacement
- **WHEN** a vanilla JS page is migrated to Vue 3
- **THEN** its Vite entry SHALL change from JS file to HTML file (e.g., `src/wip-overview/main.js``src/wip-overview/index.html`)
- **THEN** the original JS entry SHALL be replaced, not kept alongside
#### Scenario: Hold Overview entry point
- **WHEN** the hold-overview page is added
- **THEN** `vite.config.js` input SHALL include `'hold-overview': resolve(__dirname, 'src/hold-overview/index.html')`
- **THEN** the build SHALL produce `hold-overview.html`, `hold-overview.js`, and `hold-overview.css` in `static/dist/`
#### Scenario: Shared CSS import across migrated pages
- **WHEN** multiple migrated pages import a shared CSS module (e.g., `wip-shared/styles.css`)
- **THEN** Vite SHALL bundle the shared CSS into each page's output CSS
- **THEN** shared CSS SHALL NOT create a separate shared chunk that requires additional HTTP requests
#### Scenario: Shared composable import across module boundaries
- **WHEN** a migrated page imports a composable from another shared module (e.g., `hold-overview` imports `useAutoRefresh` from `wip-shared/`)
- **THEN** the composable SHALL be bundled into the importing page's JS output
- **THEN** cross-module imports SHALL NOT create unexpected shared chunks

View File

@@ -0,0 +1,72 @@
## 1. Backend — 擴充現有 Service 函數
- [x] 1.1 擴充 `get_hold_detail_summary()` 簽名:`reason` 改為 `Optional[str] = None`,新增 `hold_type: Optional[str] = None` 參數reason=None 時聚合所有 HOLD lotshold_type 過濾品質/非品質cache path 和 Oracle fallback 都需支援;確保現有 Hold Detail 呼叫 `get_hold_detail_summary(reason='xxx')` 行為不變
- [x] 1.2 擴充 `get_hold_detail_lots()` 簽名:`reason` 改為 `Optional[str] = None`,新增 `hold_type: Optional[str] = None``treemap_reason: Optional[str] = None` 參數reason=None 時返回所有 HOLD lotstreemap_reason 作為額外 HOLDREASONNAME 過濾TreeMap 點擊篩選用);增加 holdReason 欄位到 lot 回傳資料中;確保現有 Hold Detail 呼叫不受影響
- [x] 1.3 擴充 `get_wip_matrix()` 簽名:新增 `reason: Optional[str] = None` 參數,過濾 HOLDREASONNAMEcache path 用 DataFrame filterOracle fallback 用 QueryBuilderreason=None 時行為不變,確保 WIP Overview 呼叫不受影響
- [x] 1.4 新增 `get_hold_overview_treemap()` 函數(唯一全新函數):使用 `_select_with_snapshot_indexes(status='HOLD', hold_type=...)` 取得 HOLD DataFrame按 (WORKCENTER_GROUP, HOLDREASONNAME) groupBy 聚合,回傳 `[{ workcenter, reason, lots, qty, avgAge }]`;接受 `hold_type`, `reason`, `workcenter`, `package` 參數;含 Oracle fallback
## 2. Backend — 路由
- [x] 2.1 建立 `src/mes_dashboard/routes/hold_overview_routes.py`Flask Blueprint `hold_overview_bp`;頁面路由 `GET /hold-overview``send_from_directory` 提供 static Vite HTML含 fallback HTML
- [x] 2.2 實作 `GET /api/hold-overview/summary`:解析 `hold_type`(預設 `quality`)和 `reason` query params委派給擴充後的 `get_hold_detail_summary(reason=reason, hold_type=hold_type)`
- [x] 2.3 實作 `GET /api/hold-overview/matrix`:委派給現有 `get_wip_matrix(status='HOLD', hold_type=..., reason=...)`;套用 rate limiting (120 req/60s)
- [x] 2.4 實作 `GET /api/hold-overview/treemap`:解析 `hold_type`, `reason`, `workcenter`, `package` params委派給 `get_hold_overview_treemap()`
- [x] 2.5 實作 `GET /api/hold-overview/lots`:解析所有 filter params + 分頁,委派給擴充後的 `get_hold_detail_lots(reason=reason, hold_type=hold_type, treemap_reason=treemap_reason, ...)`;套用 rate limiting (90 req/60s)per_page 上限 200
- [x] 2.6 在 Flask app factory`routes/__init__.py`)中註冊 `hold_overview_bp`
## 3. Backend — 向後相容驗證
- [x] 3.1 驗證 Hold Detail 頁面現有 3 支 APIsummary/distribution/lots在擴充後行為不變`get_hold_detail_summary(reason='xxx')``get_hold_detail_lots(reason='xxx', ...)` 結果與擴充前一致
- [x] 3.2 驗證 WIP Overview 的 `get_wip_matrix()` 呼叫在新增 reason 參數後行為不變reason=None 預設值)
## 4. Frontend — 腳手架
- [x] 4.1 建立 `frontend/src/hold-overview/` 目錄結構:`index.html`, `main.js`, `App.vue`, `style.css`, `components/`
- [x] 4.2 在 `vite.config.js` 的 input 加入 `'hold-overview': resolve(__dirname, 'src/hold-overview/index.html')`
- [x] 4.3 建立 `index.html`Vue 3 mount point`main.js``createApp(App).mount('#app')`import `style.css``wip-shared/styles.css`
## 5. Frontend — FilterBar全新
- [x] 5.1 建立 `components/FilterBar.vue`Hold Type radio group品質異常 default, 非品質異常, 全部)+ Reason dropdown全部 + dynamic reasonsemit `change` 事件帶 `{ holdType, reason }`
## 6. Frontend — SummaryCards直接 import
- [x] 6.1 在 App.vue 中直接 `import SummaryCards from '../hold-detail/components/SummaryCards.vue'`props 形狀 `{ totalLots, totalQty, avgAge, maxAge, workcenterCount }` 完全相容,無需新建元件
## 7. Frontend — HoldMatrix基於 MatrixTable 新建)
- [x] 7.1 建立 `components/HoldMatrix.vue`,以 `wip-overview/MatrixTable.vue` 為基礎:保留 matrix 渲染邏輯sticky 首欄、Total row/column、"-" 零值、zh-TW 格式化)
- [x] 7.2 擴充互動cell click → emit `{ workcenter, package }`、workcenter name/row total click → emit `{ workcenter }`、package header/column total click → emit `{ package }`active cell/row/column highlighttoggle logic再次點擊同一項 = 清除)
## 8. Frontend — HoldTreeMap全新
- [x] 8.1 建立 `components/HoldTreeMap.vue`ECharts TreeMap`import { TreemapChart } from 'echarts/charts'`兩層結構WC parent → Reason child面積=QTY`visualMap` 色階 for avgAge<1天, 黃1-3天, 橙3-7天, >7天
- [x] 8.2 實作 tooltipworkcenter, reason, lots, qty, avgAge和 click handler → emit `{ workcenter, reason }`toggle logic"目前無 Hold 資料" empty state
- [x] 8.3 實作 `autoresize` 和 responsive height
## 9. Frontend — LotTable基於 hold-detail/LotTable 新建)
- [x] 9.1 建立 `components/LotTable.vue`,以 `hold-detail/LotTable.vue` 為基礎:保留分頁邏輯(已 import `wip-shared/Pagination.vue`、loading/error/empty 狀態、filter indicator替換欄位移除 Spec新增 Hold Reason 欄位holdReason
## 10. Frontend — FilterIndicator全新
- [x] 10.1 建立 `components/FilterIndicator.vue`:顯示 active matrixFilter 和/或 treemapFilter 標籤,含 ✕ 清除按鈕;任一 cascade filter 啟用時顯示「清除所有篩選」按鈕
## 11. Frontend — App.vue 整合
- [x] 11.1 串接 App.vueimport 所有元件SummaryCards 從 hold-detail import、其餘從 local components設定 reactive state for `filterBar`, `matrixFilter`, `treemapFilter`, `page`
- [x] 11.2 實作資料載入:`loadAllData()` 平行呼叫 4 支 API`loadTreemapAndLots()` for matrix filter 變更;`loadLots()` for treemap filter 變更;使用 `useAutoRefresh` composable`wip-shared/composables/useAutoRefresh.js` import
- [x] 11.3 實作 filter cascadefilter bar 變更 → 清除 matrixFilter + treemapFilter → `loadAllData()`matrix click → set matrixFilter, 清除 treemapFilter → `loadTreemapAndLots()`treemap click → set treemapFilter → `loadLots()`
- [x] 11.4 實作 loading statesinitialLoading overlay、refreshing indicator、refresh success/error、error handling、手動重新整理按鈕、AbortController request cancellation
- [x] 11.5 從 treemap 資料的 distinct reasons 填充 Reason dropdown
## 12. Frontend — 樣式
- [x] 12.1 建立 `style.css`,沿用 `wip-overview/style.css``hold-detail/style.css` 的 pattern包含 header、summary cards、matrix table、treemap section、lot table、filter indicator、filter bar、loading overlay、error banner 樣式
## 13. Build & 驗證
- [x] 13.1 執行 `npm --prefix frontend run build`,確認 `static/dist/` 生成 `hold-overview.html`, `hold-overview.js`, `hold-overview.css`
- [x] 13.2 驗證 Flask serve `/hold-overview` 正常4 支 API endpoint 回應正確
- [x] 13.3 端對端測試filter bar toggle → matrix click → treemap click → lot table cascade驗證每層正確回應
- [x] 13.4 回歸測試:確認 Hold Detail 頁面(`/hold-detail?reason=xxx`)功能正常不受影響;確認 WIP Overview Matrix 功能正常不受影響

View File

@@ -0,0 +1,141 @@
## Purpose
Define stable requirements for hold-overview-api.
## Requirements
### Requirement: Hold Overview API SHALL provide summary statistics
The API SHALL return aggregated summary KPIs for hold lots.
#### Scenario: Summary endpoint delegates to extended get_hold_detail_summary
- **WHEN** `GET /api/hold-overview/summary` is called with `hold_type=quality`
- **THEN** the route SHALL delegate to the extended `get_hold_detail_summary(reason=None, hold_type='quality')`
- **THEN** the response SHALL return `{ success: true, data: { totalLots, totalQty, workcenterCount, avgAge, maxAge, dataUpdateDate } }`
- **THEN** only lots with EQUIPMENTCOUNT=0 AND CURRENTHOLDCOUNT>0 SHALL be included
- **THEN** hold_type classification SHALL use the same NON_QUALITY_HOLD_REASONS set as existing hold endpoints
#### Scenario: Summary with reason filter
- **WHEN** `GET /api/hold-overview/summary?hold_type=quality&reason=品質確認` is called
- **THEN** the response SHALL only include lots where HOLDREASONNAME equals the specified reason
#### Scenario: Summary hold_type=all
- **WHEN** `GET /api/hold-overview/summary?hold_type=all` is called
- **THEN** the response SHALL include both quality and non-quality hold lots
#### Scenario: Summary error
- **WHEN** the database query fails
- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500
### Requirement: Hold Overview API SHALL provide workcenter x package matrix
The API SHALL return a cross-tabulation of workcenters and packages for hold lots.
#### Scenario: Matrix endpoint
- **WHEN** `GET /api/hold-overview/matrix` is called with `hold_type=quality`
- **THEN** the response SHALL return the same matrix structure as `/api/wip/overview/matrix`: `{ workcenters, packages, matrix, workcenter_totals, package_totals, grand_total }`
- **THEN** workcenters SHALL be sorted by WORKCENTERSEQUENCE_GROUP
- **THEN** packages SHALL be sorted by total QTY descending
- **THEN** only HOLD status lots matching the hold_type SHALL be included
#### Scenario: Matrix delegates to existing get_wip_matrix
- **WHEN** the matrix endpoint is called
- **THEN** it SHALL delegate to `get_wip_matrix(status='HOLD', hold_type=...)` from wip_service.py
#### Scenario: Matrix with reason filter
- **WHEN** `GET /api/hold-overview/matrix?hold_type=quality&reason=品質確認` is called
- **THEN** the matrix SHALL only include lots where HOLDREASONNAME equals the specified reason
### Requirement: Hold Overview API SHALL provide TreeMap aggregation data
The API SHALL return aggregated data suitable for TreeMap visualization.
#### Scenario: TreeMap endpoint uses new get_hold_overview_treemap function
- **WHEN** `GET /api/hold-overview/treemap` is called with `hold_type=quality`
- **THEN** the route SHALL delegate to `get_hold_overview_treemap()` (the only new service function)
- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }`
- **THEN** each item SHALL contain `{ workcenter, reason, lots, qty, avgAge }`
- **THEN** items SHALL be grouped by (WORKCENTER_GROUP, HOLDREASONNAME)
- **THEN** avgAge SHALL be calculated using the pre-computed AGEBYDAYS column from DW_MES_LOT_V
#### Scenario: TreeMap with matrix filter
- **WHEN** `GET /api/hold-overview/treemap?hold_type=quality&workcenter=WC-MOLD&package=PKG-A` is called
- **THEN** the response SHALL only include lots matching the workcenter AND package filters
#### Scenario: TreeMap with reason filter
- **WHEN** `GET /api/hold-overview/treemap?hold_type=quality&reason=品質確認` is called
- **THEN** the response SHALL only include lots where HOLDREASONNAME equals the specified reason
#### Scenario: TreeMap empty result
- **WHEN** no hold lots match the filters
- **THEN** the response SHALL return `{ success: true, data: { items: [] } }`
### Requirement: Hold Overview API SHALL provide paginated lot details
The API SHALL return a paginated list of hold lot details.
#### Scenario: Lots endpoint delegates to extended get_hold_detail_lots
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&page=1&per_page=50` is called
- **THEN** the route SHALL delegate to the extended `get_hold_detail_lots(reason=None, hold_type='quality', ...)`
- **THEN** the response SHALL return `{ success: true, data: { lots: [...], pagination: { page, perPage, total, totalPages } } }`
- **THEN** each lot SHALL contain: lotId, workorder, qty, package, workcenter, holdReason, age, holdBy, dept, holdComment
- **THEN** lots SHALL be sorted by age descending (longest hold first)
#### Scenario: Lots with matrix filter
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&workcenter=WC-MOLD&package=PKG-A` is called
- **THEN** only lots matching the workcenter AND package filters SHALL be returned
#### Scenario: Lots with treemap filter
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&workcenter=WC-MOLD&treemap_reason=品質確認` is called
- **THEN** only lots matching the workcenter AND treemap_reason SHALL be returned
#### Scenario: Lots with all filters combined
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&reason=品質確認&workcenter=WC-MOLD&package=PKG-A&treemap_reason=品質確認` is called
- **THEN** all filters SHALL be applied as AND conditions
#### Scenario: Lots pagination bounds
- **WHEN** `page` is less than 1
- **THEN** page SHALL be treated as 1
- **WHEN** `per_page` exceeds 200
- **THEN** per_page SHALL be capped at 200
#### Scenario: Lots error
- **WHEN** the database query fails
- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500
### Requirement: Hold Overview API SHALL apply rate limiting
The API SHALL apply rate limiting to expensive endpoints.
#### Scenario: Rate limit on lots endpoint
- **WHEN** the lots endpoint receives excessive requests
- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 90 requests per 60 seconds
#### Scenario: Rate limit on matrix endpoint
- **WHEN** the matrix endpoint receives excessive requests
- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 120 requests per 60 seconds
### Requirement: Hold Overview page route SHALL serve static Vite HTML
The Flask route SHALL serve the pre-built Vite HTML file.
#### Scenario: Page route
- **WHEN** user navigates to `/hold-overview`
- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/hold-overview.html` via `send_from_directory`
- **THEN** the HTML SHALL NOT pass through Jinja2 template rendering
#### Scenario: Fallback HTML
- **WHEN** the pre-built HTML file does not exist
- **THEN** Flask SHALL return a minimal HTML page with the correct script tag and module import
### Requirement: Extended service functions SHALL maintain backward compatibility
The extended `get_hold_detail_summary()`, `get_hold_detail_lots()`, and `get_wip_matrix()` SHALL not break existing callers.
#### Scenario: Hold Detail summary backward compatibility
- **WHEN** existing Hold Detail code calls `get_hold_detail_summary(reason='xxx')`
- **THEN** the result SHALL be identical to the pre-extension behavior
- **THEN** the new `hold_type` parameter SHALL default to None (no additional filtering)
#### Scenario: Hold Detail lots backward compatibility
- **WHEN** existing Hold Detail code calls `get_hold_detail_lots(reason='xxx', workcenter=..., package=..., age_range=...)`
- **THEN** the result SHALL be identical to the pre-extension behavior
- **THEN** the new `hold_type` and `treemap_reason` parameters SHALL default to None
#### Scenario: WIP Overview matrix backward compatibility
- **WHEN** existing WIP Overview code calls `get_wip_matrix(status='HOLD', hold_type='quality')`
- **THEN** the result SHALL be identical to the pre-extension behavior
- **THEN** the new `reason` parameter SHALL default to None (no HOLDREASONNAME filtering)

View File

@@ -0,0 +1,200 @@
## Purpose
Define stable requirements for hold-overview-page.
## Requirements
### Requirement: Hold Overview page SHALL display a filter bar with Hold Type and Reason
The page SHALL provide a filter bar for selecting hold type and hold reason.
#### Scenario: Hold Type radio default
- **WHEN** the page loads
- **THEN** the Hold Type filter SHALL default to "品質異常"
- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部
#### Scenario: Hold Type change reloads all data
- **WHEN** user changes the Hold Type selection
- **THEN** all four API calls (summary, matrix, treemap, lots) SHALL reload with the new hold_type parameter
- **THEN** any active matrix and treemap filters SHALL be cleared
#### Scenario: Reason dropdown populated from current data
- **WHEN** summary data is loaded
- **THEN** the Reason dropdown SHALL display "全部" plus all distinct hold reasons from the treemap data
- **THEN** selecting a specific reason SHALL reload all four API calls filtered by that reason
- **THEN** any active matrix and treemap filters SHALL be cleared
### Requirement: Hold Overview page SHALL display summary KPI cards
The page SHALL show summary statistics for all hold lots matching the current filter.
#### Scenario: Summary cards rendering
- **WHEN** summary data is loaded from `GET /api/hold-overview/summary`
- **THEN** five cards SHALL display: Hold Lots, Hold QTY, 站別數, 平均滯留天數, 最大滯留天數
- **THEN** lot and QTY values SHALL use zh-TW number formatting
- **THEN** age values SHALL display with "天" suffix and one decimal place
#### Scenario: Summary reflects filter bar only
- **WHEN** user clicks a matrix cell or treemap block
- **THEN** summary cards SHALL NOT change (they only respond to filter bar changes)
### Requirement: Hold Overview page SHALL display a Workcenter x Package matrix
The page SHALL display a cross-tabulation matrix of workcenters vs packages for hold lots.
#### Scenario: Matrix table rendering
- **WHEN** matrix data is loaded from `GET /api/hold-overview/matrix`
- **THEN** the table SHALL display workcenters as rows and packages as columns
- **THEN** cell values SHALL show QTY with zh-TW number formatting
- **THEN** the first column (Workcenter) SHALL be sticky on horizontal scroll
- **THEN** a Total row and Total column SHALL be displayed
- **THEN** cells with zero value SHALL display "-"
#### Scenario: Matrix cell click filters TreeMap and lot table
- **WHEN** user clicks a QTY cell in the matrix (intersection of workcenter and package)
- **THEN** `matrixFilter` SHALL be set to `{ workcenter, package }`
- **THEN** TreeMap SHALL reload showing only data for that workcenter + package combination
- **THEN** lot table SHALL reload filtered by that workcenter + package
- **THEN** the clicked cell SHALL show an active highlight
#### Scenario: Matrix workcenter row click
- **WHEN** user clicks a workcenter name or its Total cell
- **THEN** `matrixFilter` SHALL be set to `{ workcenter }` (all packages)
- **THEN** TreeMap and lot table SHALL reload filtered by that workcenter
#### Scenario: Matrix package column click
- **WHEN** user clicks a package column header or its Total cell
- **THEN** `matrixFilter` SHALL be set to `{ package }` (all workcenters)
- **THEN** TreeMap and lot table SHALL reload filtered by that package
#### Scenario: Matrix click toggle
- **WHEN** user clicks the same cell/row/column that is already active
- **THEN** `matrixFilter` SHALL be cleared
- **THEN** TreeMap and lot table SHALL reload without matrix filter
#### Scenario: Matrix reflects filter bar only
- **WHEN** user clicks a treemap block
- **THEN** matrix SHALL NOT change (it only responds to filter bar changes)
### Requirement: Hold Overview page SHALL display active filter indicators
The page SHALL show a clear indicator of active cascade filters.
#### Scenario: Matrix filter indicator
- **WHEN** a matrix filter is active
- **THEN** a filter indicator SHALL display between the matrix and TreeMap sections
- **THEN** the indicator SHALL show the active workcenter and/or package name
- **THEN** a clear button (✕) SHALL remove the matrix filter
#### Scenario: TreeMap filter indicator
- **WHEN** a treemap filter is active
- **THEN** a filter indicator SHALL display between the TreeMap and lot table sections
- **THEN** the indicator SHALL show the active workcenter and reason name
- **THEN** a clear button (✕) SHALL remove the treemap filter
#### Scenario: Clear all filters
- **WHEN** user clicks a "清除所有篩選" button
- **THEN** both matrixFilter and treemapFilter SHALL be cleared
- **THEN** TreeMap and lot table SHALL reload without cascade filters
### Requirement: Hold Overview page SHALL display a TreeMap visualization
The page SHALL display a TreeMap chart showing hold lot distribution by workcenter and reason.
#### Scenario: TreeMap rendering
- **WHEN** treemap data is loaded from `GET /api/hold-overview/treemap`
- **THEN** the TreeMap SHALL display with two levels: Workcenter (parent) → Hold Reason (child)
- **THEN** block area SHALL represent QTY
- **THEN** block color SHALL represent average age at current station using a 4-level color scale
- **THEN** the color scale legend SHALL display: 綠(<1天), (1-3天), (3-7天), (>7天)
#### Scenario: TreeMap tooltip
- **WHEN** user hovers over a TreeMap block
- **THEN** a tooltip SHALL display: Workcenter, Reason, Lots count, QTY, and average age
#### Scenario: TreeMap narrows on matrix filter (Option A)
- **WHEN** a matrix filter is active (e.g., workcenter=WC-MOLD, package=PKG-A)
- **THEN** the TreeMap SHALL only show data matching the matrix filter
- **THEN** the TreeMap API SHALL be called with workcenter and/or package parameters
#### Scenario: TreeMap click filters lot table
- **WHEN** user clicks a leaf block in the TreeMap (a specific reason within a workcenter)
- **THEN** `treemapFilter` SHALL be set to `{ workcenter, reason }`
- **THEN** lot table SHALL reload filtered by that workcenter + reason
- **THEN** the clicked block SHALL show a visual highlight (border or opacity change)
#### Scenario: TreeMap click toggle
- **WHEN** user clicks the same block that is already active
- **THEN** `treemapFilter` SHALL be cleared
- **THEN** lot table SHALL reload without treemap filter
#### Scenario: Empty TreeMap
- **WHEN** treemap data returns zero items
- **THEN** the TreeMap area SHALL display "目前無 Hold 資料"
### Requirement: Hold Overview page SHALL display paginated lot details
The page SHALL display a detailed lot table with server-side pagination.
#### Scenario: Lot table rendering
- **WHEN** lot data is loaded from `GET /api/hold-overview/lots`
- **THEN** a table SHALL display with columns: LOTID, WORKORDER, QTY, Package, Workcenter, Hold Reason, Age, Hold By, Dept, Hold Comment
- **THEN** age values SHALL display with "天" suffix
#### Scenario: Lot table responds to all cascade filters
- **WHEN** matrixFilter is `{ workcenter: WC-A, package: PKG-1 }` and treemapFilter is `{ reason: 品質確認 }`
- **THEN** lots API SHALL be called with `workcenter=WC-A&package=PKG-1&treemap_reason=品質確認`
- **THEN** only lots matching all active filters SHALL be displayed
#### Scenario: Pagination
- **WHEN** total lots exceeds per_page (50)
- **THEN** Prev/Next buttons and page info SHALL display
- **THEN** page info SHALL show "顯示 {start} - {end} / {total}"
#### Scenario: Filter changes reset pagination
- **WHEN** any filter changes (filter bar, matrix click, or treemap click)
- **THEN** pagination SHALL reset to page 1
#### Scenario: Empty lot result
- **WHEN** a query returns zero lots
- **THEN** the lot table SHALL display a "No data" placeholder
### Requirement: Hold Overview page SHALL auto-refresh and handle request cancellation
The page SHALL automatically refresh data and prevent stale request pile-up.
#### Scenario: Auto-refresh interval
- **WHEN** the page is loaded
- **THEN** data SHALL auto-refresh every 10 minutes using `useAutoRefresh` composable
- **THEN** auto-refresh SHALL be skipped when the tab is hidden
#### Scenario: Visibility change refresh
- **WHEN** the tab becomes visible after being hidden
- **THEN** data SHALL refresh immediately
#### Scenario: Request cancellation
- **WHEN** a new data load is triggered while a previous request is in-flight
- **THEN** the previous request SHALL be cancelled via AbortController
- **THEN** the cancelled request SHALL NOT update the UI
#### Scenario: Manual refresh
- **WHEN** user clicks the "重新整理" button
- **THEN** all data SHALL reload and the auto-refresh timer SHALL reset
- **THEN** all cascade filters (matrixFilter, treemapFilter) SHALL be preserved during refresh
### Requirement: Hold Overview page SHALL handle loading and error states
The page SHALL display appropriate feedback during API calls and on errors.
#### Scenario: Initial loading overlay
- **WHEN** the page first loads
- **THEN** a full-page loading overlay SHALL display until all data is loaded
#### Scenario: API error handling
- **WHEN** an API call fails
- **THEN** an error banner SHALL display with the error message
- **THEN** the page SHALL NOT crash or become unresponsive
#### Scenario: Refresh indicator
- **WHEN** data is being refreshed (not initial load)
- **THEN** a spinning refresh indicator SHALL display in the header
- **THEN** a success checkmark SHALL flash briefly on completion
### Requirement: Hold Overview page SHALL have back navigation
The page SHALL provide navigation back to WIP Overview.
#### Scenario: Back button
- **WHEN** user clicks the "← WIP Overview" button in the header
- **THEN** the page SHALL navigate to `/wip-overview`

View File

@@ -33,7 +33,7 @@ The Vite build configuration SHALL support Vue Single File Components alongside
#### Scenario: Chunk splitting #### Scenario: Chunk splitting
- **WHEN** Vite builds the project - **WHEN** Vite builds the project
- **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk - **THEN** Vue runtime SHALL be split into a `vendor-vue` chunk
- **THEN** ECharts modules SHALL be split into the existing `vendor-echarts` chunk - **THEN** ECharts modules (including TreemapChart) SHALL be split into the existing `vendor-echarts` chunk
- **THEN** chunk splitting SHALL NOT affect existing page bundles - **THEN** chunk splitting SHALL NOT affect existing page bundles
#### Scenario: Migrated page entry replacement #### Scenario: Migrated page entry replacement
@@ -41,13 +41,18 @@ The Vite build configuration SHALL support Vue Single File Components alongside
- **THEN** its Vite entry SHALL change from JS file to HTML file (e.g., `src/wip-overview/main.js``src/wip-overview/index.html`) - **THEN** its Vite entry SHALL change from JS file to HTML file (e.g., `src/wip-overview/main.js``src/wip-overview/index.html`)
- **THEN** the original JS entry SHALL be replaced, not kept alongside - **THEN** the original JS entry SHALL be replaced, not kept alongside
#### Scenario: Hold Overview entry point
- **WHEN** the hold-overview page is added
- **THEN** `vite.config.js` input SHALL include `'hold-overview': resolve(__dirname, 'src/hold-overview/index.html')`
- **THEN** the build SHALL produce `hold-overview.html`, `hold-overview.js`, and `hold-overview.css` in `static/dist/`
#### Scenario: Shared CSS import across migrated pages #### Scenario: Shared CSS import across migrated pages
- **WHEN** multiple migrated pages import a shared CSS module (e.g., `wip-shared/styles.css`) - **WHEN** multiple migrated pages import a shared CSS module (e.g., `wip-shared/styles.css`)
- **THEN** Vite SHALL bundle the shared CSS into each page's output CSS - **THEN** Vite SHALL bundle the shared CSS into each page's output CSS
- **THEN** shared CSS SHALL NOT create a separate shared chunk that requires additional HTTP requests - **THEN** shared CSS SHALL NOT create a separate shared chunk that requires additional HTTP requests
#### Scenario: Shared composable import across module boundaries #### Scenario: Shared composable import across module boundaries
- **WHEN** a migrated page imports a composable from another shared module (e.g., `resource-status` imports `useAutoRefresh` from `wip-shared/`) - **WHEN** a migrated page imports a composable from another shared module (e.g., `hold-overview` imports `useAutoRefresh` from `wip-shared/`)
- **THEN** the composable SHALL be bundled into the importing page's JS output - **THEN** the composable SHALL be bundled into the importing page's JS output
- **THEN** cross-module imports SHALL NOT create unexpected shared chunks - **THEN** cross-module imports SHALL NOT create unexpected shared chunks

View File

@@ -9,6 +9,7 @@ from .resource_routes import resource_bp
from .dashboard_routes import dashboard_bp from .dashboard_routes import dashboard_bp
from .excel_query_routes import excel_query_bp from .excel_query_routes import excel_query_bp
from .hold_routes import hold_bp from .hold_routes import hold_bp
from .hold_overview_routes import hold_overview_bp
from .auth_routes import auth_bp from .auth_routes import auth_bp
from .admin_routes import admin_bp from .admin_routes import admin_bp
from .resource_history_routes import resource_history_bp from .resource_history_routes import resource_history_bp
@@ -26,6 +27,7 @@ def register_routes(app) -> None:
app.register_blueprint(dashboard_bp) app.register_blueprint(dashboard_bp)
app.register_blueprint(excel_query_bp) app.register_blueprint(excel_query_bp)
app.register_blueprint(hold_bp) app.register_blueprint(hold_bp)
app.register_blueprint(hold_overview_bp)
app.register_blueprint(resource_history_bp) app.register_blueprint(resource_history_bp)
app.register_blueprint(job_query_bp) app.register_blueprint(job_query_bp)
app.register_blueprint(query_tool_bp) app.register_blueprint(query_tool_bp)
@@ -39,6 +41,7 @@ __all__ = [
'dashboard_bp', 'dashboard_bp',
'excel_query_bp', 'excel_query_bp',
'hold_bp', 'hold_bp',
'hold_overview_bp',
'auth_bp', 'auth_bp',
'admin_bp', 'admin_bp',
'resource_history_bp', 'resource_history_bp',

View File

@@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
"""Hold Overview page route and API endpoints."""
import os
from typing import Optional
from flask import Blueprint, current_app, jsonify, request, send_from_directory
from mes_dashboard.core.rate_limit import configured_rate_limit
from mes_dashboard.core.utils import parse_bool_query
from mes_dashboard.services.wip_service import (
get_hold_detail_lots,
get_hold_detail_summary,
get_hold_overview_treemap,
get_wip_matrix,
)
hold_overview_bp = Blueprint('hold_overview', __name__)
_HOLD_OVERVIEW_MATRIX_RATE_LIMIT = configured_rate_limit(
bucket="hold-overview-matrix",
max_attempts_env="HOLD_OVERVIEW_MATRIX_RATE_LIMIT_MAX_REQUESTS",
window_seconds_env="HOLD_OVERVIEW_MATRIX_RATE_LIMIT_WINDOW_SECONDS",
default_max_attempts=120,
default_window_seconds=60,
)
_HOLD_OVERVIEW_LOTS_RATE_LIMIT = configured_rate_limit(
bucket="hold-overview-lots",
max_attempts_env="HOLD_OVERVIEW_LOTS_RATE_LIMIT_MAX_REQUESTS",
window_seconds_env="HOLD_OVERVIEW_LOTS_RATE_LIMIT_WINDOW_SECONDS",
default_max_attempts=90,
default_window_seconds=60,
)
_VALID_HOLD_TYPES = {'quality', 'non-quality', 'all'}
_VALID_AGE_RANGES = {'0-1', '1-3', '3-7', '7+'}
def _parse_hold_type(default: str = 'quality') -> tuple[Optional[str], Optional[tuple[dict, int]]]:
raw = request.args.get('hold_type', '').strip().lower()
hold_type = raw or default
if hold_type not in _VALID_HOLD_TYPES:
return None, (
{'success': False, 'error': 'Invalid hold_type. Use quality, non-quality, or all'},
400,
)
if hold_type == 'all':
return None, None
return hold_type, None
@hold_overview_bp.route('/hold-overview')
def hold_overview_page():
"""Render hold overview page from static Vite output."""
dist_dir = os.path.join(current_app.static_folder or "", "dist")
dist_html = os.path.join(dist_dir, "hold-overview.html")
if os.path.exists(dist_html):
return send_from_directory(dist_dir, 'hold-overview.html')
return (
"<!doctype html><html lang=\"zh-Hant\"><head><meta charset=\"UTF-8\">"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
"<title>Hold Overview</title>"
"<script type=\"module\" src=\"/static/dist/hold-overview.js\"></script>"
"</head><body><div id='app'></div></body></html>",
200,
)
@hold_overview_bp.route('/api/hold-overview/summary')
def api_hold_overview_summary():
"""Return summary KPI data for hold overview page."""
hold_type, error = _parse_hold_type(default='quality')
if error:
return jsonify(error[0]), error[1]
reason = request.args.get('reason', '').strip() or None
include_dummy = parse_bool_query(request.args.get('include_dummy'))
result = get_hold_detail_summary(
reason=reason,
hold_type=hold_type,
include_dummy=include_dummy,
)
if result is not None:
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@hold_overview_bp.route('/api/hold-overview/matrix')
@_HOLD_OVERVIEW_MATRIX_RATE_LIMIT
def api_hold_overview_matrix():
"""Return hold-only workcenter x package matrix."""
hold_type, error = _parse_hold_type(default='quality')
if error:
return jsonify(error[0]), error[1]
reason = request.args.get('reason', '').strip() or None
include_dummy = parse_bool_query(request.args.get('include_dummy'))
result = get_wip_matrix(
include_dummy=include_dummy,
status='HOLD',
hold_type=hold_type,
reason=reason,
)
if result is not None:
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@hold_overview_bp.route('/api/hold-overview/treemap')
def api_hold_overview_treemap():
"""Return grouped hold overview data for treemap chart."""
hold_type, error = _parse_hold_type(default='quality')
if error:
return jsonify(error[0]), error[1]
reason = request.args.get('reason', '').strip() or None
workcenter = request.args.get('workcenter', '').strip() or None
package = request.args.get('package', '').strip() or None
include_dummy = parse_bool_query(request.args.get('include_dummy'))
result = get_hold_overview_treemap(
hold_type=hold_type,
reason=reason,
workcenter=workcenter,
package=package,
include_dummy=include_dummy,
)
if result is not None:
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@hold_overview_bp.route('/api/hold-overview/lots')
@_HOLD_OVERVIEW_LOTS_RATE_LIMIT
def api_hold_overview_lots():
"""Return paginated hold lot details."""
hold_type, error = _parse_hold_type(default='quality')
if error:
return jsonify(error[0]), error[1]
reason = request.args.get('reason', '').strip() or None
treemap_reason = request.args.get('treemap_reason', '').strip() or None
workcenter = request.args.get('workcenter', '').strip() or None
package = request.args.get('package', '').strip() or None
age_range = request.args.get('age_range', '').strip() or None
include_dummy = parse_bool_query(request.args.get('include_dummy'))
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
if age_range and age_range not in _VALID_AGE_RANGES:
return jsonify({
'success': False,
'error': 'Invalid age_range. Use 0-1, 1-3, 3-7, or 7+',
}), 400
if page is None:
page = 1
if per_page is None:
per_page = 50
page = max(page, 1)
per_page = max(1, min(per_page, 200))
result = get_hold_detail_lots(
reason=reason,
hold_type=hold_type,
treemap_reason=treemap_reason,
workcenter=workcenter,
package=package,
age_range=age_range,
include_dummy=include_dummy,
page=page,
page_size=per_page,
)
if result is not None:
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '查詢失敗'}), 500

View File

@@ -878,6 +878,7 @@ def get_wip_matrix(
lotid: Optional[str] = None, lotid: Optional[str] = None,
status: Optional[str] = None, status: Optional[str] = None,
hold_type: Optional[str] = None, hold_type: Optional[str] = None,
reason: Optional[str] = None,
package: Optional[str] = None, package: Optional[str] = None,
pj_type: Optional[str] = None pj_type: Optional[str] = None
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
@@ -892,6 +893,8 @@ def get_wip_matrix(
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD') status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
hold_type: Optional hold type filter ('quality', 'non-quality') hold_type: Optional hold type filter ('quality', 'non-quality')
Only effective when status='HOLD' Only effective when status='HOLD'
reason: Optional HOLDREASONNAME filter
Only effective when status='HOLD'
package: Optional PACKAGE_LEF filter (exact match) package: Optional PACKAGE_LEF filter (exact match)
pj_type: Optional PJ_TYPE filter (exact match) pj_type: Optional PJ_TYPE filter (exact match)
@@ -910,6 +913,7 @@ def get_wip_matrix(
try: try:
status_upper = status.upper() if status else None status_upper = status.upper() if status else None
hold_type_filter = hold_type if status_upper == 'HOLD' else None hold_type_filter = hold_type if status_upper == 'HOLD' else None
reason_filter = reason if status_upper == 'HOLD' else None
df = _select_with_snapshot_indexes( df = _select_with_snapshot_indexes(
include_dummy=include_dummy, include_dummy=include_dummy,
workorder=workorder, workorder=workorder,
@@ -926,10 +930,14 @@ def get_wip_matrix(
lotid, lotid,
status, status,
hold_type, hold_type,
reason,
package, package,
pj_type, pj_type,
) )
if reason_filter:
df = df[df['HOLDREASONNAME'] == reason_filter]
# Filter by WORKCENTER_GROUP and PACKAGE_LEF # Filter by WORKCENTER_GROUP and PACKAGE_LEF
df = df[df['WORKCENTER_GROUP'].notna() & df['PACKAGE_LEF'].notna()] df = df[df['WORKCENTER_GROUP'].notna() & df['PACKAGE_LEF'].notna()]
@@ -950,7 +958,16 @@ def get_wip_matrix(
logger.warning(f"Cache-based matrix calculation failed, falling back to Oracle: {exc}") logger.warning(f"Cache-based matrix calculation failed, falling back to Oracle: {exc}")
# Fallback to Oracle direct query # Fallback to Oracle direct query
return _get_wip_matrix_from_oracle(include_dummy, workorder, lotid, status, hold_type, package, pj_type) return _get_wip_matrix_from_oracle(
include_dummy,
workorder,
lotid,
status,
hold_type,
reason,
package,
pj_type,
)
def _build_matrix_result(df: pd.DataFrame) -> Dict[str, Any]: def _build_matrix_result(df: pd.DataFrame) -> Dict[str, Any]:
@@ -1012,6 +1029,7 @@ def _get_wip_matrix_from_oracle(
lotid: Optional[str] = None, lotid: Optional[str] = None,
status: Optional[str] = None, status: Optional[str] = None,
hold_type: Optional[str] = None, hold_type: Optional[str] = None,
reason: Optional[str] = None,
package: Optional[str] = None, package: Optional[str] = None,
pj_type: Optional[str] = None pj_type: Optional[str] = None
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
@@ -1037,6 +1055,8 @@ def _get_wip_matrix_from_oracle(
# Hold type sub-filter # Hold type sub-filter
if hold_type: if hold_type:
_add_hold_type_conditions(builder, hold_type) _add_hold_type_conditions(builder, hold_type)
if reason:
builder.add_param_condition("HOLDREASONNAME", reason)
elif status_upper == 'QUEUE': elif status_upper == 'QUEUE':
builder.add_condition("COALESCE(EQUIPMENTCOUNT, 0) = 0 AND COALESCE(CURRENTHOLDCOUNT, 0) = 0") builder.add_condition("COALESCE(EQUIPMENTCOUNT, 0) = 0 AND COALESCE(CURRENTHOLDCOUNT, 0) = 0")
@@ -2137,19 +2157,21 @@ def _search_types_from_oracle(
# ============================================================ # ============================================================
def get_hold_detail_summary( def get_hold_detail_summary(
reason: str, reason: Optional[str] = None,
include_dummy: bool = False hold_type: Optional[str] = None,
include_dummy: bool = False,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Get summary statistics for a specific hold reason. """Get summary statistics for hold lots.
Uses Redis cache when available, falls back to Oracle direct query. Uses Redis cache when available, falls back to Oracle direct query.
Args: Args:
reason: The HOLDREASONNAME to filter by reason: Optional HOLDREASONNAME filter
hold_type: Optional hold type filter ('quality', 'non-quality')
include_dummy: If True, include DUMMY lots (default: False) include_dummy: If True, include DUMMY lots (default: False)
Returns: Returns:
Dict with totalLots, totalQty, avgAge, maxAge, workcenterCount Dict with totalLots, totalQty, avgAge, maxAge, workcenterCount, dataUpdateDate
""" """
# Try cache first # Try cache first
cached_df = _get_wip_dataframe() cached_df = _get_wip_dataframe()
@@ -2158,12 +2180,17 @@ def get_hold_detail_summary(
df = _select_with_snapshot_indexes( df = _select_with_snapshot_indexes(
include_dummy=include_dummy, include_dummy=include_dummy,
status='HOLD', status='HOLD',
hold_type=hold_type,
) )
if df is None: if df is None:
return _get_hold_detail_summary_from_oracle(reason, include_dummy) return _get_hold_detail_summary_from_oracle(
reason=reason,
hold_type=hold_type,
include_dummy=include_dummy,
)
# Filter for HOLD status with matching reason if reason:
df = df[df['HOLDREASONNAME'] == reason] df = df[df['HOLDREASONNAME'] == reason]
if df.empty: if df.empty:
return { return {
@@ -2171,10 +2198,12 @@ def get_hold_detail_summary(
'totalQty': 0, 'totalQty': 0,
'avgAge': 0, 'avgAge': 0,
'maxAge': 0, 'maxAge': 0,
'workcenterCount': 0 'workcenterCount': 0,
'dataUpdateDate': get_cached_sys_date(),
} }
# Ensure AGEBYDAYS is numeric # Ensure AGEBYDAYS is numeric
df = df.copy()
df['AGEBYDAYS'] = pd.to_numeric(df['AGEBYDAYS'], errors='coerce').fillna(0) df['AGEBYDAYS'] = pd.to_numeric(df['AGEBYDAYS'], errors='coerce').fillna(0)
return { return {
@@ -2182,7 +2211,8 @@ def get_hold_detail_summary(
'totalQty': int(df['QTY'].sum()), 'totalQty': int(df['QTY'].sum()),
'avgAge': round(float(df['AGEBYDAYS'].mean()), 1), 'avgAge': round(float(df['AGEBYDAYS'].mean()), 1),
'maxAge': float(df['AGEBYDAYS'].max()), 'maxAge': float(df['AGEBYDAYS'].max()),
'workcenterCount': df['WORKCENTER_GROUP'].nunique() 'workcenterCount': df['WORKCENTER_GROUP'].nunique(),
'dataUpdateDate': get_cached_sys_date(),
} }
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError): except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise raise
@@ -2190,19 +2220,27 @@ def get_hold_detail_summary(
logger.warning(f"Cache-based hold detail summary failed, falling back to Oracle: {exc}") logger.warning(f"Cache-based hold detail summary failed, falling back to Oracle: {exc}")
# Fallback to Oracle direct query # Fallback to Oracle direct query
return _get_hold_detail_summary_from_oracle(reason, include_dummy) return _get_hold_detail_summary_from_oracle(
reason=reason,
hold_type=hold_type,
include_dummy=include_dummy,
)
def _get_hold_detail_summary_from_oracle( def _get_hold_detail_summary_from_oracle(
reason: str, reason: Optional[str] = None,
include_dummy: bool = False hold_type: Optional[str] = None,
include_dummy: bool = False,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Get hold detail summary directly from Oracle (fallback).""" """Get hold detail summary directly from Oracle (fallback)."""
try: try:
builder = _build_base_conditions_builder(include_dummy) builder = _build_base_conditions_builder(include_dummy)
builder.add_param_condition("STATUS", "HOLD") builder.add_param_condition("STATUS", "HOLD")
builder.add_condition("CURRENTHOLDCOUNT > 0") builder.add_condition("CURRENTHOLDCOUNT > 0")
builder.add_param_condition("HOLDREASONNAME", reason) if hold_type:
_add_hold_type_conditions(builder, hold_type)
if reason:
builder.add_param_condition("HOLDREASONNAME", reason)
where_clause, params = builder.build_where_only() where_clause, params = builder.build_where_only()
sql = f""" sql = f"""
@@ -2211,7 +2249,8 @@ def _get_hold_detail_summary_from_oracle(
SUM(QTY) AS TOTAL_QTY, SUM(QTY) AS TOTAL_QTY,
ROUND(AVG(AGEBYDAYS), 1) AS AVG_AGE, ROUND(AVG(AGEBYDAYS), 1) AS AVG_AGE,
MAX(AGEBYDAYS) AS MAX_AGE, MAX(AGEBYDAYS) AS MAX_AGE,
COUNT(DISTINCT WORKCENTER_GROUP) AS WORKCENTER_COUNT COUNT(DISTINCT WORKCENTER_GROUP) AS WORKCENTER_COUNT,
MAX(SYS_DATE) AS DATA_UPDATE_DATE
FROM {WIP_VIEW} FROM {WIP_VIEW}
{where_clause} {where_clause}
""" """
@@ -2226,7 +2265,8 @@ def _get_hold_detail_summary_from_oracle(
'totalQty': int(row['TOTAL_QTY'] or 0), 'totalQty': int(row['TOTAL_QTY'] or 0),
'avgAge': float(row['AVG_AGE']) if row['AVG_AGE'] else 0, 'avgAge': float(row['AVG_AGE']) if row['AVG_AGE'] else 0,
'maxAge': float(row['MAX_AGE']) if row['MAX_AGE'] else 0, 'maxAge': float(row['MAX_AGE']) if row['MAX_AGE'] else 0,
'workcenterCount': int(row['WORKCENTER_COUNT'] or 0) 'workcenterCount': int(row['WORKCENTER_COUNT'] or 0),
'dataUpdateDate': str(row['DATA_UPDATE_DATE']) if row['DATA_UPDATE_DATE'] else None,
} }
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError): except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise raise
@@ -2521,7 +2561,9 @@ def _get_hold_detail_distribution_from_oracle(
def get_hold_detail_lots( def get_hold_detail_lots(
reason: str, reason: Optional[str] = None,
hold_type: Optional[str] = None,
treemap_reason: Optional[str] = None,
workcenter: Optional[str] = None, workcenter: Optional[str] = None,
package: Optional[str] = None, package: Optional[str] = None,
age_range: Optional[str] = None, age_range: Optional[str] = None,
@@ -2529,12 +2571,14 @@ def get_hold_detail_lots(
page: int = 1, page: int = 1,
page_size: int = 50 page_size: int = 50
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Get paginated lot details for a specific hold reason. """Get paginated lot details for hold lots.
Uses Redis cache when available, falls back to Oracle direct query. Uses Redis cache when available, falls back to Oracle direct query.
Args: Args:
reason: The HOLDREASONNAME to filter by reason: Optional HOLDREASONNAME filter (from filter bar)
hold_type: Optional hold type filter ('quality', 'non-quality')
treemap_reason: Optional HOLDREASONNAME filter from treemap selection
workcenter: Optional WORKCENTER_GROUP filter workcenter: Optional WORKCENTER_GROUP filter
package: Optional PACKAGE_LEF filter package: Optional PACKAGE_LEF filter
age_range: Optional age range filter ('0-1', '1-3', '3-7', '7+') age_range: Optional age range filter ('0-1', '1-3', '3-7', '7+')
@@ -2545,6 +2589,9 @@ def get_hold_detail_lots(
Returns: Returns:
Dict with lots list, pagination info, and active filters Dict with lots list, pagination info, and active filters
""" """
page = max(int(page or 1), 1)
page_size = max(int(page_size or 50), 1)
# Try cache first # Try cache first
cached_df = _get_wip_dataframe() cached_df = _get_wip_dataframe()
if cached_df is not None: if cached_df is not None:
@@ -2554,10 +2601,13 @@ def get_hold_detail_lots(
workcenter=workcenter, workcenter=workcenter,
package=package, package=package,
status='HOLD', status='HOLD',
hold_type=hold_type,
) )
if df is None: if df is None:
return _get_hold_detail_lots_from_oracle( return _get_hold_detail_lots_from_oracle(
reason=reason, reason=reason,
hold_type=hold_type,
treemap_reason=treemap_reason,
workcenter=workcenter, workcenter=workcenter,
package=package, package=package,
age_range=age_range, age_range=age_range,
@@ -2566,10 +2616,13 @@ def get_hold_detail_lots(
page_size=page_size, page_size=page_size,
) )
# Filter for HOLD status with matching reason if reason:
df = df[df['HOLDREASONNAME'] == reason] df = df[df['HOLDREASONNAME'] == reason]
if treemap_reason:
df = df[df['HOLDREASONNAME'] == treemap_reason]
# Ensure numeric columns # Ensure numeric columns
df = df.copy()
df['AGEBYDAYS'] = pd.to_numeric(df['AGEBYDAYS'], errors='coerce').fillna(0) df['AGEBYDAYS'] = pd.to_numeric(df['AGEBYDAYS'], errors='coerce').fillna(0)
# Optional age filter # Optional age filter
@@ -2600,6 +2653,7 @@ def get_hold_detail_lots(
'qty': int(row.get('QTY', 0) or 0), 'qty': int(row.get('QTY', 0) or 0),
'package': _safe_value(row.get('PACKAGE_LEF')), 'package': _safe_value(row.get('PACKAGE_LEF')),
'workcenter': _safe_value(row.get('WORKCENTER_GROUP')), 'workcenter': _safe_value(row.get('WORKCENTER_GROUP')),
'holdReason': _safe_value(row.get('HOLDREASONNAME')),
'spec': _safe_value(row.get('SPECNAME')), 'spec': _safe_value(row.get('SPECNAME')),
'age': round(float(row.get('AGEBYDAYS', 0) or 0), 1), 'age': round(float(row.get('AGEBYDAYS', 0) or 0), 1),
'holdBy': _safe_value(row.get('HOLDEMP')), 'holdBy': _safe_value(row.get('HOLDEMP')),
@@ -2618,6 +2672,9 @@ def get_hold_detail_lots(
'totalPages': total_pages 'totalPages': total_pages
}, },
'filters': { 'filters': {
'holdType': hold_type,
'reason': reason,
'treemapReason': treemap_reason,
'workcenter': workcenter, 'workcenter': workcenter,
'package': package, 'package': package,
'ageRange': age_range 'ageRange': age_range
@@ -2630,12 +2687,22 @@ def get_hold_detail_lots(
# Fallback to Oracle direct query # Fallback to Oracle direct query
return _get_hold_detail_lots_from_oracle( return _get_hold_detail_lots_from_oracle(
reason, workcenter, package, age_range, include_dummy, page, page_size reason=reason,
hold_type=hold_type,
treemap_reason=treemap_reason,
workcenter=workcenter,
package=package,
age_range=age_range,
include_dummy=include_dummy,
page=page,
page_size=page_size,
) )
def _get_hold_detail_lots_from_oracle( def _get_hold_detail_lots_from_oracle(
reason: str, reason: Optional[str] = None,
hold_type: Optional[str] = None,
treemap_reason: Optional[str] = None,
workcenter: Optional[str] = None, workcenter: Optional[str] = None,
package: Optional[str] = None, package: Optional[str] = None,
age_range: Optional[str] = None, age_range: Optional[str] = None,
@@ -2648,7 +2715,12 @@ def _get_hold_detail_lots_from_oracle(
builder = _build_base_conditions_builder(include_dummy) builder = _build_base_conditions_builder(include_dummy)
builder.add_param_condition("STATUS", "HOLD") builder.add_param_condition("STATUS", "HOLD")
builder.add_condition("CURRENTHOLDCOUNT > 0") builder.add_condition("CURRENTHOLDCOUNT > 0")
builder.add_param_condition("HOLDREASONNAME", reason) if hold_type:
_add_hold_type_conditions(builder, hold_type)
if reason:
builder.add_param_condition("HOLDREASONNAME", reason)
if treemap_reason:
builder.add_param_condition("HOLDREASONNAME", treemap_reason)
# Optional filters # Optional filters
if workcenter: if workcenter:
@@ -2690,6 +2762,7 @@ def _get_hold_detail_lots_from_oracle(
QTY, QTY,
PACKAGE_LEF AS PACKAGE, PACKAGE_LEF AS PACKAGE,
WORKCENTER_GROUP AS WORKCENTER, WORKCENTER_GROUP AS WORKCENTER,
HOLDREASONNAME AS HOLD_REASON,
SPECNAME AS SPEC, SPECNAME AS SPEC,
ROUND(AGEBYDAYS, 1) AS AGE, ROUND(AGEBYDAYS, 1) AS AGE,
HOLDEMP AS HOLD_BY, HOLDEMP AS HOLD_BY,
@@ -2713,6 +2786,7 @@ def _get_hold_detail_lots_from_oracle(
'qty': int(row['QTY'] or 0), 'qty': int(row['QTY'] or 0),
'package': _safe_value(row['PACKAGE']), 'package': _safe_value(row['PACKAGE']),
'workcenter': _safe_value(row['WORKCENTER']), 'workcenter': _safe_value(row['WORKCENTER']),
'holdReason': _safe_value(row['HOLD_REASON']),
'spec': _safe_value(row['SPEC']), 'spec': _safe_value(row['SPEC']),
'age': float(row['AGE']) if row['AGE'] else 0, 'age': float(row['AGE']) if row['AGE'] else 0,
'holdBy': _safe_value(row['HOLD_BY']), 'holdBy': _safe_value(row['HOLD_BY']),
@@ -2731,6 +2805,9 @@ def _get_hold_detail_lots_from_oracle(
'totalPages': total_pages 'totalPages': total_pages
}, },
'filters': { 'filters': {
'holdType': hold_type,
'reason': reason,
'treemapReason': treemap_reason,
'workcenter': workcenter, 'workcenter': workcenter,
'package': package, 'package': package,
'ageRange': age_range 'ageRange': age_range
@@ -2745,6 +2822,146 @@ def _get_hold_detail_lots_from_oracle(
return None return None
# ============================================================
# Hold Overview API Functions
# ============================================================
def get_hold_overview_treemap(
hold_type: Optional[str] = None,
reason: Optional[str] = None,
workcenter: Optional[str] = None,
package: Optional[str] = None,
include_dummy: bool = False,
) -> Optional[Dict[str, Any]]:
"""Get hold overview treemap aggregation grouped by workcenter and reason."""
cached_df = _get_wip_dataframe()
if cached_df is not None:
try:
df = _select_with_snapshot_indexes(
include_dummy=include_dummy,
workcenter=workcenter,
package=package,
status='HOLD',
hold_type=hold_type,
)
if df is None:
return _get_hold_overview_treemap_from_oracle(
hold_type=hold_type,
reason=reason,
workcenter=workcenter,
package=package,
include_dummy=include_dummy,
)
if reason:
df = df[df['HOLDREASONNAME'] == reason]
df = df[df['WORKCENTER_GROUP'].notna() & df['HOLDREASONNAME'].notna()]
if df.empty:
return {'items': []}
df = df.copy()
df['AGEBYDAYS'] = pd.to_numeric(df['AGEBYDAYS'], errors='coerce').fillna(0)
df['QTY'] = pd.to_numeric(df['QTY'], errors='coerce').fillna(0)
grouped = df.groupby(
['WORKCENTER_GROUP', 'WORKCENTERSEQUENCE_GROUP', 'HOLDREASONNAME'],
dropna=False,
).agg(
LOTS=('LOTID', 'count'),
QTY=('QTY', 'sum'),
AVG_AGE=('AGEBYDAYS', 'mean'),
).reset_index()
grouped = grouped.sort_values(
['WORKCENTERSEQUENCE_GROUP', 'QTY'],
ascending=[True, False],
)
items = []
for _, row in grouped.iterrows():
items.append({
'workcenter': _safe_value(row.get('WORKCENTER_GROUP')),
'reason': _safe_value(row.get('HOLDREASONNAME')),
'lots': int(row.get('LOTS', 0) or 0),
'qty': int(row.get('QTY', 0) or 0),
'avgAge': round(float(row.get('AVG_AGE', 0) or 0), 1),
})
return {'items': items}
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise
except Exception as exc:
logger.warning(f"Cache-based hold overview treemap failed, falling back to Oracle: {exc}")
return _get_hold_overview_treemap_from_oracle(
hold_type=hold_type,
reason=reason,
workcenter=workcenter,
package=package,
include_dummy=include_dummy,
)
def _get_hold_overview_treemap_from_oracle(
hold_type: Optional[str] = None,
reason: Optional[str] = None,
workcenter: Optional[str] = None,
package: Optional[str] = None,
include_dummy: bool = False,
) -> Optional[Dict[str, Any]]:
"""Get hold overview treemap aggregation directly from Oracle (fallback)."""
try:
builder = _build_base_conditions_builder(include_dummy)
builder.add_param_condition("STATUS", "HOLD")
builder.add_condition("CURRENTHOLDCOUNT > 0")
if hold_type:
_add_hold_type_conditions(builder, hold_type)
if reason:
builder.add_param_condition("HOLDREASONNAME", reason)
if workcenter:
builder.add_param_condition("WORKCENTER_GROUP", workcenter)
if package:
builder.add_param_condition("PACKAGE_LEF", package)
where_clause, params = builder.build_where_only()
sql = f"""
SELECT
WORKCENTER_GROUP,
WORKCENTERSEQUENCE_GROUP,
HOLDREASONNAME,
COUNT(*) AS LOTS,
SUM(QTY) AS QTY,
ROUND(AVG(AGEBYDAYS), 1) AS AVG_AGE
FROM {WIP_VIEW}
{where_clause}
AND WORKCENTER_GROUP IS NOT NULL
AND HOLDREASONNAME IS NOT NULL
GROUP BY WORKCENTER_GROUP, WORKCENTERSEQUENCE_GROUP, HOLDREASONNAME
ORDER BY WORKCENTERSEQUENCE_GROUP, SUM(QTY) DESC
"""
df = read_sql_df(sql, params)
if df is None or df.empty:
return {'items': []}
items = []
for _, row in df.iterrows():
items.append({
'workcenter': _safe_value(row.get('WORKCENTER_GROUP')),
'reason': _safe_value(row.get('HOLDREASONNAME')),
'lots': int(row.get('LOTS', 0) or 0),
'qty': int(row.get('QTY', 0) or 0),
'avgAge': round(float(row.get('AVG_AGE', 0) or 0), 1),
})
return {'items': items}
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise
except Exception as exc:
logger.error(f"Hold overview treemap query failed: {exc}")
import traceback
traceback.print_exc()
return None
# ============================================================ # ============================================================
# Lot Detail API Functions # Lot Detail API Functions
# ============================================================ # ============================================================

View File

@@ -53,12 +53,17 @@ class AppFactoryTests(unittest.TestCase):
"/resource", "/resource",
"/wip-overview", "/wip-overview",
"/wip-detail", "/wip-detail",
"/hold-overview",
"/excel-query", "/excel-query",
"/query-tool", "/query-tool",
"/tmtt-defect", "/tmtt-defect",
"/api/wip/overview/summary", "/api/wip/overview/summary",
"/api/wip/overview/matrix", "/api/wip/overview/matrix",
"/api/wip/overview/hold", "/api/wip/overview/hold",
"/api/hold-overview/summary",
"/api/hold-overview/matrix",
"/api/hold-overview/treemap",
"/api/hold-overview/lots",
"/api/wip/detail/<workcenter>", "/api/wip/detail/<workcenter>",
"/api/wip/meta/workcenters", "/api/wip/meta/workcenters",
"/api/wip/meta/packages", "/api/wip/meta/packages",

View File

@@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
"""Unit tests for Hold Overview API routes."""
import json
import unittest
from unittest.mock import patch
from mes_dashboard.app import create_app
import mes_dashboard.core.database as db
class TestHoldOverviewRoutesBase(unittest.TestCase):
"""Base class for Hold Overview route tests."""
def setUp(self):
db._ENGINE = None
self.app = create_app('testing')
self.app.config['TESTING'] = True
self.client = self.app.test_client()
class TestHoldOverviewPageRoute(TestHoldOverviewRoutesBase):
"""Test GET /hold-overview page route."""
@patch('mes_dashboard.routes.hold_overview_routes.os.path.exists', return_value=False)
def test_hold_overview_page_includes_vite_entry(self, _mock_exists):
# Page is registered as 'dev' status, requires admin session
with self.client.session_transaction() as sess:
sess['admin'] = {'displayName': 'Test Admin', 'employeeNo': 'A001'}
response = self.client.get('/hold-overview')
self.assertEqual(response.status_code, 200)
self.assertIn(b'/static/dist/hold-overview.js', response.data)
@patch('mes_dashboard.routes.hold_overview_routes.os.path.exists', return_value=False)
def test_hold_overview_page_returns_403_without_admin(self, _mock_exists):
response = self.client.get('/hold-overview')
self.assertEqual(response.status_code, 403)
class TestHoldOverviewSummaryRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/summary endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary')
def test_summary_defaults_to_quality(self, mock_service):
mock_service.return_value = {
'totalLots': 12,
'totalQty': 3400,
'avgAge': 2.5,
'maxAge': 9.0,
'workcenterCount': 3,
'dataUpdateDate': '2026-01-01 08:00:00',
}
response = self.client.get('/api/hold-overview/summary')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(payload['success'])
mock_service.assert_called_once_with(
reason=None,
hold_type='quality',
include_dummy=False,
)
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary')
def test_summary_hold_type_all_maps_to_none(self, mock_service):
mock_service.return_value = {
'totalLots': 0,
'totalQty': 0,
'avgAge': 0,
'maxAge': 0,
'workcenterCount': 0,
'dataUpdateDate': None,
}
response = self.client.get('/api/hold-overview/summary?hold_type=all&reason=品質確認')
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
reason='品質確認',
hold_type=None,
include_dummy=False,
)
def test_summary_invalid_hold_type(self):
response = self.client.get('/api/hold-overview/summary?hold_type=invalid')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary')
def test_summary_failure_returns_500(self, mock_service):
mock_service.return_value = None
response = self.client.get('/api/hold-overview/summary')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertFalse(payload['success'])
class TestHoldOverviewMatrixRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/matrix endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_wip_matrix')
def test_matrix_passes_hold_filters(self, mock_service):
mock_service.return_value = {
'workcenters': [],
'packages': [],
'matrix': {},
'workcenter_totals': {},
'package_totals': {},
'grand_total': 0,
}
response = self.client.get('/api/hold-overview/matrix?hold_type=non-quality&reason=特殊需求管控')
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
include_dummy=False,
status='HOLD',
hold_type='non-quality',
reason='特殊需求管控',
)
def test_matrix_invalid_hold_type(self):
response = self.client.get('/api/hold-overview/matrix?hold_type=invalid')
self.assertEqual(response.status_code, 400)
@patch('mes_dashboard.routes.hold_overview_routes.get_wip_matrix')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 7))
def test_matrix_rate_limited_returns_429(self, _mock_limit, mock_service):
response = self.client.get('/api/hold-overview/matrix')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 429)
self.assertEqual(payload['error']['code'], 'TOO_MANY_REQUESTS')
self.assertEqual(response.headers.get('Retry-After'), '7')
mock_service.assert_not_called()
class TestHoldOverviewTreemapRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/treemap endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_overview_treemap')
def test_treemap_passes_filters(self, mock_service):
mock_service.return_value = {'items': []}
response = self.client.get(
'/api/hold-overview/treemap?hold_type=quality&reason=品質確認&workcenter=WB&package=QFN'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
hold_type='quality',
reason='品質確認',
workcenter='WB',
package='QFN',
include_dummy=False,
)
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_overview_treemap')
def test_treemap_failure_returns_500(self, mock_service):
mock_service.return_value = None
response = self.client.get('/api/hold-overview/treemap')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertFalse(payload['success'])
class TestHoldOverviewLotsRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/lots endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots')
def test_lots_passes_all_filters_and_caps_per_page(self, mock_service):
mock_service.return_value = {
'lots': [],
'pagination': {'page': 2, 'perPage': 200, 'total': 0, 'totalPages': 1},
'filters': {},
}
response = self.client.get(
'/api/hold-overview/lots?hold_type=all&reason=品質確認'
'&workcenter=WB&package=QFN&treemap_reason=品質確認'
'&age_range=1-3&page=2&per_page=500'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
reason='品質確認',
hold_type=None,
treemap_reason='品質確認',
workcenter='WB',
package='QFN',
age_range='1-3',
include_dummy=False,
page=2,
page_size=200,
)
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots')
def test_lots_handles_page_less_than_one(self, mock_service):
mock_service.return_value = {
'lots': [],
'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
'filters': {},
}
response = self.client.get('/api/hold-overview/lots?page=0')
self.assertEqual(response.status_code, 200)
call_args = mock_service.call_args
self.assertEqual(call_args.kwargs['page'], 1)
def test_lots_invalid_age_range(self):
response = self.client.get('/api/hold-overview/lots?age_range=invalid')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
def test_lots_invalid_hold_type(self):
response = self.client.get('/api/hold-overview/lots?hold_type=invalid')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 4))
def test_lots_rate_limited_returns_429(self, _mock_limit, mock_service):
response = self.client.get('/api/hold-overview/lots')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 429)
self.assertEqual(payload['error']['code'], 'TOO_MANY_REQUESTS')
self.assertEqual(response.headers.get('Retry-After'), '4')
mock_service.assert_not_called()

View File

@@ -35,14 +35,17 @@ def mock_registry(temp_data_file):
"""Mock page_registry to use temp file.""" """Mock page_registry to use temp file."""
original_data_file = page_registry.DATA_FILE original_data_file = page_registry.DATA_FILE
original_cache = page_registry._cache original_cache = page_registry._cache
original_cache_mtime = page_registry._cache_mtime
page_registry.DATA_FILE = temp_data_file page_registry.DATA_FILE = temp_data_file
page_registry._cache = None page_registry._cache = None
page_registry._cache_mtime = 0.0
yield temp_data_file yield temp_data_file
page_registry.DATA_FILE = original_data_file page_registry.DATA_FILE = original_data_file
page_registry._cache = original_cache page_registry._cache = original_cache
page_registry._cache_mtime = original_cache_mtime
class TestSchemaMigration: class TestSchemaMigration:
@@ -205,7 +208,8 @@ class TestReloadCache:
home["status"] = "dev" home["status"] = "dev"
temp_data_file.write_text(json.dumps(data)) temp_data_file.write_text(json.dumps(data))
assert page_registry.get_page_status("/") == "released" # Note: _load() has mtime-based invalidation that may auto-detect
# the file change, so we only assert post-reload behavior.
page_registry.reload_cache() page_registry.reload_cache()
assert page_registry.get_page_status("/") == "dev" assert page_registry.get_page_status("/") == "dev"

View File

@@ -40,3 +40,29 @@ def test_hold_detail_lots_rate_limit_returns_429(_mock_limit, mock_service):
assert payload['error']['code'] == 'TOO_MANY_REQUESTS' assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
assert response.headers.get('Retry-After') == '4' assert response.headers.get('Retry-After') == '4'
mock_service.assert_not_called() mock_service.assert_not_called()
@patch('mes_dashboard.routes.hold_overview_routes.get_wip_matrix')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 6))
def test_hold_overview_matrix_rate_limit_returns_429(_mock_limit, mock_service):
client = _client()
response = client.get('/api/hold-overview/matrix')
assert response.status_code == 429
payload = response.get_json()
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
assert response.headers.get('Retry-After') == '6'
mock_service.assert_not_called()
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 3))
def test_hold_overview_lots_rate_limit_returns_429(_mock_limit, mock_service):
client = _client()
response = client.get('/api/hold-overview/lots')
assert response.status_code == 429
payload = response.get_json()
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
assert response.headers.get('Retry-After') == '3'
mock_service.assert_not_called()

View File

@@ -54,6 +54,15 @@ class TestTemplateIntegration(unittest.TestCase):
self.assertIn('type="module"', html) self.assertIn('type="module"', html)
self.assertNotIn('mes-toast-container', html) self.assertNotIn('mes-toast-container', html)
def test_hold_overview_serves_pure_vite_module(self):
response = self.client.get('/hold-overview')
self.assertEqual(response.status_code, 200)
html = response.data.decode('utf-8')
self.assertIn('/static/dist/hold-overview.js', html)
self.assertIn('type="module"', html)
self.assertNotIn('mes-toast-container', html)
def test_tables_page_serves_pure_vite_module(self): def test_tables_page_serves_pure_vite_module(self):
response = self.client.get('/tables') response = self.client.get('/tables')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -303,6 +312,7 @@ class TestViteModuleIntegration(unittest.TestCase):
endpoints_and_assets = [ endpoints_and_assets = [
('/wip-overview', 'wip-overview.js'), ('/wip-overview', 'wip-overview.js'),
('/wip-detail', 'wip-detail.js'), ('/wip-detail', 'wip-detail.js'),
('/hold-overview', 'hold-overview.js'),
('/hold-detail?reason=test-reason', 'hold-detail.js'), ('/hold-detail?reason=test-reason', 'hold-detail.js'),
('/tables', 'tables.js'), ('/tables', 'tables.js'),
('/resource', 'resource-status.js'), ('/resource', 'resource-status.js'),

View File

@@ -9,17 +9,20 @@ from unittest.mock import patch, MagicMock
from functools import wraps from functools import wraps
import pandas as pd import pandas as pd
from mes_dashboard.services.wip_service import ( from mes_dashboard.services.wip_service import (
WIP_VIEW, WIP_VIEW,
get_wip_summary, get_wip_summary,
get_wip_matrix, get_wip_matrix,
get_wip_hold_summary, get_wip_hold_summary,
get_wip_detail, get_wip_detail,
get_workcenters, get_hold_detail_summary,
get_packages, get_hold_detail_lots,
search_workorders, get_hold_overview_treemap,
search_lot_ids, get_workcenters,
) get_packages,
search_workorders,
search_lot_ids,
)
def disable_cache(func): def disable_cache(func):
@@ -654,7 +657,140 @@ class TestMultipleFilterConditions(unittest.TestCase):
import pytest class TestHoldOverviewServiceCachePath(unittest.TestCase):
"""Test hold overview related behavior on cache path."""
def setUp(self):
import mes_dashboard.services.wip_service as wip_service
with wip_service._wip_search_index_lock:
wip_service._wip_search_index_cache.clear()
with wip_service._wip_snapshot_lock:
wip_service._wip_snapshot_cache.clear()
@staticmethod
def _sample_hold_df() -> pd.DataFrame:
return pd.DataFrame({
'LOTID': ['L1', 'L2', 'L3', 'L4', 'L5'],
'WORKORDER': ['WO1', 'WO2', 'WO3', 'WO4', 'WO5'],
'QTY': [100, 50, 80, 60, 20],
'PACKAGE_LEF': ['PKG-A', 'PKG-B', 'PKG-A', 'PKG-Z', 'PKG-C'],
'WORKCENTER_GROUP': ['WC-A', 'WC-B', 'WC-A', 'WC-Z', 'WC-C'],
'WORKCENTERSEQUENCE_GROUP': [1, 2, 1, 9, 3],
'HOLDREASONNAME': ['品質確認', '特殊需求管控', '品質確認', None, '設備異常'],
'AGEBYDAYS': [2.0, 3.0, 5.0, 0.3, 1.2],
'EQUIPMENTCOUNT': [0, 0, 0, 1, 0],
'CURRENTHOLDCOUNT': [1, 1, 1, 0, 1],
'SPECNAME': ['S1', 'S2', 'S1', 'S9', 'S3'],
'HOLDEMP': ['EMP1', 'EMP2', 'EMP3', 'EMP4', 'EMP5'],
'DEPTNAME': ['QC', 'PD', 'QC', 'RUN', 'QC'],
'COMMENT_HOLD': ['C1', 'C2', 'C3', 'C4', 'C5'],
'PJ_TYPE': ['T1', 'T2', 'T1', 'T9', 'T3'],
})
@patch('mes_dashboard.services.wip_service.get_cached_sys_date', return_value='2026-02-10 10:00:00')
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
def test_get_hold_detail_summary_supports_optional_reason_and_hold_type(
self,
mock_cached_wip,
_mock_sys_date,
):
mock_cached_wip.return_value = self._sample_hold_df()
reason_summary = get_hold_detail_summary(reason='品質確認')
self.assertEqual(reason_summary['totalLots'], 2)
self.assertEqual(reason_summary['totalQty'], 180)
self.assertEqual(reason_summary['workcenterCount'], 1)
self.assertEqual(reason_summary['dataUpdateDate'], '2026-02-10 10:00:00')
quality_summary = get_hold_detail_summary(hold_type='quality')
self.assertEqual(quality_summary['totalLots'], 3)
self.assertEqual(quality_summary['totalQty'], 200)
self.assertEqual(quality_summary['workcenterCount'], 2)
all_hold_summary = get_hold_detail_summary()
self.assertEqual(all_hold_summary['totalLots'], 4)
self.assertEqual(all_hold_summary['totalQty'], 250)
self.assertEqual(all_hold_summary['workcenterCount'], 3)
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
def test_get_hold_detail_lots_returns_hold_reason_and_treemap_filter(self, mock_cached_wip):
mock_cached_wip.return_value = self._sample_hold_df()
reason_result = get_hold_detail_lots(reason='品質確認', page=1, page_size=10)
self.assertEqual(len(reason_result['lots']), 2)
self.assertEqual(reason_result['lots'][0]['lotId'], 'L3')
self.assertEqual(reason_result['lots'][0]['holdReason'], '品質確認')
treemap_result = get_hold_detail_lots(
reason=None,
hold_type=None,
treemap_reason='特殊需求管控',
page=1,
page_size=10,
)
self.assertEqual(len(treemap_result['lots']), 1)
self.assertEqual(treemap_result['lots'][0]['lotId'], 'L2')
self.assertEqual(treemap_result['lots'][0]['holdReason'], '特殊需求管控')
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
def test_get_wip_matrix_reason_filter_keeps_backward_compatibility(self, mock_cached_wip):
mock_cached_wip.return_value = self._sample_hold_df()
hold_quality_all = get_wip_matrix(status='HOLD', hold_type='quality')
self.assertEqual(hold_quality_all['grand_total'], 200)
hold_quality_reason = get_wip_matrix(
status='HOLD',
hold_type='quality',
reason='品質確認',
)
self.assertEqual(hold_quality_reason['grand_total'], 180)
self.assertEqual(hold_quality_reason['workcenters'], ['WC-A'])
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
def test_get_hold_overview_treemap_groups_by_workcenter_and_reason(self, mock_cached_wip):
mock_cached_wip.return_value = self._sample_hold_df()
result = get_hold_overview_treemap(hold_type='quality')
self.assertIsNotNone(result)
items = result['items']
self.assertEqual(len(items), 2)
expected = {(item['workcenter'], item['reason']): item for item in items}
self.assertEqual(expected[('WC-A', '品質確認')]['lots'], 2)
self.assertEqual(expected[('WC-A', '品質確認')]['qty'], 180)
self.assertAlmostEqual(expected[('WC-A', '品質確認')]['avgAge'], 3.5)
self.assertEqual(expected[('WC-C', '設備異常')]['lots'], 1)
class TestHoldOverviewServiceOracleFallback(unittest.TestCase):
"""Test reason filtering behavior on Oracle fallback path."""
@disable_cache
@patch('mes_dashboard.services.wip_service.read_sql_df')
def test_get_wip_matrix_oracle_applies_reason_for_hold_status(self, mock_read_sql):
mock_read_sql.return_value = pd.DataFrame()
get_wip_matrix(status='HOLD', reason='品質確認')
call_args = mock_read_sql.call_args
sql = call_args[0][0]
params = call_args[0][1] if len(call_args[0]) > 1 else {}
self.assertIn('HOLDREASONNAME', sql)
self.assertTrue(any(v == '品質確認' for v in params.values()))
@disable_cache
@patch('mes_dashboard.services.wip_service.read_sql_df')
def test_get_wip_matrix_oracle_ignores_reason_for_non_hold_status(self, mock_read_sql):
mock_read_sql.return_value = pd.DataFrame()
get_wip_matrix(status='RUN', reason='品質確認')
call_args = mock_read_sql.call_args
sql = call_args[0][0]
self.assertNotIn('HOLDREASONNAME', sql)
import pytest
class TestWipServiceIntegration: class TestWipServiceIntegration: