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:
@@ -12,6 +12,13 @@
|
||||
"drawer_id": "reports",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/hold-overview",
|
||||
"name": "Hold 即時概況",
|
||||
"status": "dev",
|
||||
"drawer_id": "reports",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"route": "/wip-detail",
|
||||
"name": "WIP 明細",
|
||||
@@ -27,14 +34,14 @@
|
||||
"name": "設備歷史績效",
|
||||
"status": "released",
|
||||
"drawer_id": "reports",
|
||||
"order": 3
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"route": "/qc-gate",
|
||||
"name": "QC-GATE 狀態",
|
||||
"status": "released",
|
||||
"drawer_id": "reports",
|
||||
"order": 4
|
||||
"order": 5
|
||||
},
|
||||
{
|
||||
"route": "/tables",
|
||||
@@ -48,7 +55,7 @@
|
||||
"name": "設備即時概況",
|
||||
"status": "released",
|
||||
"drawer_id": "reports",
|
||||
"order": 2
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"route": "/excel-query",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
521
frontend/src/hold-overview/App.vue
Normal file
521
frontend/src/hold-overview/App.vue
Normal 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">← 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 }">✓</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>
|
||||
99
frontend/src/hold-overview/components/FilterBar.vue
Normal file
99
frontend/src/hold-overview/components/FilterBar.vue
Normal 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>
|
||||
77
frontend/src/hold-overview/components/FilterIndicator.vue
Normal file
77
frontend/src/hold-overview/components/FilterIndicator.vue
Normal 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>
|
||||
149
frontend/src/hold-overview/components/HoldMatrix.vue
Normal file
149
frontend/src/hold-overview/components/HoldMatrix.vue
Normal 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>
|
||||
225
frontend/src/hold-overview/components/HoldTreeMap.vue
Normal file
225
frontend/src/hold-overview/components/HoldTreeMap.vue
Normal 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>綠(<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>紅(>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>
|
||||
132
frontend/src/hold-overview/components/LotTable.vue
Normal file
132
frontend/src/hold-overview/components/LotTable.vue
Normal 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>
|
||||
12
frontend/src/hold-overview/index.html
Normal file
12
frontend/src/hold-overview/index.html
Normal 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>
|
||||
7
frontend/src/hold-overview/main.js
Normal file
7
frontend/src/hold-overview/main.js
Normal 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');
|
||||
374
frontend/src/hold-overview/style.css
Normal file
374
frontend/src/hold-overview/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => ({
|
||||
'wip-overview': resolve(__dirname, 'src/wip-overview/index.html'),
|
||||
'wip-detail': resolve(__dirname, 'src/wip-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-history': resolve(__dirname, 'src/resource-history/index.html'),
|
||||
'job-query': resolve(__dirname, 'src/job-query/main.js'),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
127
openspec/changes/archive/2026-02-10-hold-lot-overview/design.md
Normal file
127
openspec/changes/archive/2026-02-10-hold-lot-overview/design.md
Normal file
@@ -0,0 +1,127 @@
|
||||
## Context
|
||||
|
||||
MES Dashboard 已有 WIP Overview(全局 RUN/QUEUE/HOLD 概況)和 Hold Detail(單一 Hold Reason 明細)。主管需要一個專用頁面,聚焦在 Hold Lot 的全局分析。
|
||||
|
||||
現有架構:Vue 3 SFC + Flask + Oracle(DWH.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/workcenterCount,reason 改 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 Bar(Hold 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 種類很多(>20),TreeMap 小區塊會難以辨識 → 可考慮只顯示 Top N reason,其餘歸為「其他」
|
||||
- **[Matrix 與 TreeMap 同時篩選]** 使用者可能忘記已有 matrix 篩選,誤以為 TreeMap 是全局 → 需要明確的 active filter 指示器和一鍵清除功能
|
||||
- **[ECharts TreeMap 效能]** 大量區塊時 TreeMap 渲染可能卡頓 → ECharts TreeMap 有內建 leafDepth 限制,測試時注意超過 200 個葉節點的情境
|
||||
- **[Cache 一致性]** Hold Overview 與 WIP Overview 共用同一份 cache,auto-refresh 週期相同(10 分鐘),不需調整 cache 策略
|
||||
@@ -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 endpoints(summary / matrix / treemap / lots)
|
||||
- 頁面預設只顯示品質異常 Hold,可切換至非品質異常或全部
|
||||
- 提供 Workcenter x Package Matrix(如 WIP Overview),數字可點擊篩選下方所有資料
|
||||
- 提供 TreeMap 視覺化(WC → Reason 層級,面積=QTY,顏色=平均滯留天數)
|
||||
- 提供 paginated Hold Lot 明細表
|
||||
- 篩選 cascade 機制:Filter Bar → 全部重載;Matrix 點擊 → TreeMap + Table;TreeMap 點擊 → 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 Blueprint,4 支 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
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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 lots;hold_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 lots;treemap_reason 作為額外 HOLDREASONNAME 過濾(TreeMap 點擊篩選用);增加 holdReason 欄位到 lot 回傳資料中;確保現有 Hold Detail 呼叫不受影響
|
||||
- [x] 1.3 擴充 `get_wip_matrix()` 簽名:新增 `reason: Optional[str] = None` 參數,過濾 HOLDREASONNAME;cache path 用 DataFrame filter,Oracle fallback 用 QueryBuilder;reason=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 支 API(summary/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 reasons);emit `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 highlight;toggle 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 實作 tooltip(workcenter, 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.vue:import 所有元件(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 cascade:filter bar 變更 → 清除 matrixFilter + treemapFilter → `loadAllData()`;matrix click → set matrixFilter, 清除 treemapFilter → `loadTreemapAndLots()`;treemap click → set treemapFilter → `loadLots()`
|
||||
- [x] 11.4 實作 loading states(initialLoading 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 功能正常不受影響
|
||||
141
openspec/specs/hold-overview-api/spec.md
Normal file
141
openspec/specs/hold-overview-api/spec.md
Normal 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)
|
||||
200
openspec/specs/hold-overview-page/spec.md
Normal file
200
openspec/specs/hold-overview-page/spec.md
Normal 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`
|
||||
@@ -33,7 +33,7 @@ The Vite build configuration SHALL support Vue Single File Components alongside
|
||||
#### Scenario: Chunk splitting
|
||||
- **WHEN** Vite builds the project
|
||||
- **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
|
||||
|
||||
#### 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** 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., `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** cross-module imports SHALL NOT create unexpected shared chunks
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from .resource_routes import resource_bp
|
||||
from .dashboard_routes import dashboard_bp
|
||||
from .excel_query_routes import excel_query_bp
|
||||
from .hold_routes import hold_bp
|
||||
from .hold_overview_routes import hold_overview_bp
|
||||
from .auth_routes import auth_bp
|
||||
from .admin_routes import admin_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(excel_query_bp)
|
||||
app.register_blueprint(hold_bp)
|
||||
app.register_blueprint(hold_overview_bp)
|
||||
app.register_blueprint(resource_history_bp)
|
||||
app.register_blueprint(job_query_bp)
|
||||
app.register_blueprint(query_tool_bp)
|
||||
@@ -39,6 +41,7 @@ __all__ = [
|
||||
'dashboard_bp',
|
||||
'excel_query_bp',
|
||||
'hold_bp',
|
||||
'hold_overview_bp',
|
||||
'auth_bp',
|
||||
'admin_bp',
|
||||
'resource_history_bp',
|
||||
|
||||
181
src/mes_dashboard/routes/hold_overview_routes.py
Normal file
181
src/mes_dashboard/routes/hold_overview_routes.py
Normal 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
|
||||
@@ -878,6 +878,7 @@ def get_wip_matrix(
|
||||
lotid: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
hold_type: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
package: Optional[str] = None,
|
||||
pj_type: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
@@ -892,6 +893,8 @@ def get_wip_matrix(
|
||||
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
|
||||
hold_type: Optional hold type filter ('quality', 'non-quality')
|
||||
Only effective when status='HOLD'
|
||||
reason: Optional HOLDREASONNAME filter
|
||||
Only effective when status='HOLD'
|
||||
package: Optional PACKAGE_LEF filter (exact match)
|
||||
pj_type: Optional PJ_TYPE filter (exact match)
|
||||
|
||||
@@ -910,6 +913,7 @@ def get_wip_matrix(
|
||||
try:
|
||||
status_upper = status.upper() if status 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(
|
||||
include_dummy=include_dummy,
|
||||
workorder=workorder,
|
||||
@@ -926,10 +930,14 @@ def get_wip_matrix(
|
||||
lotid,
|
||||
status,
|
||||
hold_type,
|
||||
reason,
|
||||
package,
|
||||
pj_type,
|
||||
)
|
||||
|
||||
if reason_filter:
|
||||
df = df[df['HOLDREASONNAME'] == reason_filter]
|
||||
|
||||
# Filter by WORKCENTER_GROUP and PACKAGE_LEF
|
||||
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}")
|
||||
|
||||
# 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]:
|
||||
@@ -1012,6 +1029,7 @@ def _get_wip_matrix_from_oracle(
|
||||
lotid: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
hold_type: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
package: Optional[str] = None,
|
||||
pj_type: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
@@ -1037,6 +1055,8 @@ def _get_wip_matrix_from_oracle(
|
||||
# Hold type sub-filter
|
||||
if hold_type:
|
||||
_add_hold_type_conditions(builder, hold_type)
|
||||
if reason:
|
||||
builder.add_param_condition("HOLDREASONNAME", reason)
|
||||
elif status_upper == 'QUEUE':
|
||||
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(
|
||||
reason: str,
|
||||
include_dummy: bool = False
|
||||
reason: Optional[str] = None,
|
||||
hold_type: Optional[str] = None,
|
||||
include_dummy: bool = False,
|
||||
) -> 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.
|
||||
|
||||
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)
|
||||
|
||||
Returns:
|
||||
Dict with totalLots, totalQty, avgAge, maxAge, workcenterCount
|
||||
Dict with totalLots, totalQty, avgAge, maxAge, workcenterCount, dataUpdateDate
|
||||
"""
|
||||
# Try cache first
|
||||
cached_df = _get_wip_dataframe()
|
||||
@@ -2158,12 +2180,17 @@ def get_hold_detail_summary(
|
||||
df = _select_with_snapshot_indexes(
|
||||
include_dummy=include_dummy,
|
||||
status='HOLD',
|
||||
hold_type=hold_type,
|
||||
)
|
||||
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
|
||||
df = df[df['HOLDREASONNAME'] == reason]
|
||||
if reason:
|
||||
df = df[df['HOLDREASONNAME'] == reason]
|
||||
|
||||
if df.empty:
|
||||
return {
|
||||
@@ -2171,10 +2198,12 @@ def get_hold_detail_summary(
|
||||
'totalQty': 0,
|
||||
'avgAge': 0,
|
||||
'maxAge': 0,
|
||||
'workcenterCount': 0
|
||||
'workcenterCount': 0,
|
||||
'dataUpdateDate': get_cached_sys_date(),
|
||||
}
|
||||
|
||||
# Ensure AGEBYDAYS is numeric
|
||||
df = df.copy()
|
||||
df['AGEBYDAYS'] = pd.to_numeric(df['AGEBYDAYS'], errors='coerce').fillna(0)
|
||||
|
||||
return {
|
||||
@@ -2182,7 +2211,8 @@ def get_hold_detail_summary(
|
||||
'totalQty': int(df['QTY'].sum()),
|
||||
'avgAge': round(float(df['AGEBYDAYS'].mean()), 1),
|
||||
'maxAge': float(df['AGEBYDAYS'].max()),
|
||||
'workcenterCount': df['WORKCENTER_GROUP'].nunique()
|
||||
'workcenterCount': df['WORKCENTER_GROUP'].nunique(),
|
||||
'dataUpdateDate': get_cached_sys_date(),
|
||||
}
|
||||
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
|
||||
raise
|
||||
@@ -2190,19 +2220,27 @@ def get_hold_detail_summary(
|
||||
logger.warning(f"Cache-based hold detail summary failed, falling back to Oracle: {exc}")
|
||||
|
||||
# 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(
|
||||
reason: str,
|
||||
include_dummy: bool = False
|
||||
reason: Optional[str] = None,
|
||||
hold_type: Optional[str] = None,
|
||||
include_dummy: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get hold detail summary directly from Oracle (fallback)."""
|
||||
try:
|
||||
builder = _build_base_conditions_builder(include_dummy)
|
||||
builder.add_param_condition("STATUS", "HOLD")
|
||||
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()
|
||||
|
||||
sql = f"""
|
||||
@@ -2211,7 +2249,8 @@ def _get_hold_detail_summary_from_oracle(
|
||||
SUM(QTY) AS TOTAL_QTY,
|
||||
ROUND(AVG(AGEBYDAYS), 1) AS AVG_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}
|
||||
{where_clause}
|
||||
"""
|
||||
@@ -2226,7 +2265,8 @@ def _get_hold_detail_summary_from_oracle(
|
||||
'totalQty': int(row['TOTAL_QTY'] or 0),
|
||||
'avgAge': float(row['AVG_AGE']) if row['AVG_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):
|
||||
raise
|
||||
@@ -2521,7 +2561,9 @@ def _get_hold_detail_distribution_from_oracle(
|
||||
|
||||
|
||||
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,
|
||||
package: Optional[str] = None,
|
||||
age_range: Optional[str] = None,
|
||||
@@ -2529,12 +2571,14 @@ def get_hold_detail_lots(
|
||||
page: int = 1,
|
||||
page_size: int = 50
|
||||
) -> 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.
|
||||
|
||||
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
|
||||
package: Optional PACKAGE_LEF filter
|
||||
age_range: Optional age range filter ('0-1', '1-3', '3-7', '7+')
|
||||
@@ -2545,6 +2589,9 @@ def get_hold_detail_lots(
|
||||
Returns:
|
||||
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
|
||||
cached_df = _get_wip_dataframe()
|
||||
if cached_df is not None:
|
||||
@@ -2554,10 +2601,13 @@ def get_hold_detail_lots(
|
||||
workcenter=workcenter,
|
||||
package=package,
|
||||
status='HOLD',
|
||||
hold_type=hold_type,
|
||||
)
|
||||
if df is None:
|
||||
return _get_hold_detail_lots_from_oracle(
|
||||
reason=reason,
|
||||
hold_type=hold_type,
|
||||
treemap_reason=treemap_reason,
|
||||
workcenter=workcenter,
|
||||
package=package,
|
||||
age_range=age_range,
|
||||
@@ -2566,10 +2616,13 @@ def get_hold_detail_lots(
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
# Filter for HOLD status with matching reason
|
||||
df = df[df['HOLDREASONNAME'] == reason]
|
||||
if reason:
|
||||
df = df[df['HOLDREASONNAME'] == reason]
|
||||
if treemap_reason:
|
||||
df = df[df['HOLDREASONNAME'] == treemap_reason]
|
||||
|
||||
# Ensure numeric columns
|
||||
df = df.copy()
|
||||
df['AGEBYDAYS'] = pd.to_numeric(df['AGEBYDAYS'], errors='coerce').fillna(0)
|
||||
|
||||
# Optional age filter
|
||||
@@ -2600,6 +2653,7 @@ def get_hold_detail_lots(
|
||||
'qty': int(row.get('QTY', 0) or 0),
|
||||
'package': _safe_value(row.get('PACKAGE_LEF')),
|
||||
'workcenter': _safe_value(row.get('WORKCENTER_GROUP')),
|
||||
'holdReason': _safe_value(row.get('HOLDREASONNAME')),
|
||||
'spec': _safe_value(row.get('SPECNAME')),
|
||||
'age': round(float(row.get('AGEBYDAYS', 0) or 0), 1),
|
||||
'holdBy': _safe_value(row.get('HOLDEMP')),
|
||||
@@ -2618,6 +2672,9 @@ def get_hold_detail_lots(
|
||||
'totalPages': total_pages
|
||||
},
|
||||
'filters': {
|
||||
'holdType': hold_type,
|
||||
'reason': reason,
|
||||
'treemapReason': treemap_reason,
|
||||
'workcenter': workcenter,
|
||||
'package': package,
|
||||
'ageRange': age_range
|
||||
@@ -2630,12 +2687,22 @@ def get_hold_detail_lots(
|
||||
|
||||
# Fallback to Oracle direct query
|
||||
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(
|
||||
reason: str,
|
||||
reason: Optional[str] = None,
|
||||
hold_type: Optional[str] = None,
|
||||
treemap_reason: Optional[str] = None,
|
||||
workcenter: Optional[str] = None,
|
||||
package: 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.add_param_condition("STATUS", "HOLD")
|
||||
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
|
||||
if workcenter:
|
||||
@@ -2690,6 +2762,7 @@ def _get_hold_detail_lots_from_oracle(
|
||||
QTY,
|
||||
PACKAGE_LEF AS PACKAGE,
|
||||
WORKCENTER_GROUP AS WORKCENTER,
|
||||
HOLDREASONNAME AS HOLD_REASON,
|
||||
SPECNAME AS SPEC,
|
||||
ROUND(AGEBYDAYS, 1) AS AGE,
|
||||
HOLDEMP AS HOLD_BY,
|
||||
@@ -2713,6 +2786,7 @@ def _get_hold_detail_lots_from_oracle(
|
||||
'qty': int(row['QTY'] or 0),
|
||||
'package': _safe_value(row['PACKAGE']),
|
||||
'workcenter': _safe_value(row['WORKCENTER']),
|
||||
'holdReason': _safe_value(row['HOLD_REASON']),
|
||||
'spec': _safe_value(row['SPEC']),
|
||||
'age': float(row['AGE']) if row['AGE'] else 0,
|
||||
'holdBy': _safe_value(row['HOLD_BY']),
|
||||
@@ -2731,6 +2805,9 @@ def _get_hold_detail_lots_from_oracle(
|
||||
'totalPages': total_pages
|
||||
},
|
||||
'filters': {
|
||||
'holdType': hold_type,
|
||||
'reason': reason,
|
||||
'treemapReason': treemap_reason,
|
||||
'workcenter': workcenter,
|
||||
'package': package,
|
||||
'ageRange': age_range
|
||||
@@ -2745,6 +2822,146 @@ def _get_hold_detail_lots_from_oracle(
|
||||
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
|
||||
# ============================================================
|
||||
|
||||
@@ -53,12 +53,17 @@ class AppFactoryTests(unittest.TestCase):
|
||||
"/resource",
|
||||
"/wip-overview",
|
||||
"/wip-detail",
|
||||
"/hold-overview",
|
||||
"/excel-query",
|
||||
"/query-tool",
|
||||
"/tmtt-defect",
|
||||
"/api/wip/overview/summary",
|
||||
"/api/wip/overview/matrix",
|
||||
"/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/meta/workcenters",
|
||||
"/api/wip/meta/packages",
|
||||
|
||||
228
tests/test_hold_overview_routes.py
Normal file
228
tests/test_hold_overview_routes.py
Normal 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()
|
||||
|
||||
@@ -35,14 +35,17 @@ def mock_registry(temp_data_file):
|
||||
"""Mock page_registry to use temp file."""
|
||||
original_data_file = page_registry.DATA_FILE
|
||||
original_cache = page_registry._cache
|
||||
original_cache_mtime = page_registry._cache_mtime
|
||||
|
||||
page_registry.DATA_FILE = temp_data_file
|
||||
page_registry._cache = None
|
||||
page_registry._cache_mtime = 0.0
|
||||
|
||||
yield temp_data_file
|
||||
|
||||
page_registry.DATA_FILE = original_data_file
|
||||
page_registry._cache = original_cache
|
||||
page_registry._cache_mtime = original_cache_mtime
|
||||
|
||||
|
||||
class TestSchemaMigration:
|
||||
@@ -205,7 +208,8 @@ class TestReloadCache:
|
||||
home["status"] = "dev"
|
||||
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()
|
||||
assert page_registry.get_page_status("/") == "dev"
|
||||
|
||||
|
||||
@@ -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 response.headers.get('Retry-After') == '4'
|
||||
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()
|
||||
|
||||
@@ -54,6 +54,15 @@ class TestTemplateIntegration(unittest.TestCase):
|
||||
self.assertIn('type="module"', 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):
|
||||
response = self.client.get('/tables')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -303,6 +312,7 @@ class TestViteModuleIntegration(unittest.TestCase):
|
||||
endpoints_and_assets = [
|
||||
('/wip-overview', 'wip-overview.js'),
|
||||
('/wip-detail', 'wip-detail.js'),
|
||||
('/hold-overview', 'hold-overview.js'),
|
||||
('/hold-detail?reason=test-reason', 'hold-detail.js'),
|
||||
('/tables', 'tables.js'),
|
||||
('/resource', 'resource-status.js'),
|
||||
|
||||
@@ -9,17 +9,20 @@ from unittest.mock import patch, MagicMock
|
||||
from functools import wraps
|
||||
import pandas as pd
|
||||
|
||||
from mes_dashboard.services.wip_service import (
|
||||
WIP_VIEW,
|
||||
get_wip_summary,
|
||||
get_wip_matrix,
|
||||
get_wip_hold_summary,
|
||||
get_wip_detail,
|
||||
get_workcenters,
|
||||
get_packages,
|
||||
search_workorders,
|
||||
search_lot_ids,
|
||||
)
|
||||
from mes_dashboard.services.wip_service import (
|
||||
WIP_VIEW,
|
||||
get_wip_summary,
|
||||
get_wip_matrix,
|
||||
get_wip_hold_summary,
|
||||
get_wip_detail,
|
||||
get_hold_detail_summary,
|
||||
get_hold_detail_lots,
|
||||
get_hold_overview_treemap,
|
||||
get_workcenters,
|
||||
get_packages,
|
||||
search_workorders,
|
||||
search_lot_ids,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user