feat(hold-overview): restructure WIP/Hold pages — migrate Pareto, unify filters and lot table

- Move Hold Pareto charts from WIP Overview to Hold Overview with conditional visibility by holdType
- WIP Overview hold cards now navigate to Hold Overview instead of filtering matrix
- Add 6-field FilterPanel to Hold Overview (reuse WIP Overview's FilterPanel)
- Default holdType to "all" when entering Hold Overview directly
- Unify lot table to 13 columns (shared HoldLotTable component) across Hold Overview and Hold Detail
- Hold Detail back button now returns to Hold Overview instead of WIP Overview
- Backend: thread WIP filter params through hold-overview summary/matrix/lots APIs
- Fix nativeModuleRegistry CSS imports for hold-overview and query-tool
- Migrate ParetoSection.vue and pareto CSS to wip-shared for cross-page reuse

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-03-03 18:35:52 +08:00
parent 777751311c
commit da2c2f7879
25 changed files with 1258 additions and 296 deletions

View File

@@ -5,10 +5,10 @@ import { apiGet } from '../core/api.js';
import { navigateToRuntimeRoute, replaceRuntimeHistory, toRuntimeRoute } from '../core/shell-navigation.js';
import { NON_QUALITY_HOLD_REASON_SET } from '../wip-shared/constants.js';
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
import HoldLotTable from '../wip-shared/components/HoldLotTable.vue';
import AgeDistribution from './components/AgeDistribution.vue';
import DistributionTable from './components/DistributionTable.vue';
import LotTable from './components/LotTable.vue';
import SummaryCards from './components/SummaryCards.vue';
const API_TIMEOUT = 60000;
@@ -104,7 +104,7 @@ const holdType = computed(() => {
});
const holdTypeLabel = computed(() => (holdType.value === 'quality' ? '品質異常' : '非品質異常'));
const backToOverviewHref = toRuntimeRoute('/wip-overview');
const backToOverviewHref = toRuntimeRoute('/hold-overview');
const headerStyle = computed(() => ({
'--header-gradient': holdType.value === 'quality'
@@ -292,7 +292,7 @@ onMounted(() => {
}
if (!reason.value) {
navigateToRuntimeRoute('/wip-overview', { replace: true });
navigateToRuntimeRoute('/hold-overview', { replace: true });
return;
}
updateUrlState();
@@ -304,7 +304,7 @@ onMounted(() => {
<div class="dashboard hold-detail-page">
<header class="header" :style="headerStyle">
<div class="header-left">
<a :href="backToOverviewHref" class="btn btn-back">&larr; WIP Overview</a>
<a :href="backToOverviewHref" class="btn btn-back">&larr; Hold Overview</a>
<h1>Hold Detail: {{ reason }}</h1>
<span class="hold-type-badge">{{ holdTypeLabel }}</span>
</div>
@@ -343,13 +343,14 @@ onMounted(() => {
/>
</section>
<LotTable
<HoldLotTable
:lots="lots"
:pagination="pagination"
:loading="lotsLoading"
:error-message="lotsError"
:has-active-filters="hasActiveFilters"
:filter-text="filterText"
title="Lot Details"
@clear-filters="clearFilters"
@prev-page="prevPage"
@next-page="nextPage"

View File

@@ -225,9 +225,8 @@
}
.lot-table th:nth-child(3),
.lot-table th:nth-child(7),
.lot-table td:nth-child(3),
.lot-table td:nth-child(7) {
.lot-table td:nth-child(3) {
text-align: right;
}

View File

@@ -1,28 +1,51 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js';
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
import { navigateToRuntimeRoute, replaceRuntimeHistory } from '../core/shell-navigation.js';
import { buildWipOverviewQueryParams, splitHoldByType } from '../core/wip-derive.js';
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
import HoldLotTable from '../wip-shared/components/HoldLotTable.vue';
import ParetoSection from '../wip-shared/components/ParetoSection.vue';
import FilterPanel from '../wip-overview/components/FilterPanel.vue';
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 LotTable from './components/LotTable.vue';
const API_TIMEOUT = 60000;
const DEFAULT_PER_PAGE = 50;
const FILTER_OPTION_DEBOUNCE_MS = 120;
const summary = ref(null);
const matrix = ref(null);
const hold = ref(null);
const lots = ref([]);
const filterBar = reactive({
holdType: 'quality',
holdType: 'all',
reason: '',
});
const filterOptions = ref({
workorders: [],
lotids: [],
packages: [],
types: [],
firstnames: [],
waferdescs: [],
});
const filters = reactive({
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
const matrixFilter = ref(null);
const pagination = ref({
@@ -42,6 +65,8 @@ const errorMessage = ref('');
const lotsError = ref('');
let activeRequestId = 0;
let filterOptionsDebounceTimer = null;
let filterOptionsRequestToken = 0;
const holdTypeLabel = computed(() => {
if (filterBar.holdType === 'non-quality') {
@@ -53,7 +78,8 @@ const holdTypeLabel = computed(() => {
return '品質異常';
});
const hasCascadeFilters = computed(() => Boolean(matrixFilter.value));
const showQualityPareto = computed(() => filterBar.holdType !== 'non-quality');
const showNonQualityPareto = computed(() => filterBar.holdType !== 'quality');
const lotFilterText = computed(() => {
const parts = [];
@@ -98,6 +124,19 @@ const reasonOptions = computed(() => {
);
});
const splitHold = computed(() => {
const base = splitHoldByType(hold.value);
const activeReason = String(filterBar.reason || '').trim();
if (!activeReason) {
return base;
}
return {
quality: base.quality.filter((item) => String(item?.reason || '').trim() === activeReason),
nonQuality: base.nonQuality.filter((item) => String(item?.reason || '').trim() === activeReason),
};
});
function nextRequestId() {
activeRequestId += 1;
return activeRequestId;
@@ -124,41 +163,54 @@ function getUrlParam(name) {
return new URLSearchParams(window.location.search).get(name)?.trim() || '';
}
function parseCsvParam(name) {
const raw = getUrlParam(name);
if (!raw) {
return [];
}
return raw
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function normalizeArrayValues(values) {
if (!values) {
return [];
}
if (Array.isArray(values)) {
return values.map((value) => String(value).trim()).filter(Boolean);
}
return String(values)
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function serializeFilterValue(values) {
return normalizeArrayValues(values).join(',');
}
function normalizeHoldType(value) {
const holdType = String(value || '').trim();
if (holdType === 'quality' || holdType === 'non-quality' || holdType === 'all') {
return holdType;
}
return 'quality';
return 'all';
}
function updateUrlState() {
const params = new URLSearchParams();
if (filterBar.holdType) {
params.set('hold_type', filterBar.holdType);
}
if (filterBar.reason) {
params.set('reason', filterBar.reason);
}
if (matrixFilter.value?.workcenter) {
params.set('workcenter', matrixFilter.value.workcenter);
}
if (matrixFilter.value?.package) {
params.set('package', matrixFilter.value.package);
}
if (page.value > 1) {
params.set('page', String(page.value));
}
const query = params.toString();
const nextUrl = query ? `/hold-overview?${query}` : '/hold-overview';
replaceRuntimeHistory(nextUrl);
function updateFilters(nextFilters) {
filters.workorder = normalizeArrayValues(nextFilters.workorder);
filters.lotid = normalizeArrayValues(nextFilters.lotid);
filters.package = normalizeArrayValues(nextFilters.package);
filters.type = normalizeArrayValues(nextFilters.type);
filters.firstname = normalizeArrayValues(nextFilters.firstname);
filters.waferdesc = normalizeArrayValues(nextFilters.waferdesc);
}
function buildFilterBarParams() {
const params = {
hold_type: filterBar.holdType || 'quality',
hold_type: filterBar.holdType || 'all',
};
if (filterBar.reason) {
params.reason = filterBar.reason;
@@ -177,18 +229,38 @@ function buildMatrixFilterParams() {
return params;
}
function buildLotsParams() {
function buildAllFilterParams() {
return {
...buildFilterBarParams(),
...buildWipOverviewQueryParams(filters),
};
}
function buildLotsParams() {
return {
...buildAllFilterParams(),
...buildMatrixFilterParams(),
page: page.value,
per_page: Number(pagination.value?.perPage || DEFAULT_PER_PAGE),
};
}
function buildFilterOptionsParams(sourceFilters = filters) {
const params = {
...buildWipOverviewQueryParams(sourceFilters),
status: 'HOLD',
};
if (filterBar.holdType && filterBar.holdType !== 'all') {
params.hold_type = filterBar.holdType;
}
return params;
}
async function fetchSummary(signal) {
const result = await apiGet('/api/hold-overview/summary', {
params: buildFilterBarParams(),
params: buildAllFilterParams(),
timeout: API_TIMEOUT,
signal,
});
@@ -197,13 +269,22 @@ async function fetchSummary(signal) {
async function fetchMatrix(signal) {
const result = await apiGet('/api/hold-overview/matrix', {
params: buildFilterBarParams(),
params: buildAllFilterParams(),
timeout: API_TIMEOUT,
signal,
});
return unwrapApiResult(result, 'Failed to fetch hold matrix');
}
async function fetchHold(signal) {
const result = await apiGet('/api/wip/overview/hold', {
params: buildAllFilterParams(),
timeout: API_TIMEOUT,
signal,
});
return unwrapApiResult(result, 'Failed to fetch hold data');
}
async function fetchLots(signal) {
const result = await apiGet('/api/hold-overview/lots', {
params: buildLotsParams(),
@@ -213,6 +294,50 @@ async function fetchLots(signal) {
return unwrapApiResult(result, 'Failed to fetch hold lots');
}
async function loadFilterOptions(sourceFilters = filters) {
const requestToken = ++filterOptionsRequestToken;
try {
const result = await apiGet('/api/wip/meta/filter-options', {
params: buildFilterOptionsParams(sourceFilters),
timeout: API_TIMEOUT,
silent: true,
});
const data = unwrapApiResult(result, '載入篩選選項失敗');
if (requestToken !== filterOptionsRequestToken) {
return;
}
filterOptions.value = {
workorders: Array.isArray(data?.workorders) ? data.workorders : [],
lotids: Array.isArray(data?.lotids) ? data.lotids : [],
packages: Array.isArray(data?.packages) ? data.packages : [],
types: Array.isArray(data?.types) ? data.types : [],
firstnames: Array.isArray(data?.firstnames) ? data.firstnames : [],
waferdescs: Array.isArray(data?.waferdescs) ? data.waferdescs : [],
};
} catch (error) {
if (error?.name !== 'AbortError') {
console.warn('載入 WIP 篩選選項失敗:', error);
}
}
}
function scheduleFilterOptionsReload(nextDraftFilters) {
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer);
}
filterOptionsDebounceTimer = setTimeout(() => {
void loadFilterOptions(nextDraftFilters);
}, FILTER_OPTION_DEBOUNCE_MS);
}
function onFilterDraftChange(nextDraftFilters) {
scheduleFilterOptionsReload(nextDraftFilters);
}
function updateLotsState(payload) {
lots.value = Array.isArray(payload?.lots) ? payload.lots : [];
pagination.value = {
@@ -231,6 +356,56 @@ function showRefreshSuccess() {
}, 1500);
}
function updateUrlState() {
const params = new URLSearchParams();
if (filterBar.holdType) {
params.set('hold_type', filterBar.holdType);
}
if (filterBar.reason) {
params.set('reason', filterBar.reason);
}
if (matrixFilter.value?.workcenter) {
params.set('workcenter', matrixFilter.value.workcenter);
}
if (matrixFilter.value?.package) {
params.set('matrix_package', matrixFilter.value.package);
}
const workorder = serializeFilterValue(filters.workorder);
const lotid = serializeFilterValue(filters.lotid);
const pkg = serializeFilterValue(filters.package);
const type = serializeFilterValue(filters.type);
const firstname = serializeFilterValue(filters.firstname);
const waferdesc = serializeFilterValue(filters.waferdesc);
if (workorder) {
params.set('workorder', workorder);
}
if (lotid) {
params.set('lotid', lotid);
}
if (pkg) {
params.set('package', pkg);
}
if (type) {
params.set('type', type);
}
if (firstname) {
params.set('firstname', firstname);
}
if (waferdesc) {
params.set('waferdesc', waferdesc);
}
if (page.value > 1) {
params.set('page', String(page.value));
}
const query = params.toString();
const nextUrl = query ? `/hold-overview?${query}` : '/hold-overview';
replaceRuntimeHistory(nextUrl);
}
const { createAbortSignal, clearAbortController, triggerRefresh } = useAutoRefresh({
onRefresh: () => loadAllData(false),
autoStart: true,
@@ -251,9 +426,10 @@ async function loadAllData(showOverlay = true) {
lotsError.value = '';
try {
const [summaryData, matrixData, lotsData] = await Promise.all([
const [summaryData, matrixData, holdData, lotsData] = await Promise.all([
fetchSummary(signal),
fetchMatrix(signal),
fetchHold(signal),
fetchLots(signal),
]);
if (isStaleRequest(requestId)) {
@@ -262,6 +438,7 @@ async function loadAllData(showOverlay = true) {
summary.value = summaryData;
matrix.value = matrixData;
hold.value = holdData;
updateLotsState(lotsData);
showRefreshSuccess();
} catch (error) {
@@ -317,9 +494,17 @@ async function loadLots() {
}
}
function navigateToHoldDetail(reason) {
if (!reason) {
return;
}
navigateToRuntimeRoute(`/hold-detail?reason=${encodeURIComponent(reason)}`);
}
function handleFilterChange(next) {
const nextHoldType = next?.holdType || 'quality';
const nextReason = next?.reason || '';
const nextHoldType = normalizeHoldType(next?.holdType || 'all');
const nextReason = String(next?.reason || '').trim();
if (filterBar.holdType === nextHoldType && filterBar.reason === nextReason) {
return;
}
@@ -329,6 +514,7 @@ function handleFilterChange(next) {
matrixFilter.value = null;
page.value = 1;
updateUrlState();
void loadFilterOptions(filters);
void loadAllData(false);
}
@@ -349,6 +535,31 @@ function clearMatrixFilter() {
void loadLots();
}
function applyFilters(nextFilters) {
updateFilters(nextFilters);
matrixFilter.value = null;
page.value = 1;
updateUrlState();
void loadFilterOptions(filters);
void loadAllData(false);
}
function clearAllFilters() {
updateFilters({
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
matrixFilter.value = null;
page.value = 1;
updateUrlState();
void loadFilterOptions(filters);
void loadAllData(false);
}
function prevPage() {
if (page.value <= 1) {
return;
@@ -371,23 +582,50 @@ async function manualRefresh() {
await triggerRefresh({ resetTimer: true, force: true });
}
onMounted(() => {
filterBar.holdType = normalizeHoldType(getUrlParam('hold_type'));
async function initializePage() {
filterBar.holdType = normalizeHoldType(getUrlParam('hold_type') || 'all');
filterBar.reason = getUrlParam('reason');
updateFilters({
workorder: parseCsvParam('workorder'),
lotid: parseCsvParam('lotid'),
package: parseCsvParam('package'),
type: parseCsvParam('type'),
firstname: parseCsvParam('firstname'),
waferdesc: parseCsvParam('waferdesc'),
});
const workcenter = getUrlParam('workcenter');
const pkg = getUrlParam('package');
if (workcenter || pkg) {
const matrixPkg = getUrlParam('matrix_package');
if (workcenter || matrixPkg) {
matrixFilter.value = {
workcenter: workcenter || null,
package: pkg || null,
package: matrixPkg || null,
};
}
const parsedPage = Number.parseInt(getUrlParam('page'), 10);
if (Number.isFinite(parsedPage) && parsedPage > 0) {
page.value = parsedPage;
}
updateUrlState();
void loadAllData(true);
await Promise.all([
loadFilterOptions(filters),
loadAllData(true),
]);
}
onMounted(() => {
void initializePage();
});
onBeforeUnmount(() => {
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer);
filterOptionsDebounceTimer = null;
}
});
</script>
@@ -395,7 +633,7 @@ onMounted(() => {
<div class="dashboard hold-overview-page">
<header class="header">
<div class="header-left">
<h1>Hold Lot Overview</h1>
<h1>Hold 即時概況</h1>
<span class="hold-type-badge">{{ holdTypeLabel }}</span>
</div>
<div class="header-right">
@@ -411,6 +649,15 @@ onMounted(() => {
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
<FilterPanel
:filters="filters"
:options="filterOptions"
:loading="refreshing"
@apply="applyFilters"
@clear="clearAllFilters"
@draft-change="onFilterDraftChange"
/>
<FilterBar
:hold-type="filterBar.holdType"
:reason="filterBar.reason"
@@ -421,33 +668,52 @@ onMounted(() => {
<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 class="content-grid">
<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>
<section class="pareto-grid">
<ParetoSection
v-if="showQualityPareto"
type="quality"
title="品質異常 Hold"
:items="splitHold.quality"
@drilldown="navigateToHoldDetail"
/>
<ParetoSection
v-if="showNonQualityPareto"
type="non-quality"
title="非品質異常 Hold"
:items="splitHold.nonQuality"
@drilldown="navigateToHoldDetail"
/>
</section>
<FilterIndicator
:matrix-filter="matrixFilter"
:show-clear-all="true"
@clear-matrix="clearMatrixFilter"
@clear-all="clearMatrixFilter"
/>
<HoldLotTable
:lots="lots"
:pagination="pagination"
:loading="lotsLoading"
:error-message="lotsError"
:has-active-filters="hasLotFilterText"
:filter-text="lotFilterText"
@clear-filters="clearMatrixFilter"
@prev-page="prevPage"
@next-page="nextPage"
/>
</section>
<FilterIndicator
:matrix-filter="matrixFilter"
:show-clear-all="true"
@clear-matrix="clearMatrixFilter"
@clear-all="clearMatrixFilter"
/>
<LotTable
:lots="lots"
:pagination="pagination"
:loading="lotsLoading"
:error-message="lotsError"
:has-active-filters="hasLotFilterText"
:filter-text="lotFilterText"
@clear-filters="clearMatrixFilter"
@prev-page="prevPage"
@next-page="nextPage"
/>
</div>
<div v-if="initialLoading" class="loading-overlay">

View File

@@ -4,7 +4,7 @@ import { computed } from 'vue';
const props = defineProps({
holdType: {
type: String,
default: 'quality',
default: 'all',
},
reason: {
type: String,
@@ -28,7 +28,7 @@ const HOLD_TYPE_OPTIONS = Object.freeze([
{ value: 'all', label: '全部' },
]);
const holdTypeModel = computed(() => props.holdType || 'quality');
const holdTypeModel = computed(() => props.holdType || 'all');
const reasonModel = computed({
get() {
@@ -36,7 +36,7 @@ const reasonModel = computed({
},
set(nextValue) {
emit('change', {
holdType: props.holdType || 'quality',
holdType: props.holdType || 'all',
reason: nextValue || '',
});
},
@@ -60,7 +60,7 @@ function selectHoldType(nextValue) {
if (props.disabled) {
return;
}
const normalized = nextValue || 'quality';
const normalized = nextValue || 'all';
if (normalized === holdTypeModel.value) {
return;
}

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue';
import App from './App.vue';
import '../resource-shared/styles.css';
import '../wip-shared/styles.css';
import './style.css';

View File

@@ -1,3 +1,5 @@
@import '../wip-shared/pareto-styles.css';
.hold-overview-page .header h1 {
font-size: 24px;
}
@@ -21,6 +23,44 @@
font-size: 13px;
}
.filters {
background: var(--card-bg);
padding: 16px 20px;
border-radius: 10px;
margin-bottom: 16px;
box-shadow: var(--shadow);
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 14px 16px;
align-items: start;
}
.filters .filter-group {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.filters .filter-group label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.filters-actions {
grid-column: 1 / -1;
display: inline-flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
}
.filters-actions .btn-primary,
.filters-actions .btn-secondary {
min-width: 108px;
}
.hold-summary-row {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
@@ -67,7 +107,7 @@
}
.hold-overview-hold-type-group {
flex: 1 1 520px;
flex: 0 0 auto;
}
.hold-overview-reason-group {
@@ -84,7 +124,7 @@
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
min-width: 420px;
min-width: 320px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 12px;
@@ -218,6 +258,12 @@
font-weight: 700;
}
.content-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.cascade-filter-indicator {
display: flex;
align-items: center;
@@ -356,9 +402,8 @@
}
.lot-table th:nth-child(3),
.lot-table th:nth-child(7),
.lot-table td:nth-child(3),
.lot-table td:nth-child(7) {
.lot-table td:nth-child(3) {
text-align: right;
}
@@ -377,6 +422,10 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filters {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.filter-bar {
flex-direction: column;
align-items: stretch;
@@ -398,6 +447,14 @@
padding: 14px;
}
.filters {
grid-template-columns: 1fr;
}
.filters-actions {
justify-content: flex-start;
}
.hold-summary-row {
grid-template-columns: 1fr;
}

View File

@@ -24,7 +24,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
),
'/hold-overview': createNativeLoader(
() => import('../hold-overview/App.vue'),
[() => import('../wip-shared/styles.css'), () => import('../hold-overview/style.css')],
[() => import('../resource-shared/styles.css'), () => import('../wip-shared/styles.css'), () => import('../hold-overview/style.css')],
),
'/hold-detail': createNativeLoader(
() => import('../hold-detail/App.vue'),
@@ -60,7 +60,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
),
'/query-tool': createNativeLoader(
() => import('../query-tool/App.vue'),
[() => import('../query-tool/style.css')],
[() => import('../wip-shared/styles.css'), () => import('../styles/tailwind.css'), () => import('../query-tool/style.css')],
),
'/tables': createNativeLoader(
() => import('../tables/App.vue'),

View File

@@ -3,15 +3,11 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js';
import { navigateToRuntimeRoute, replaceRuntimeHistory } from '../core/shell-navigation.js';
import {
buildWipOverviewQueryParams,
splitHoldByType,
} from '../core/wip-derive.js';
import { buildWipOverviewQueryParams } from '../core/wip-derive.js';
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
import FilterPanel from './components/FilterPanel.vue';
import MatrixTable from './components/MatrixTable.vue';
import ParetoSection from './components/ParetoSection.vue';
import StatusCards from './components/StatusCards.vue';
import SummaryCards from './components/SummaryCards.vue';
@@ -20,7 +16,6 @@ const FILTER_OPTION_DEBOUNCE_MS = 120;
const summary = ref(null);
const matrix = ref(null);
const hold = ref(null);
const filterOptions = ref({
workorders: [],
lotids: [],
@@ -115,15 +110,6 @@ async function fetchMatrix(signal) {
return unwrapApiResult(result, 'Failed to fetch matrix');
}
async function fetchHold(signal) {
const result = await apiGet('/api/wip/overview/hold', {
params: buildFilters(),
timeout: API_TIMEOUT,
signal,
});
return unwrapApiResult(result, 'Failed to fetch hold data');
}
async function loadFilterOptions(sourceFilters = filters) {
const requestToken = ++filterOptionsRequestToken;
@@ -188,8 +174,6 @@ const matrixTitle = computed(() => {
return `${base} - ${activeStatusFilter.value.toUpperCase()} Only`;
});
const splitHold = computed(() => splitHoldByType(hold.value));
const { createAbortSignal, triggerRefresh } = useAutoRefresh({
onRefresh: () => loadAllData(false),
autoStart: true,
@@ -214,15 +198,13 @@ async function loadAllData(showOverlay = true) {
errorMessage.value = '';
try {
const [summaryData, matrixData, holdData] = await Promise.all([
const [summaryData, matrixData] = await Promise.all([
fetchSummary(signal),
fetchMatrix(signal),
fetchHold(signal),
]);
summary.value = summaryData;
matrix.value = matrixData;
hold.value = holdData;
showRefreshSuccess();
} catch (error) {
if (error?.name === 'AbortError') {
@@ -256,6 +238,15 @@ async function loadMatrixOnly() {
}
function toggleStatusFilter(status) {
if (status === 'quality-hold') {
navigateToRuntimeRoute('/hold-overview?hold_type=quality');
return;
}
if (status === 'non-quality-hold') {
navigateToRuntimeRoute('/hold-overview?hold_type=non-quality');
return;
}
activeStatusFilter.value = activeStatusFilter.value === status ? null : status;
updateUrlState();
void loadMatrixOnly();
@@ -365,13 +356,6 @@ function navigateToDetail(workcenter) {
navigateToRuntimeRoute(`/wip-detail?${params.toString()}`);
}
function navigateToHoldDetail(reason) {
if (!reason) {
return;
}
navigateToRuntimeRoute(`/hold-detail?reason=${encodeURIComponent(reason)}`);
}
async function manualRefresh() {
await triggerRefresh({ resetTimer: true, force: true });
}
@@ -449,20 +433,6 @@ onBeforeUnmount(() => {
</div>
</section>
<section class="pareto-grid">
<ParetoSection
type="quality"
title="品質異常 Hold"
:items="splitHold.quality"
@drilldown="navigateToHoldDetail"
/>
<ParetoSection
type="non-quality"
title="非品質異常 Hold"
:items="splitHold.nonQuality"
@drilldown="navigateToHoldDetail"
/>
</section>
</section>
</div>

View File

@@ -1,5 +1,6 @@
@import '../wip-shared/styles.css';
@import '../resource-shared/styles.css';
@import '../wip-shared/pareto-styles.css';
.wip-overview-page .header h1 {
font-size: 24px;
@@ -274,126 +275,6 @@
text-decoration: underline;
}
.pareto-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.pareto-section {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
}
.pareto-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: #fafbfc;
}
.pareto-header.quality {
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
border-bottom-color: #fca5a5;
}
.pareto-header.non-quality {
background: linear-gradient(135deg, #ffedd5 0%, #fed7aa 100%);
border-bottom-color: #fdba74;
}
.pareto-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.pareto-title .badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.1);
}
.pareto-body {
padding: 12px;
}
.pareto-chart {
width: 100%;
height: 360px;
}
.pareto-no-data {
display: flex;
align-items: center;
justify-content: center;
height: 280px;
color: var(--muted);
font-size: 14px;
}
.pareto-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-top: 12px;
}
.pareto-table th,
.pareto-table td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.pareto-table th {
background: #f9fafb;
font-weight: 600;
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
}
.pareto-table td:nth-child(2),
.pareto-table td:nth-child(3),
.pareto-table td:nth-child(4),
.pareto-table th:nth-child(2),
.pareto-table th:nth-child(3),
.pareto-table th:nth-child(4) {
text-align: right;
}
.pareto-table tbody tr:hover {
background: #f3f4f6;
}
.pareto-table .reason-link {
color: var(--primary);
text-decoration: none;
cursor: pointer;
}
.pareto-table .reason-link:hover {
text-decoration: underline;
color: var(--primary-dark);
}
.pareto-table .cumulative {
color: var(--muted);
font-size: 11px;
}
@media (max-width: 1200px) {
.pareto-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1000px) {
.filters {
grid-template-columns: repeat(2, minmax(220px, 1fr));

View File

@@ -0,0 +1,142 @@
<script setup>
import { computed } from 'vue';
import Pagination from '../../shared-ui/components/PaginationControl.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: '',
},
title: {
type: String,
default: 'Hold Lot Details',
},
});
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">{{ title }}</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>Product</th>
<th>Package</th>
<th>Workcenter</th>
<th>Hold Reason</th>
<th>Spec</th>
<th>Age</th>
<th>Hold By</th>
<th>Dept</th>
<th>Hold Comment</th>
<th>Future Hold Comment</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="13" class="placeholder">Loading...</td>
</tr>
<tr v-else-if="errorMessage">
<td colspan="13" class="placeholder">{{ errorMessage }}</td>
</tr>
<tr v-else-if="lots.length === 0">
<td colspan="13" 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.product || '-' }}</td>
<td>{{ lot.package || '-' }}</td>
<td>{{ lot.workcenter || '-' }}</td>
<td>{{ lot.holdReason || '-' }}</td>
<td>{{ lot.spec || '-' }}</td>
<td>{{ formatAge(lot.age) }}</td>
<td>{{ lot.holdBy || '-' }}</td>
<td>{{ lot.dept || '-' }}</td>
<td>{{ lot.holdComment || '-' }}</td>
<td>{{ lot.futureHoldComment || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:visible="Number(pagination.totalPages || 1) > 1"
:page="Number(pagination.page || 1)"
:total-pages="Number(pagination.totalPages || 1)"
:info-text="pageInfo"
@prev="emit('prev-page')"
@next="emit('next-page')"
/>
</section>
</template>

View File

@@ -0,0 +1,119 @@
.pareto-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.pareto-section {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
}
.pareto-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: #fafbfc;
}
.pareto-header.quality {
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
border-bottom-color: #fca5a5;
}
.pareto-header.non-quality {
background: linear-gradient(135deg, #ffedd5 0%, #fed7aa 100%);
border-bottom-color: #fdba74;
}
.pareto-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.pareto-title .badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.1);
}
.pareto-body {
padding: 12px;
}
.pareto-chart {
width: 100%;
height: 360px;
}
.pareto-no-data {
display: flex;
align-items: center;
justify-content: center;
height: 280px;
color: var(--muted);
font-size: 14px;
}
.pareto-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-top: 12px;
}
.pareto-table th,
.pareto-table td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.pareto-table th {
background: #f9fafb;
font-weight: 600;
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
}
.pareto-table td:nth-child(2),
.pareto-table td:nth-child(3),
.pareto-table td:nth-child(4),
.pareto-table th:nth-child(2),
.pareto-table th:nth-child(3),
.pareto-table th:nth-child(4) {
text-align: right;
}
.pareto-table tbody tr:hover {
background: #f3f4f6;
}
.pareto-table .reason-link {
color: var(--primary);
text-decoration: none;
cursor: pointer;
}
.pareto-table .reason-link:hover {
text-decoration: underline;
color: var(--primary-dark);
}
.pareto-table .cumulative {
color: var(--muted);
font-size: 11px;
}
@media (max-width: 1200px) {
.pareto-grid {
grid-template-columns: 1fr;
}
}

View File

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

View File

@@ -0,0 +1,72 @@
## Context
WIP 即時概況和 Hold 即時概況共用部分 Hold 資料呈現邏輯但目前職責劃分不清Hold 柏拉圖放在 WIP 頁面、Hold 頁面篩選能力薄弱、Lot 明細欄位不一致。兩頁的後端 service 層(`wip_service.py`)已共用 `_select_with_snapshot_indexes()` 作為核心查詢引擎,支援 workorder/lotid/pj_type/firstname/waferdesc 篩選,但 Hold Overview 的 route 層和 service function 簽名尚未穿透這些參數。
**現有架構:**
- `ParetoSection.vue` 位於 `wip-overview/components/`,僅 WIP 使用
- Hold Overview 的 `FilterBar` 僅有 holdType + reason 兩個篩選欄位
- Hold Overview 和 Hold Detail 各有獨立的 `LotTable.vue`,欄位不同(前者有 Hold Reason 無 Spec後者相反
- Hold Detail 返回按鈕指向 WIP Overview
## Goals / Non-Goals
**Goals:**
- Hold 相關視覺化(柏拉圖)集中到 Hold 即時概況
- WIP 即時概況的 Hold 卡片改為跳轉入口(而非本地篩選)
- Hold 即時概況具備與 WIP 相同的 6 欄位篩選能力
- 統一 Lot 明細表格為 13 欄(含 Hold Reason + Spec
- 修正 Hold 即時概況版面窄的問題
- Hold Detail 導航回到 Hold Overview而非 WIP Overview
**Non-Goals:**
- 不變更 Hold 柏拉圖的資料來源 API繼續使用 `/api/wip/overview/hold`
- 不變更 Hold Detail 的內部功能AgeDistribution、DistributionTable 等)
- 不重構後端 `wip_service.py` 的核心查詢邏輯
- 不新增 API endpoint只擴充現有 endpoint 的參數)
## Decisions
### D1. 柏拉圖資料來源:沿用 `/api/wip/overview/hold`
Hold 即時概況的柏拉圖直接呼叫現有 `/api/wip/overview/hold` API而非新增 Hold Overview 專用 endpoint。
**理由:** 該 API 回傳 `{items: [{reason, lots, qty, holdType}]}`,正好是 `ParetoSection` + `splitHoldByType()` 所需格式。已有的 treemap API 回傳格式不同workcenter×reason 分組),不適合柏拉圖。
### D2. ParetoSection 搬遷至 `wip-shared/components/`
`ParetoSection.vue``wip-overview/components/` 移至 `wip-shared/components/`,柏拉圖 CSS 抽取為 `wip-shared/pareto-styles.css`
**理由:** `wip-shared/` 是既有的共用目錄(已有 `styles.css`),符合專案的組件共用慣例。
### D3. 統一 LotTable 為 `wip-shared/components/HoldLotTable.vue`
以 Hold Overview 的 `LotTable.vue` 為基礎,新增 Spec 欄位,形成 13 欄統一表格。兩頁均改用此共用組件。
**理由:** 後端 `get_hold_detail_lots` 已同時回傳 `holdReason``spec`wip_service.py:3079-3080純前端調整。共用組件避免欄位不同步。
### D4. FilterPanel 直接 import WIP Overview 的組件
Hold Overview 的 FilterPanel 直接 `import FilterPanel from '../wip-overview/components/FilterPanel.vue'`,不複製組件。
**理由:** FilterPanel 的 props interfacefilters/options/loading + apply/clear/draft-change events是穩定的且內部已正確處理 MultiSelect 相對路徑。避免組件重複。
### D5. 柏拉圖根據 holdType 條件顯示
- holdType = 'all'(預設)→ 顯示品質異常 + 非品質異常兩張柏拉圖
- holdType = 'quality' → 僅顯示品質異常柏拉圖
- holdType = 'non-quality' → 僅顯示非品質異常柏拉圖
**理由:** 使用者選擇特定 holdType 後,無關的柏拉圖顯示空資料會造成困惑。
### D6. 後端參數穿透策略
`get_hold_detail_summary``get_hold_detail_lots` 加入 5 個 Optional[str] 參數,傳遞至 `_select_with_snapshot_indexes()`cache path和 Oracle fallback。`get_wip_matrix` 已原生支援,只需在 route 層解析參數。
**理由:** 最小變更量,`_select_with_snapshot_indexes` 已支援全部篩選欄位。
## Risks / Trade-offs
- **[ParetoSection 搬遷路徑斷裂]** → 搬遷後需確認 WIP Overview 不再 import 舊路徑。透過 `npm run build` 驗證。
- **[FilterPanel 跨目錄 import]** → 若 FilterPanel 內部路徑變更會影響 Hold Overview。風險低該組件穩定。未來可考慮提升至 shared。
- **[Hold Overview API 回應時間增加]** → 新增 FilterPanel 觸發 filter-options API。此 API 已有 debounce120ms和 cache影響可忽略。
- **[WIP Hold 卡片不再 toggle matrix]** → 使用者行為改變。但 Hold 卡片的 toggle 功能使用率低跳轉到專頁更直觀。RUN/QUEUE 卡片保持原行為。

View File

@@ -0,0 +1,32 @@
## Why
Hold 即時概況版面單薄(僅 FilterBar + SummaryCards + Matrix + LotTable缺乏視覺密度且篩選能力有限只有 holdType + reason。而 WIP 即時概況承載了兩張 Hold 柏拉圖,但使用者分析 Hold 數據時需在兩頁間來回切換。此次重構將 Hold 相關視覺化集中到 Hold 即時概況,並統一篩選器與 Lot 明細欄位,讓兩頁各司其職。
## What Changes
- 將 WIP 即時概況的品質異常 / 非品質異常 Hold 柏拉圖移至 Hold 即時概況
- WIP 即時概況的「品質異常」「非品質異常」StatusCard 點擊由篩選 matrix 改為跳轉至 Hold 即時概況(帶 hold_type 參數)
- Hold 即時概況直接進入時 Hold Type 預設「全部」(原為「品質異常」)
- Hold 即時概況加入 WIP 即時概況的 6 欄位 FilterPanelworkorder/lotid/package/type/firstname/waferdesc
- Hold Detail 返回按鈕改指 Hold 即時概況(原指 WIP 即時概況)
- 統一 Hold 即時概況與 Hold Detail 的 Lot 明細欄位(合併 Hold Reason + Spec 為 13 欄)
- Hold 即時概況版面修正FilterBar 寬度過大、缺少 content-grid 包裹)
- 後端 Hold Overview API 穿透 WIP 篩選參數workorder/lotid/type/firstname/waferdesc
## Capabilities
### New Capabilities
_(無新增 capability)_
### Modified Capabilities
- `hold-overview-page`: Hold 即時概況加入柏拉圖、FilterPanel、預設 holdType 改為 all、版面修正、統一 LotTable
- `wip-overview-page`: 移除 Hold 柏拉圖、Hold 卡片改為跳轉導航
- `hold-detail-page`: 返回按鈕改指 Hold Overview、改用統一 LotTable
## Impact
- **前端**`wip-overview/App.vue`(移除柏拉圖、卡片跳轉)、`hold-overview/App.vue`(核心重構)、`hold-detail/App.vue`導航修正、共用組件新增ParetoSection 搬遷、HoldLotTable、CSS 重構
- **後端**`hold_overview_routes.py`3 API 加入篩選參數)、`wip_service.py`2 函式簽名擴充)、`hold_routes.py`redirect 改向)
- **API**`/api/hold-overview/summary``/api/hold-overview/matrix``/api/hold-overview/lots` 新增 workorder/lotid/type/firstname/waferdesc 可選參數(向後相容)

View File

@@ -0,0 +1,49 @@
## MODIFIED Requirements
### Requirement: Hold Detail page SHALL display hold reason analysis
The page SHALL show summary statistics for a specific hold reason.
#### Scenario: Summary cards rendering
- **WHEN** the page loads with `?reason={reason}` in the URL
- **THEN** the page SHALL call `GET /api/wip/hold-detail/summary` with the reason
- **THEN** five cards SHALL display: Total Lots, Total QTY, 平均當站滯留, 最久當站滯留, 影響站群
- **THEN** age values SHALL display with "天" suffix
#### Scenario: Hold type classification
- **WHEN** the page loads
- **THEN** the header gradient color SHALL be red (#ef4444) for quality holds or orange (#f97316) for non-quality holds
- **THEN** a badge SHALL display "品質異常" or "非品質異常" accordingly
- **THEN** classification SHALL use a frontend `NON_QUALITY_HOLD_REASONS` constant set (11 values matching backend `sql/filters.py`)
#### Scenario: Missing reason parameter
- **WHEN** the page loads without a `reason` URL parameter
- **THEN** the page SHALL redirect to `/hold-overview`
### Requirement: Hold Detail page SHALL display paginated lot details
The page SHALL display detailed lot information with server-side pagination.
#### Scenario: Lot table rendering
- **WHEN** lot data is loaded from `GET /api/wip/hold-detail/lots`
- **THEN** a table SHALL display with 13 columns: LOTID, WORKORDER, QTY, Product, Package, Workcenter, Hold Reason, Spec, Age, Hold By, Dept, Hold Comment, Future Hold Comment
- **THEN** age values SHALL display with "天" suffix
#### Scenario: Filter indicator
- **WHEN** any filter is active (workcenter, package, or age range)
- **THEN** a blue filter indicator bar SHALL display showing active filters (e.g., "篩選: Workcenter=WC-A, Age=3-7天")
- **THEN** clicking the "×" on the indicator SHALL clear all filters
#### Scenario: Pagination
- **WHEN** total pages exceeds 1
- **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 is toggled
- **THEN** pagination SHALL reset to page 1
### Requirement: Hold Detail page SHALL have back navigation to Hold Overview
The page SHALL provide a way to return to the Hold Overview page.
#### Scenario: Back button
- **WHEN** user clicks the "← Hold Overview" button in the header
- **THEN** the page SHALL navigate to `/hold-overview`

View File

@@ -0,0 +1,126 @@
## MODIFIED 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 without a `hold_type` URL parameter
- **THEN** the Hold Type filter SHALL default to "全部"
- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部
#### Scenario: Hold Type from URL parameter
- **WHEN** the page loads with `?hold_type=quality` or `?hold_type=non-quality`
- **THEN** the Hold Type filter SHALL be set to the specified value
#### Scenario: Hold Type change reloads all data
- **WHEN** user changes the Hold Type selection
- **THEN** all API calls (summary, matrix, hold pareto, lots) SHALL reload with the new hold_type parameter
- **THEN** any active matrix 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 data
- **THEN** selecting a specific reason SHALL reload all API calls filtered by that reason
- **THEN** any active matrix filters SHALL be cleared
### 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 13 columns: LOTID, WORKORDER, QTY, Product, Package, Workcenter, Hold Reason, Spec, Age, Hold By, Dept, Hold Comment, Future 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 }`
- **THEN** lots API SHALL be called with `workcenter=WC-A&package=PKG-1`
- **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 WIP filter apply)
- **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 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`
## ADDED Requirements
### Requirement: Hold Overview page SHALL display Hold Pareto analysis
The page SHALL display Pareto charts for quality and non-quality hold reasons, fetched from `/api/wip/overview/hold`.
#### Scenario: Pareto chart rendering
- **WHEN** hold data is loaded from `GET /api/wip/overview/hold`
- **THEN** hold items SHALL be split into quality and non-quality groups using `splitHoldByType()`
- **THEN** each group SHALL display an ECharts dual-axis Pareto chart (bar=QTY, line=cumulative %)
- **THEN** items SHALL be sorted by QTY descending
- **THEN** quality chart SHALL use red color (#ef4444), non-quality SHALL use orange (#f97316)
#### Scenario: Pareto visibility by holdType
- **WHEN** holdType is "all"
- **THEN** both quality and non-quality Pareto charts SHALL display
- **WHEN** holdType is "quality"
- **THEN** only the quality Pareto chart SHALL display
- **WHEN** holdType is "non-quality"
- **THEN** only the non-quality Pareto chart SHALL display
#### Scenario: Pareto chart drill-down
- **WHEN** user clicks a bar in the Pareto chart or a reason link in the table
- **THEN** the page SHALL navigate to `/hold-detail?reason={reason}`
#### Scenario: Empty hold data
- **WHEN** a hold type has no items
- **THEN** the chart area SHALL display a "目前無資料" message
### Requirement: Hold Overview page SHALL support WIP-style multi-field filtering
The page SHALL provide the same 6-field FilterPanel as WIP Overview (workorder, lotid, package, type, firstname, waferdesc).
#### Scenario: FilterPanel rendering
- **WHEN** the page loads
- **THEN** a FilterPanel SHALL display with 6 multi-select fields: WORKORDER, LOT ID, PACKAGE, TYPE, Wafer LOT, Wafer Type
- **THEN** filter options SHALL be loaded from `GET /api/wip/meta/filter-options` with `status=HOLD` and current holdType
#### Scenario: Apply filters
- **WHEN** user selects filter values and clicks "套用篩選"
- **THEN** all API calls (summary, matrix, hold pareto, lots) SHALL reload with the filter values
- **THEN** the URL SHALL be updated to include the filter values
#### Scenario: Clear filters
- **WHEN** user clicks "清除篩選"
- **THEN** all filter inputs SHALL be cleared and data SHALL reload without WIP filters
- **THEN** holdType and reason filters SHALL be preserved
#### Scenario: Filter options update
- **WHEN** filters are changed (draft mode)
- **THEN** filter options SHALL reload with debounce (120ms) reflecting cross-filter narrowing
### Requirement: Hold Overview API endpoints SHALL accept WIP filter parameters
The backend Hold Overview endpoints SHALL support optional workorder, lotid, type, firstname, waferdesc query parameters.
#### Scenario: Summary API with WIP filters
- **WHEN** `GET /api/hold-overview/summary?hold_type=quality&workorder=WO-001`
- **THEN** the summary SHALL only include lots matching workorder WO-001
#### Scenario: Matrix API with WIP filters
- **WHEN** `GET /api/hold-overview/matrix?hold_type=all&package=PKG-A`
- **THEN** the matrix SHALL only include lots matching package PKG-A
#### Scenario: Lots API with WIP filters
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&lotid=LOT-001`
- **THEN** the lot list SHALL only include lots matching LOT ID LOT-001
#### Scenario: Backward compatibility
- **WHEN** WIP filter parameters are omitted
- **THEN** the API SHALL behave identically to the current implementation (no filtering)

View File

@@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: Overview page SHALL display WIP status breakdown cards
The page SHALL display four clickable status cards (RUN, QUEUE, 品質異常, 非品質異常) with lot and quantity counts.
#### Scenario: Status cards rendering
- **WHEN** summary data is loaded
- **THEN** four status cards SHALL be displayed with color coding (green=RUN, yellow=QUEUE, red=品質異常, orange=非品質異常)
- **THEN** each card SHALL show lot count and quantity
#### Scenario: RUN/QUEUE card click filters matrix
- **WHEN** user clicks the RUN or QUEUE status card
- **THEN** the matrix table SHALL reload with the selected status filter
- **THEN** the clicked card SHALL show an active visual state
- **THEN** clicking the same card again SHALL deactivate the filter and restore all cards
- **THEN** the URL SHALL be updated to reflect the active status filter
#### Scenario: Hold card click navigates to Hold Overview
- **WHEN** user clicks the "品質異常" status card
- **THEN** the page SHALL navigate to `/hold-overview?hold_type=quality`
- **WHEN** user clicks the "非品質異常" status card
- **THEN** the page SHALL navigate to `/hold-overview?hold_type=non-quality`
## REMOVED Requirements
### Requirement: Overview page SHALL display Hold Pareto analysis
**Reason**: Hold Pareto charts are moved to the Hold Overview page where they are more relevant.
**Migration**: Users can access the same Pareto analysis on the Hold Overview page. Clicking the "品質異常" or "非品質異常" status cards navigates directly there.

View File

@@ -0,0 +1,54 @@
## 1. 後端Hold Overview API 支援 WIP 篩選參數
- [x] 1.1 `wip_service.py``get_hold_detail_summary` 加入 workorder/lotid/pj_type/firstname/waferdesc 5 個 Optional[str] 參數,傳入 `_select_with_snapshot_indexes()` 和 Oracle fallback
- [x] 1.2 `wip_service.py``get_hold_detail_lots` 加入同樣 5 個參數,傳入 `_select_with_snapshot_indexes()` 和 Oracle fallback
- [x] 1.3 `hold_overview_routes.py` — 3 個 API (summary/matrix/lots) 從 request.args 解析 workorder/lotid/type/firstname/waferdesc 並傳入對應 service function
## 2. 共用基礎設施ParetoSection 搬遷、CSS 抽取、HoldLotTable
- [x] 2.1 從 `wip-overview/style.css` 抽取 `.pareto-grid``.pareto-section``.pareto-header` 等柏拉圖相關 CSS 到新檔案 `wip-shared/pareto-styles.css`,原位改為 `@import`
- [x] 2.2 將 `wip-overview/components/ParetoSection.vue` 移至 `wip-shared/components/ParetoSection.vue`WIP Overview import 路徑更新
- [x] 2.3 新增 `wip-shared/components/HoldLotTable.vue`:以 hold-overview LotTable 為基礎,新增 Spec 欄位形成 13 欄統一表格LOTID, WORKORDER, QTY, Product, Package, Workcenter, Hold Reason, Spec, Age, Hold By, Dept, Hold Comment, Future Hold Comment
## 3. WIP 即時概況:移除柏拉圖 + Hold 卡片改跳轉
- [x] 3.1 `wip-overview/App.vue` — 移除 hold ref、fetchHold()、splitHold computed、navigateToHoldDetail()、template 的 `<section class="pareto-grid">` 區塊、對應 import (splitHoldByType, ParetoSection)
- [x] 3.2 `wip-overview/App.vue` — 修改 `toggleStatusFilter()`quality-hold → `navigateToRuntimeRoute('/hold-overview?hold_type=quality')`non-quality-hold → `navigateToRuntimeRoute('/hold-overview?hold_type=non-quality')`RUN/QUEUE 保持原行為
## 4. Hold 即時概況:加入柏拉圖
- [x] 4.1 `hold-overview/App.vue` — import ParetoSectionfrom wip-shared、splitHoldByTypefrom wip-derive、navigateToRuntimeRoute新增 hold ref、fetchHold()、splitHold computed、showQualityPareto/showNonQualityPareto computed、navigateToHoldDetail()
- [x] 4.2 `hold-overview/App.vue` — loadAllData 的 Promise.all 加入 fetchHold(signal),結果存入 hold.value
- [x] 4.3 `hold-overview/App.vue` — Template 在 Matrix 後加入 pareto-grid 區段,根據 holdType 條件顯示品質異常 / 非品質異常柏拉圖
- [x] 4.4 `hold-overview/style.css` — 頂部加入 `@import '../wip-shared/pareto-styles.css'`
## 5. Hold 即時概況:加入 WIP FilterPanel
- [x] 5.1 `hold-overview/App.vue` — import FilterPanelfrom wip-overview/components、buildWipOverviewQueryParamsfrom wip-derive新增 filters reactive state + filterOptions ref + debounce 機制
- [x] 5.2 `hold-overview/App.vue` — 新增 buildAllFilterParams() 合併 holdType/reason 與 WIP 6 欄位;更新 fetchSummary/fetchMatrix/fetchLots 改用 buildAllFilterParams()
- [x] 5.3 `hold-overview/App.vue` — 新增 loadFilterOptions() 呼叫 `/api/wip/meta/filter-options`(帶 status=HOLD + holdType、applyFilters()、clearAllFilters()、onFilterDraftChange()
- [x] 5.4 `hold-overview/App.vue` — Template 在 FilterBar 前加入 FilterPanel 組件
- [x] 5.5 `hold-overview/App.vue` — updateUrlState() 加入 6 欄位序列化onMounted 解析 URL 的 WIP 篩選參數
- [x] 5.6 `hold-overview/main.js` — 加入 `import '../resource-shared/styles.css'`MultiSelect 需要)
## 6. Hold 即時概況:預設 holdType 改為 'all'
- [x] 6.1 `hold-overview/App.vue` — 所有 holdType 預設值和 fallback 從 'quality' 改為 'all'filterBar initial、normalizeHoldType、buildFilterBarParams、handleFilterChange
- [x] 6.2 `hold-overview/components/FilterBar.vue` — 所有 holdType 預設值和 fallback 從 'quality' 改為 'all'
## 7. Hold Detail返回按鈕改指 Hold Overview + 共用 LotTable
- [x] 7.1 `hold-detail/App.vue` — 返回按鈕 navigateToRuntimeRoute 從 '/wip-overview' 改為 '/hold-overview',按鈕文字改為 '← Hold Overview'
- [x] 7.2 `hold-detail/App.vue` — no-reason redirect 從 '/wip-overview' 改為 '/hold-overview'
- [x] 7.3 `hold-detail/App.vue` — import 改用 `HoldLotTable` from `'../wip-shared/components/HoldLotTable.vue'`,取代 `./components/LotTable.vue`
- [x] 7.4 `hold_routes.py` — server-side redirect 從 '/wip-overview' 改為 '/hold-overview'
## 8. Hold 即時概況:版面修正 + 共用 LotTable
- [x] 8.1 `hold-overview/style.css``.hold-overview-hold-type-group` flex 改為 `0 0 auto``.hold-type-segment` min-width 改為 320px
- [x] 8.2 `hold-overview/App.vue` — Template 加入 `.content-grid` wrapper 包裹 Matrix + Pareto + FilterIndicator + LotTable
- [x] 8.3 `hold-overview/App.vue` — LotTable import 改用 `HoldLotTable` from `'../wip-shared/components/HoldLotTable.vue'`
## 9. 驗證
- [x] 9.1 前端 `npm run build` 通過(確認無 import 路徑斷裂)

View File

@@ -21,7 +21,7 @@ The page SHALL show summary statistics for a specific hold reason.
#### Scenario: Missing reason parameter
- **WHEN** the page loads without a `reason` URL parameter
- **THEN** the page SHALL redirect to `/wip-overview`
- **THEN** the page SHALL redirect to `/hold-overview`
### Requirement: Hold Detail page SHALL display age distribution
The page SHALL show the distribution of hold lots by age at current station.
@@ -57,7 +57,7 @@ The page SHALL display detailed lot information with server-side pagination.
#### Scenario: Lot table rendering
- **WHEN** lot data is loaded from `GET /api/wip/hold-detail/lots`
- **THEN** a table SHALL display with 10 columns: LOTID, WORKORDER, QTY, Package, Workcenter, Spec, Age, Hold By, Dept, Hold Comment
- **THEN** a table SHALL display with 13 columns: LOTID, WORKORDER, QTY, Product, Package, Workcenter, Hold Reason, Spec, Age, Hold By, Dept, Hold Comment, Future Hold Comment
- **THEN** age values SHALL display with "天" suffix
#### Scenario: Filter indicator
@@ -74,12 +74,12 @@ The page SHALL display detailed lot information with server-side pagination.
- **WHEN** any filter is toggled
- **THEN** pagination SHALL reset to page 1
### Requirement: Hold Detail page SHALL have back navigation to Overview
The page SHALL provide a way to return to the WIP Overview page.
### Requirement: Hold Detail page SHALL have back navigation to Hold Overview
The page SHALL provide a way to return to the Hold Overview page.
#### Scenario: Back button
- **WHEN** user clicks the "← WIP Overview" button in the header
- **THEN** the page SHALL navigate to `/wip-overview`
- **WHEN** user clicks the "← Hold Overview" button in the header
- **THEN** the page SHALL navigate to `/hold-overview`
### Requirement: Hold Detail page SHALL auto-refresh and handle request cancellation
The page SHALL automatically refresh data and prevent stale request pile-up.

View File

@@ -8,20 +8,24 @@ Define stable requirements for hold-overview-page.
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 "品質異常"
- **WHEN** the page loads without a `hold_type` URL parameter
- **THEN** the Hold Type filter SHALL default to "全部"
- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部
#### Scenario: Hold Type from URL parameter
- **WHEN** the page loads with `?hold_type=quality` or `?hold_type=non-quality`
- **THEN** the Hold Type filter SHALL be set to the specified value
#### 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
- **THEN** all API calls (summary, matrix, hold pareto, lots) SHALL reload with the new hold_type parameter
- **THEN** any active matrix 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
- **THEN** the Reason dropdown SHALL display "全部" plus all distinct hold reasons from the data
- **THEN** selecting a specific reason SHALL reload all API calls filtered by that reason
- **THEN** any active matrix 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.
@@ -132,12 +136,12 @@ 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** a table SHALL display with 13 columns: LOTID, WORKORDER, QTY, Product, Package, Workcenter, Hold Reason, Spec, Age, Hold By, Dept, Hold Comment, Future 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=品質確認`
- **WHEN** matrixFilter is `{ workcenter: WC-A, package: PKG-1 }`
- **THEN** lots API SHALL be called with `workcenter=WC-A&package=PKG-1`
- **THEN** only lots matching all active filters SHALL be displayed
#### Scenario: Pagination
@@ -146,7 +150,7 @@ The page SHALL display a detailed lot table with server-side pagination.
- **THEN** page info SHALL show "顯示 {start} - {end} / {total}"
#### Scenario: Filter changes reset pagination
- **WHEN** any filter changes (filter bar, matrix click, or treemap click)
- **WHEN** any filter changes (filter bar, matrix click, or WIP filter apply)
- **THEN** pagination SHALL reset to page 1
#### Scenario: Empty lot result
@@ -198,3 +202,70 @@ 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`
### Requirement: Hold Overview page SHALL display Hold Pareto analysis
The page SHALL display Pareto charts for quality and non-quality hold reasons, fetched from `/api/wip/overview/hold`.
#### Scenario: Pareto chart rendering
- **WHEN** hold data is loaded from `GET /api/wip/overview/hold`
- **THEN** hold items SHALL be split into quality and non-quality groups using `splitHoldByType()`
- **THEN** each group SHALL display an ECharts dual-axis Pareto chart (bar=QTY, line=cumulative %)
- **THEN** items SHALL be sorted by QTY descending
- **THEN** quality chart SHALL use red color (#ef4444), non-quality SHALL use orange (#f97316)
#### Scenario: Pareto visibility by holdType
- **WHEN** holdType is "all"
- **THEN** both quality and non-quality Pareto charts SHALL display
- **WHEN** holdType is "quality"
- **THEN** only the quality Pareto chart SHALL display
- **WHEN** holdType is "non-quality"
- **THEN** only the non-quality Pareto chart SHALL display
#### Scenario: Pareto chart drill-down
- **WHEN** user clicks a bar in the Pareto chart or a reason link in the table
- **THEN** the page SHALL navigate to `/hold-detail?reason={reason}`
#### Scenario: Empty hold data
- **WHEN** a hold type has no items
- **THEN** the chart area SHALL display a "目前無資料" message
### Requirement: Hold Overview page SHALL support WIP-style multi-field filtering
The page SHALL provide the same 6-field FilterPanel as WIP Overview (workorder, lotid, package, type, firstname, waferdesc).
#### Scenario: FilterPanel rendering
- **WHEN** the page loads
- **THEN** a FilterPanel SHALL display with 6 multi-select fields: WORKORDER, LOT ID, PACKAGE, TYPE, Wafer LOT, Wafer Type
- **THEN** filter options SHALL be loaded from `GET /api/wip/meta/filter-options` with `status=HOLD` and current holdType
#### Scenario: Apply filters
- **WHEN** user selects filter values and clicks "套用篩選"
- **THEN** all API calls (summary, matrix, hold pareto, lots) SHALL reload with the filter values
- **THEN** the URL SHALL be updated to include the filter values
#### Scenario: Clear filters
- **WHEN** user clicks "清除篩選"
- **THEN** all filter inputs SHALL be cleared and data SHALL reload without WIP filters
- **THEN** holdType and reason filters SHALL be preserved
#### Scenario: Filter options update
- **WHEN** filters are changed (draft mode)
- **THEN** filter options SHALL reload with debounce (120ms) reflecting cross-filter narrowing
### Requirement: Hold Overview API endpoints SHALL accept WIP filter parameters
The backend Hold Overview endpoints SHALL support optional workorder, lotid, type, firstname, waferdesc query parameters.
#### Scenario: Summary API with WIP filters
- **WHEN** `GET /api/hold-overview/summary?hold_type=quality&workorder=WO-001`
- **THEN** the summary SHALL only include lots matching workorder WO-001
#### Scenario: Matrix API with WIP filters
- **WHEN** `GET /api/hold-overview/matrix?hold_type=all&package=PKG-A`
- **THEN** the matrix SHALL only include lots matching package PKG-A
#### Scenario: Lots API with WIP filters
- **WHEN** `GET /api/hold-overview/lots?hold_type=quality&lotid=LOT-001`
- **THEN** the lot list SHALL only include lots matching LOT ID LOT-001
#### Scenario: Backward compatibility
- **WHEN** WIP filter parameters are omitted
- **THEN** the API SHALL behave identically to the current implementation (no filtering)

View File

@@ -25,14 +25,19 @@ The page SHALL display four clickable status cards (RUN, QUEUE, 品質異常,
- **THEN** four status cards SHALL be displayed with color coding (green=RUN, yellow=QUEUE, red=品質異常, orange=非品質異常)
- **THEN** each card SHALL show lot count and quantity
#### Scenario: Status card click filters matrix
- **WHEN** user clicks a status card
#### Scenario: RUN/QUEUE card click filters matrix
- **WHEN** user clicks the RUN or QUEUE status card
- **THEN** the matrix table SHALL reload with the selected status filter
- **THEN** the clicked card SHALL show an active visual state
- **THEN** non-active cards SHALL dim to 50% opacity
- **THEN** clicking the same card again SHALL deactivate the filter and restore all cards
- **THEN** the URL SHALL be updated to reflect the active status filter
#### Scenario: Hold card click navigates to Hold Overview
- **WHEN** user clicks the "品質異常" status card
- **THEN** the page SHALL navigate to `/hold-overview?hold_type=quality`
- **WHEN** user clicks the "非品質異常" status card
- **THEN** the page SHALL navigate to `/hold-overview?hold_type=non-quality`
### Requirement: Overview page SHALL display Workcenter × Package matrix
The page SHALL display a cross-tabulation table of workcenters vs packages.
@@ -48,29 +53,6 @@ The page SHALL display a cross-tabulation table of workcenters vs packages.
- **THEN** active filter values (workorder, lotid, package, type) SHALL be passed as URL parameters
- **THEN** the active status filter SHALL be passed as the `status` URL parameter if set
### Requirement: Overview page SHALL display Hold Pareto analysis
The page SHALL display Pareto charts and tables for quality and non-quality hold reasons.
#### Scenario: Pareto chart rendering
- **WHEN** hold data is loaded from `GET /api/wip/overview/hold`
- **THEN** hold items SHALL be split into quality and non-quality groups
- **THEN** each group SHALL display an ECharts dual-axis Pareto chart (bar=QTY, line=cumulative %)
- **THEN** items SHALL be sorted by QTY descending
#### Scenario: Pareto chart drill-down
- **WHEN** user clicks a bar in the Pareto chart
- **THEN** the page SHALL navigate to `/hold-detail?reason={reason}`
#### Scenario: Pareto table with drill-down links
- **WHEN** Pareto data is rendered
- **THEN** a table SHALL display below each chart with Hold Reason, Lots, QTY, and cumulative %
- **THEN** reason names SHALL be clickable links to `/hold-detail?reason={reason}`
#### Scenario: Empty hold data
- **WHEN** a hold type has no items
- **THEN** the chart area SHALL display a "目前無資料" message
- **THEN** the chart SHALL be cleared
### Requirement: Overview page SHALL support autocomplete filtering
The page SHALL provide autocomplete-enabled filter inputs for WORKORDER, LOT ID, PACKAGE, and TYPE.

View File

@@ -41,7 +41,7 @@ _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]]]:
def _parse_hold_type(default: str = 'all') -> 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:
@@ -79,16 +79,26 @@ def hold_overview_page():
@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')
hold_type, error = _parse_hold_type(default='all')
if error:
return jsonify(error[0]), error[1]
reason = request.args.get('reason', '').strip() or None
workorder = request.args.get('workorder', '').strip() or None
lotid = request.args.get('lotid', '').strip() or None
pj_type = request.args.get('type', '').strip() or None
firstname = request.args.get('firstname', '').strip() or None
waferdesc = request.args.get('waferdesc', '').strip() or None
include_dummy = parse_bool_query(request.args.get('include_dummy'))
result = get_hold_detail_summary(
reason=reason,
hold_type=hold_type,
workorder=workorder,
lotid=lotid,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
include_dummy=include_dummy,
)
if result is not None:
@@ -100,11 +110,16 @@ def api_hold_overview_summary():
@_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')
hold_type, error = _parse_hold_type(default='all')
if error:
return jsonify(error[0]), error[1]
reason = request.args.get('reason', '').strip() or None
workorder = request.args.get('workorder', '').strip() or None
lotid = request.args.get('lotid', '').strip() or None
pj_type = request.args.get('type', '').strip() or None
firstname = request.args.get('firstname', '').strip() or None
waferdesc = request.args.get('waferdesc', '').strip() or None
include_dummy = parse_bool_query(request.args.get('include_dummy'))
result = get_wip_matrix(
@@ -112,6 +127,11 @@ def api_hold_overview_matrix():
status='HOLD',
hold_type=hold_type,
reason=reason,
workorder=workorder,
lotid=lotid,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
)
if result is not None:
return jsonify({'success': True, 'data': result})
@@ -121,7 +141,7 @@ def api_hold_overview_matrix():
@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')
hold_type, error = _parse_hold_type(default='all')
if error:
return jsonify(error[0]), error[1]
@@ -146,7 +166,7 @@ def api_hold_overview_treemap():
@_HOLD_OVERVIEW_LOTS_RATE_LIMIT
def api_hold_overview_lots():
"""Return paginated hold lot details."""
hold_type, error = _parse_hold_type(default='quality')
hold_type, error = _parse_hold_type(default='all')
if error:
return jsonify(error[0]), error[1]
@@ -154,6 +174,11 @@ def api_hold_overview_lots():
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
workorder = request.args.get('workorder', '').strip() or None
lotid = request.args.get('lotid', '').strip() or None
pj_type = request.args.get('type', '').strip() or None
firstname = request.args.get('firstname', '').strip() or None
waferdesc = request.args.get('waferdesc', '').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)
@@ -179,6 +204,11 @@ def api_hold_overview_lots():
treemap_reason=treemap_reason,
workcenter=workcenter,
package=package,
workorder=workorder,
lotid=lotid,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
age_range=age_range,
include_dummy=include_dummy,
page=page,

View File

@@ -50,10 +50,10 @@ def hold_detail_page():
reason = request.args.get('reason', '').strip()
if not reason:
# Redirect to overview route; in SPA mode this becomes canonical shell URL.
overview_redirect = maybe_redirect_to_canonical_shell('/wip-overview')
overview_redirect = maybe_redirect_to_canonical_shell('/hold-overview')
if overview_redirect is not None:
return overview_redirect
return redirect('/wip-overview')
return redirect('/hold-overview')
canonical_redirect = maybe_redirect_to_canonical_shell('/hold-detail')
if canonical_redirect is not None:

View File

@@ -2581,6 +2581,11 @@ def _search_types_from_oracle(
def get_hold_detail_summary(
reason: Optional[str] = None,
hold_type: Optional[str] = None,
workorder: Optional[str] = None,
lotid: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
include_dummy: bool = False,
) -> Optional[Dict[str, Any]]:
"""Get summary statistics for hold lots.
@@ -2590,6 +2595,11 @@ def get_hold_detail_summary(
Args:
reason: Optional HOLDREASONNAME filter
hold_type: Optional hold type filter ('quality', 'non-quality')
workorder: Optional WORKORDER filter
lotid: Optional LOTID filter
pj_type: Optional PJ_TYPE filter
firstname: Optional FIRSTNAME filter
waferdesc: Optional WAFERDESC filter
include_dummy: If True, include DUMMY lots (default: False)
Returns:
@@ -2601,6 +2611,11 @@ def get_hold_detail_summary(
try:
df = _select_with_snapshot_indexes(
include_dummy=include_dummy,
workorder=workorder,
lotid=lotid,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
status='HOLD',
hold_type=hold_type,
)
@@ -2608,6 +2623,11 @@ def get_hold_detail_summary(
return _get_hold_detail_summary_from_oracle(
reason=reason,
hold_type=hold_type,
workorder=workorder,
lotid=lotid,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
include_dummy=include_dummy,
)
@@ -2645,6 +2665,11 @@ def get_hold_detail_summary(
return _get_hold_detail_summary_from_oracle(
reason=reason,
hold_type=hold_type,
workorder=workorder,
lotid=lotid,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
include_dummy=include_dummy,
)
@@ -2652,6 +2677,11 @@ def get_hold_detail_summary(
def _get_hold_detail_summary_from_oracle(
reason: Optional[str] = None,
hold_type: Optional[str] = None,
workorder: Optional[str] = None,
lotid: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
include_dummy: bool = False,
) -> Optional[Dict[str, Any]]:
"""Get hold detail summary directly from Oracle (fallback)."""
@@ -2663,6 +2693,16 @@ def _get_hold_detail_summary_from_oracle(
_add_hold_type_conditions(builder, hold_type)
if reason:
builder.add_param_condition("HOLDREASONNAME", reason)
if workorder:
builder.add_like_condition("WORKORDER", workorder)
if lotid:
builder.add_like_condition("LOTID", lotid)
if pj_type:
builder.add_param_condition("PJ_TYPE", pj_type)
if firstname:
builder.add_param_condition("FIRSTNAME", firstname)
if waferdesc:
builder.add_param_condition("WAFERDESC", waferdesc)
where_clause, params = builder.build_where_only()
sql = f"""
@@ -2988,6 +3028,11 @@ def get_hold_detail_lots(
treemap_reason: Optional[str] = None,
workcenter: Optional[str] = None,
package: Optional[str] = None,
workorder: Optional[str] = None,
lotid: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
age_range: Optional[str] = None,
include_dummy: bool = False,
page: int = 1,
@@ -3003,6 +3048,11 @@ def get_hold_detail_lots(
treemap_reason: Optional HOLDREASONNAME filter from treemap selection
workcenter: Optional WORKCENTER_GROUP filter
package: Optional PACKAGE_LEF filter
workorder: Optional WORKORDER filter
lotid: Optional LOTID filter
pj_type: Optional PJ_TYPE filter
firstname: Optional FIRSTNAME filter
waferdesc: Optional WAFERDESC filter
age_range: Optional age range filter ('0-1', '1-3', '3-7', '7+')
include_dummy: If True, include DUMMY lots (default: False)
page: Page number (1-based)
@@ -3022,6 +3072,11 @@ def get_hold_detail_lots(
include_dummy=include_dummy,
workcenter=workcenter,
package=package,
workorder=workorder,
lotid=lotid,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
status='HOLD',
hold_type=hold_type,
)
@@ -3032,6 +3087,11 @@ def get_hold_detail_lots(
treemap_reason=treemap_reason,
workcenter=workcenter,
package=package,
workorder=workorder,
lotid=lotid,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
age_range=age_range,
include_dummy=include_dummy,
page=page,
@@ -3116,6 +3176,11 @@ def get_hold_detail_lots(
treemap_reason=treemap_reason,
workcenter=workcenter,
package=package,
workorder=workorder,
lotid=lotid,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
age_range=age_range,
include_dummy=include_dummy,
page=page,
@@ -3129,6 +3194,11 @@ def _get_hold_detail_lots_from_oracle(
treemap_reason: Optional[str] = None,
workcenter: Optional[str] = None,
package: Optional[str] = None,
workorder: Optional[str] = None,
lotid: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
age_range: Optional[str] = None,
include_dummy: bool = False,
page: int = 1,
@@ -3151,6 +3221,16 @@ def _get_hold_detail_lots_from_oracle(
builder.add_param_condition("WORKCENTER_GROUP", workcenter)
if package:
builder.add_param_condition("PACKAGE_LEF", package)
if workorder:
builder.add_like_condition("WORKORDER", workorder)
if lotid:
builder.add_like_condition("LOTID", lotid)
if pj_type:
builder.add_param_condition("PJ_TYPE", pj_type)
if firstname:
builder.add_param_condition("FIRSTNAME", firstname)
if waferdesc:
builder.add_param_condition("WAFERDESC", waferdesc)
if age_range:
if age_range == '0-1':
builder.add_condition("AGEBYDAYS >= 0 AND AGEBYDAYS < 1")