feat(hold-history): add Hold 歷史績效 Dashboard with trend, pareto, duration, and detail views

New independent report page based on DWH.DW_MES_HOLDRELEASEHISTORY providing
historical hold/release performance analysis. Includes daily trend with Redis
caching, reason Pareto with click-to-filter, duration distribution with
click-to-filter, multi-select record type filter (new/on_hold/released),
workcenter-group mapping via memory cache, and server-side paginated detail
table. All 32 backend tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-10 18:03:08 +08:00
parent 8225863a85
commit 9a4e08810b
39 changed files with 4566 additions and 208 deletions

View File

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

67
docs/hold_history.md Normal file
View File

@@ -0,0 +1,67 @@
/*PJMES043-Hold歷史紀錄
20240716 Peeler 新增匯總紀錄_PJM022024000878
20250520 Peeler 加總判斷Future Hold不同站別相同原因只計算第一次Hold_PJM022025000733
*/
SELECT TO_NUMBER(BS.TXNDAY) AS TXNDAY1,BS.TXNDAY
,CASE WHEN :P_QCHOLDPARA = 2
THEN SUM(CASE WHEN HD.HOLDTXNDAY <= BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS < HD.RELEASETXNDAY) AND BS.TRANSACTION_DAYS <= TO_CHAR(SYSDATE , 'YYYY/MM/DD') AND HD.RN_HOLD = 1 THEN QTY
ELSE 0 END)
ELSE SUM(CASE WHEN HD.HOLDTXNDAY <= BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS < HD.RELEASETXNDAY) AND BS.TRANSACTION_DAYS <= TO_CHAR(SYSDATE , 'YYYY/MM/DD') AND QCHOLDFLAG = :P_QCHOLDPARA AND HD.RN_HOLD = 1 THEN QTY
ELSE 0 END )
END AS HOLDQTY
,CASE WHEN :P_QCHOLDPARA = 2
THEN SUM(CASE WHEN HD.HOLDTXNDAY = BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS <= HD.RELEASETXNDAY) AND HD.FUTUREHOLD_FLAG = 1 THEN QTY
ELSE 0 END )
ELSE SUM(CASE WHEN HD.HOLDTXNDAY = BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS <= HD.RELEASETXNDAY) AND QCHOLDFLAG = :P_QCHOLDPARA AND HD.FUTUREHOLD_FLAG = 1 THEN QTY
ELSE 0 END )
END AS NEW_HOLDQTY
,CASE WHEN :P_QCHOLDPARA = 2
THEN SUM(CASE WHEN HD.RELEASETXNDAY = BS.TRANSACTION_DAYS AND HD.RELEASETXNDAY >= HD.HOLDTXNDAY THEN QTY
ELSE 0 END )
ELSE SUM(CASE WHEN HD.RELEASETXNDAY = BS.TRANSACTION_DAYS AND HD.RELEASETXNDAY >= HD.HOLDTXNDAY AND QCHOLDFLAG = :P_QCHOLDPARA THEN QTY
ELSE 0 END )
END AS RELEASEQTY
,CASE WHEN :P_QCHOLDPARA = 2
THEN SUM(CASE WHEN HD.HOLDTXNDAY = BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS <= HD.RELEASETXNDAY) AND HD.RN_HOLD = 1 AND HD.FUTUREHOLD_FLAG = 0 THEN QTY
ELSE 0 END )
ELSE SUM(CASE WHEN HD.HOLDTXNDAY = BS.TRANSACTION_DAYS AND (HD.RELEASETXNDAY IS NULL OR BS.TRANSACTION_DAYS <= HD.RELEASETXNDAY) AND QCHOLDFLAG = :P_QCHOLDPARA AND HD.RN_HOLD = 1 AND HD.FUTUREHOLD_FLAG = 0 THEN QTY
ELSE 0 END )
END AS FUTURE_HOLDQTY
FROM(select TO_CHAR(to_date(:P_TxnDate_S , 'YYYY/MM/DD') + rownum -1,'YYYY') AS TXNYEAR
, TO_CHAR(to_date(:P_TxnDate_S , 'YYYY/MM/DD') + rownum -1,'MM') AS TXNMONTH
, TO_CHAR(to_date(:P_TxnDate_S , 'YYYY/MM/DD') + rownum -1,'DD') AS TXNDAY
, TO_CHAR(to_date(:P_TxnDate_S , 'YYYY/MM/DD') + rownum -1,'YYYY/MM/DD') AS TRANSACTION_DAYS
From dual
CONNECT BY LEVEL <= TO_CHAR(LAST_DAY(to_date(:P_TxnDate_S,'YYYY/MM/DD')),'DD')
)BS,
(SELECT HOLDTXNDAY,RELEASETXNDAY,HOLDTXNDATE,RELEASETXNDATE,CONTAINERID,QTY, QCHOLDFLAG
,ROW_NUMBER() OVER (PARTITION BY CONTAINERID,HOLDTXNDAY ORDER BY HOLDTXNDATE DESC) AS RN_HOLD--同一張工單當天重複Hold
,ROW_NUMBER() OVER (PARTITION BY CONTAINERID,RELEASETXNDAY ORDER BY RELEASETXNDATE DESC) AS RN_RELEASE--同一張工單當天重複Release
,CASE WHEN FUTUREHOLD = 1 AND RN_CONHOLD <> 1 THEN 0
ELSE 1 END AS FUTUREHOLD_FLAG --FutureHold相同原因第一筆計算1其餘給0
FROM(SELECT CASE WHEN TO_CHAR(HD.HOLDTXNDATE,'HH24MI')>=0730
THEN TO_CHAR(HD.HOLDTXNDATE +1 ,'YYYY/MM/DD')
ELSE TO_CHAR(HD.HOLDTXNDATE ,'YYYY/MM/DD') END AS HOLDTXNDAY
,CASE WHEN TO_CHAR(HD.RELEASETXNDATE,'HH24MI')>=0730
THEN TO_CHAR(HD.RELEASETXNDATE +1 ,'YYYY/MM/DD')
ELSE TO_CHAR(HD.RELEASETXNDATE ,'YYYY/MM/DD') END AS RELEASETXNDAY
,HD.HOLDTXNDATE
,HD.RELEASETXNDATE
,HD.CONTAINERID
,HD.QTY
,CASE WHEN HD.HOLDREASONNAME IN(:P_QCHOLDREASON) THEN 1
ELSE 0 END AS QCHOLDFLAG
,CASE WHEN HD.FUTUREHOLDCOMMENTS IS NOT NULL THEN 1
ELSE 0 END AS FUTUREHOLD
,ROW_NUMBER() OVER (PARTITION BY HD.CONTAINERID,HD.HOLDREASONID ORDER BY HD.HOLDTXNDATE) AS RN_CONHOLD--同一張工單重複Hold
FROM DW_MES_HOLDRELEASEHISTORY HD
WHERE 1=1
AND ((HD.HOLDTXNDATE >= TO_DATE(:P_TxnDate_S||' 073000', 'YYYYMMDD HH24MISS')-1) Or :P_TxnDate_S is null OR (HD.RELEASETXNDATE >= TO_DATE(:P_TxnDate_S||' 073000', 'YYYYMMDD HH24MISS')-1) OR (HD.RELEASETXNDATE IS NULL))
AND ((HD.HOLDTXNDATE <= TO_DATE(:P_TxnDate_E||' 073000', 'YYYYMMDD HH24MISS')) Or :P_TxnDate_E is null OR (HD.RELEASETXNDATE <= TO_DATE(:P_TxnDate_E||' 073000', 'YYYYMMDD HH24MISS')) OR (HD.RELEASETXNDATE IS NULL))
)
)HD
WHERE 1=1
GROUP BY BS.TXNDAY

View File

@@ -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/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",
"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/hold-history/index.html ../src/mes_dashboard/static/dist/hold-history.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": {

View File

@@ -85,6 +85,7 @@ const pageInfo = computed(() => {
<th>LOTID</th>
<th>WORKORDER</th>
<th>QTY</th>
<th>Product</th>
<th>Package</th>
<th>Workcenter</th>
<th>Spec</th>
@@ -92,22 +93,24 @@ const pageInfo = computed(() => {
<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="10" class="placeholder">Loading...</td>
<td colspan="12" class="placeholder">Loading...</td>
</tr>
<tr v-else-if="errorMessage">
<td colspan="10" class="placeholder">{{ errorMessage }}</td>
<td colspan="12" class="placeholder">{{ errorMessage }}</td>
</tr>
<tr v-else-if="lots.length === 0">
<td colspan="10" class="placeholder">No data</td>
<td colspan="12" 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.spec || '-' }}</td>
@@ -115,6 +118,7 @@ const pageInfo = computed(() => {
<td>{{ lot.holdBy || '-' }}</td>
<td>{{ lot.dept || '-' }}</td>
<td>{{ lot.holdComment || '-' }}</td>
<td>{{ lot.futureHoldComment || '-' }}</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,500 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js';
import DailyTrend from './components/DailyTrend.vue';
import DetailTable from './components/DetailTable.vue';
import DurationChart from './components/DurationChart.vue';
import FilterBar from './components/FilterBar.vue';
import FilterIndicator from './components/FilterIndicator.vue';
import RecordTypeFilter from './components/RecordTypeFilter.vue';
import ReasonPareto from './components/ReasonPareto.vue';
import SummaryCards from './components/SummaryCards.vue';
const API_TIMEOUT = 60000;
const DEFAULT_PER_PAGE = 50;
const filterBar = reactive({
startDate: '',
endDate: '',
holdType: 'quality',
});
const reasonFilter = ref('');
const durationFilter = ref('');
const recordType = ref(['new']);
const trendData = ref({ days: [] });
const reasonParetoData = ref({ items: [] });
const durationData = ref({ items: [] });
const detailData = ref({
items: [],
pagination: {
page: 1,
perPage: DEFAULT_PER_PAGE,
total: 0,
totalPages: 1,
},
});
const page = ref(1);
const initialLoading = ref(true);
const loading = reactive({
global: false,
list: false,
});
const errorMessage = ref('');
let activeRequestId = 0;
function toDateString(value) {
return value.toISOString().slice(0, 10);
}
function setDefaultDateRange() {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
filterBar.startDate = toDateString(start);
filterBar.endDate = toDateString(end);
}
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 normalizeListPayload(payload) {
const pagination = payload?.pagination || {};
return {
items: Array.isArray(payload?.items) ? payload.items : [],
pagination: {
page: Number(pagination.page || page.value || 1),
perPage: Number(pagination.perPage || DEFAULT_PER_PAGE),
total: Number(pagination.total || 0),
totalPages: Number(pagination.totalPages || 1),
},
};
}
function commonParams({
includeHoldType = true,
includeReason = false,
includeRecordType = false,
includeDuration = false,
} = {}) {
const params = {
start_date: filterBar.startDate,
end_date: filterBar.endDate,
};
if (includeHoldType) {
params.hold_type = filterBar.holdType;
}
if (includeRecordType) {
const rt = Array.isArray(recordType.value) ? recordType.value : [recordType.value];
params.record_type = rt.join(',');
}
if (includeReason && reasonFilter.value) {
params.reason = reasonFilter.value;
}
if (includeDuration && durationFilter.value) {
params.duration_range = durationFilter.value;
}
return params;
}
async function fetchTrend() {
const response = await apiGet('/api/hold-history/trend', {
params: commonParams({ includeHoldType: false }),
timeout: API_TIMEOUT,
});
return unwrapApiResult(response, '載入 trend 資料失敗');
}
async function fetchReasonPareto() {
const response = await apiGet('/api/hold-history/reason-pareto', {
params: commonParams({ includeHoldType: true, includeRecordType: true }),
timeout: API_TIMEOUT,
});
return unwrapApiResult(response, '載入 pareto 資料失敗');
}
async function fetchDuration() {
const response = await apiGet('/api/hold-history/duration', {
params: commonParams({ includeHoldType: true, includeRecordType: true }),
timeout: API_TIMEOUT,
});
return unwrapApiResult(response, '載入 duration 資料失敗');
}
async function fetchList() {
const response = await apiGet('/api/hold-history/list', {
params: {
...commonParams({ includeHoldType: true, includeReason: true, includeRecordType: true, includeDuration: true }),
page: page.value,
per_page: DEFAULT_PER_PAGE,
},
timeout: API_TIMEOUT,
});
return unwrapApiResult(response, '載入明細資料失敗');
}
async function loadAllData({ includeTrend = true, showOverlay = false } = {}) {
const requestId = nextRequestId();
if (showOverlay) {
initialLoading.value = true;
}
loading.global = true;
loading.list = true;
errorMessage.value = '';
try {
const requests = [];
if (includeTrend) {
requests.push(fetchTrend());
}
requests.push(fetchReasonPareto(), fetchDuration(), fetchList());
const responses = await Promise.all(requests);
if (isStaleRequest(requestId)) {
return;
}
let cursor = 0;
if (includeTrend) {
trendData.value = responses[cursor] || { days: [] };
cursor += 1;
}
reasonParetoData.value = responses[cursor] || { items: [] };
cursor += 1;
durationData.value = responses[cursor] || { items: [] };
cursor += 1;
detailData.value = normalizeListPayload(responses[cursor]);
} catch (error) {
if (isStaleRequest(requestId)) {
return;
}
errorMessage.value = error?.message || '載入資料失敗';
} finally {
if (isStaleRequest(requestId)) {
return;
}
loading.global = false;
loading.list = false;
initialLoading.value = false;
}
}
async function loadReasonDependents() {
const requestId = nextRequestId();
loading.list = true;
errorMessage.value = '';
try {
const list = await fetchList();
if (isStaleRequest(requestId)) {
return;
}
detailData.value = normalizeListPayload(list);
} catch (error) {
if (isStaleRequest(requestId)) {
return;
}
errorMessage.value = error?.message || '載入明細資料失敗';
} finally {
if (isStaleRequest(requestId)) {
return;
}
loading.list = false;
}
}
async function loadListOnly() {
const requestId = nextRequestId();
loading.list = true;
errorMessage.value = '';
try {
const list = await fetchList();
if (isStaleRequest(requestId)) {
return;
}
detailData.value = normalizeListPayload(list);
} catch (error) {
if (isStaleRequest(requestId)) {
return;
}
errorMessage.value = error?.message || '載入明細資料失敗';
} finally {
if (isStaleRequest(requestId)) {
return;
}
loading.list = false;
}
}
function estimateAvgHoldHours(items) {
const bucketHours = {
'<4h': 2,
'4-24h': 14,
'1-3d': 48,
'>3d': 96,
};
let weightedHours = 0;
let totalCount = 0;
(items || []).forEach((item) => {
const count = Number(item?.count || 0);
const range = String(item?.range || '').trim();
const representative = Number(bucketHours[range] || 0);
weightedHours += count * representative;
totalCount += count;
});
if (totalCount <= 0) {
return 0;
}
return weightedHours / totalCount;
}
const trendTypeKey = computed(() => (filterBar.holdType === 'non-quality' ? 'non_quality' : filterBar.holdType));
const selectedTrendDays = computed(() => {
const days = Array.isArray(trendData.value?.days) ? trendData.value.days : [];
return days.map((day) => {
const section = day?.[trendTypeKey.value] || {};
return {
date: day?.date || '',
holdQty: Number(section.holdQty || 0),
newHoldQty: Number(section.newHoldQty || 0),
releaseQty: Number(section.releaseQty || 0),
futureHoldQty: Number(section.futureHoldQty || 0),
};
});
});
const summary = computed(() => {
const days = selectedTrendDays.value;
const releaseQty = days.reduce((total, item) => total + Number(item.releaseQty || 0), 0);
const newHoldQty = days.reduce((total, item) => total + Number(item.newHoldQty || 0), 0);
const futureHoldQty = days.reduce((total, item) => total + Number(item.futureHoldQty || 0), 0);
const netChange = releaseQty - newHoldQty - futureHoldQty;
const avgHoldHours = estimateAvgHoldHours(durationData.value?.items || []);
const counts = trendData.value?.stillOnHoldCount || {};
const stillOnHoldCount = Number(counts[trendTypeKey.value] || 0);
return {
releaseQty,
newHoldQty,
futureHoldQty,
stillOnHoldCount,
netChange,
avgHoldHours,
};
});
const holdTypeLabel = computed(() => {
if (filterBar.holdType === 'non-quality') {
return '非品質異常';
}
if (filterBar.holdType === 'all') {
return '全部';
}
return '品質異常';
});
function handleFilterChange(next) {
const nextStartDate = next?.startDate || '';
const nextEndDate = next?.endDate || '';
const nextHoldType = next?.holdType || 'quality';
const dateChanged = filterBar.startDate !== nextStartDate || filterBar.endDate !== nextEndDate;
const holdTypeChanged = filterBar.holdType !== nextHoldType;
if (!dateChanged && !holdTypeChanged) {
return;
}
filterBar.startDate = nextStartDate;
filterBar.endDate = nextEndDate;
filterBar.holdType = nextHoldType;
reasonFilter.value = '';
durationFilter.value = '';
recordType.value = ['new'];
page.value = 1;
void loadAllData({ includeTrend: dateChanged, showOverlay: false });
}
function handleRecordTypeChange() {
reasonFilter.value = '';
durationFilter.value = '';
page.value = 1;
void loadAllData({ includeTrend: false, showOverlay: false });
}
function handleReasonToggle(reason) {
const nextReason = String(reason || '').trim();
if (!nextReason) {
return;
}
reasonFilter.value = reasonFilter.value === nextReason ? '' : nextReason;
page.value = 1;
void loadReasonDependents();
}
function clearReasonFilter() {
if (!reasonFilter.value) {
return;
}
reasonFilter.value = '';
page.value = 1;
void loadReasonDependents();
}
function handleDurationToggle(range) {
const nextRange = String(range || '').trim();
if (!nextRange) {
return;
}
durationFilter.value = durationFilter.value === nextRange ? '' : nextRange;
page.value = 1;
void loadReasonDependents();
}
function clearDurationFilter() {
if (!durationFilter.value) {
return;
}
durationFilter.value = '';
page.value = 1;
void loadReasonDependents();
}
function prevPage() {
if (page.value <= 1) {
return;
}
page.value -= 1;
void loadListOnly();
}
function nextPage() {
const totalPages = Number(detailData.value?.pagination?.totalPages || 1);
if (page.value >= totalPages) {
return;
}
page.value += 1;
void loadListOnly();
}
async function manualRefresh() {
page.value = 1;
await loadAllData({ includeTrend: true, showOverlay: false });
}
onMounted(() => {
setDefaultDateRange();
void loadAllData({ includeTrend: true, showOverlay: true });
});
</script>
<template>
<div class="dashboard hold-history-page">
<header class="header hold-history-header">
<div class="header-left">
<h1>Hold 歷史績效 Dashboard</h1>
<span class="hold-type-badge">{{ holdTypeLabel }}</span>
</div>
<div class="header-right">
<button type="button" class="btn btn-light" @click="manualRefresh">重新整理</button>
</div>
</header>
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
<FilterBar
:start-date="filterBar.startDate"
:end-date="filterBar.endDate"
:hold-type="filterBar.holdType"
:disabled="loading.global"
@change="handleFilterChange"
/>
<SummaryCards :summary="summary" />
<DailyTrend :days="selectedTrendDays" />
<RecordTypeFilter
v-model="recordType"
:disabled="loading.global"
@update:model-value="handleRecordTypeChange"
/>
<section class="hold-history-chart-grid">
<ReasonPareto
:items="reasonParetoData.items || []"
:active-reason="reasonFilter"
@toggle="handleReasonToggle"
/>
<DurationChart
:items="durationData.items || []"
:active-range="durationFilter"
@toggle="handleDurationToggle"
/>
</section>
<FilterIndicator
:reason="reasonFilter"
:duration-range="durationFilter"
@clear-reason="clearReasonFilter"
@clear-duration="clearDurationFilter"
/>
<DetailTable
:items="detailData.items || []"
:pagination="detailData.pagination"
:loading="loading.list"
:error-message="errorMessage"
@prev-page="prevPage"
@next-page="nextPage"
/>
</div>
<div v-if="initialLoading" class="loading-overlay">
<span class="loading-spinner"></span>
<span>Loading...</span>
</div>
</template>

View File

@@ -0,0 +1,131 @@
<script setup>
import { computed } from 'vue';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
use([CanvasRenderer, BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent]);
const props = defineProps({
days: {
type: Array,
default: () => [],
},
});
const hasData = computed(() => (props.days || []).length > 0);
const chartOption = computed(() => {
const days = props.days || [];
const dates = days.map((item) => item.date);
const release = days.map((item) => Number(item.releaseQty || 0));
const newHold = days.map((item) => -Math.abs(Number(item.newHoldQty || 0)));
const futureHold = days.map((item) => -Math.abs(Number(item.futureHoldQty || 0)));
const stock = days.map((item) => Number(item.holdQty || 0));
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter(params) {
const index = Number(params?.[0]?.dataIndex || 0);
const row = days[index] || {};
const parts = [
`<b>${row.date || '--'}</b>`,
`Release: ${Number(row.releaseQty || 0).toLocaleString('zh-TW')}`,
`New Hold: ${Number(row.newHoldQty || 0).toLocaleString('zh-TW')}`,
`Future Hold: ${Number(row.futureHoldQty || 0).toLocaleString('zh-TW')}`,
`On Hold: ${Number(row.holdQty || 0).toLocaleString('zh-TW')}`,
];
return parts.join('<br/>');
},
},
legend: {
data: ['Release', 'New Hold', 'Future Hold', 'On Hold'],
bottom: 0,
},
grid: {
left: 48,
right: 58,
top: 30,
bottom: 52,
},
xAxis: {
type: 'category',
data: dates,
axisLabel: {
fontSize: 11,
interval: Math.max(Math.floor(dates.length / 12), 0),
},
},
yAxis: [
{
type: 'value',
name: '增減量',
axisLabel: {
formatter: (value) => Number(value || 0).toLocaleString('zh-TW'),
},
},
{
type: 'value',
name: 'On Hold',
axisLabel: {
formatter: (value) => Number(value || 0).toLocaleString('zh-TW'),
},
},
],
series: [
{
name: 'Release',
type: 'bar',
data: release,
itemStyle: { color: '#16a34a' },
barMaxWidth: 18,
},
{
name: 'New Hold',
type: 'bar',
stack: 'negative',
data: newHold,
itemStyle: { color: '#dc2626' },
barMaxWidth: 18,
},
{
name: 'Future Hold',
type: 'bar',
stack: 'negative',
data: futureHold,
itemStyle: { color: '#f97316' },
barMaxWidth: 18,
},
{
name: 'On Hold',
type: 'line',
yAxisIndex: 1,
data: stock,
smooth: true,
lineStyle: { width: 2, color: '#2563eb' },
itemStyle: { color: '#2563eb' },
symbolSize: 5,
},
],
};
});
</script>
<template>
<section class="card">
<div class="card-header">
<div class="card-title">Daily Trend</div>
</div>
<div class="card-body">
<div v-if="hasData" class="trend-chart-wrap">
<VChart :option="chartOption" autoresize />
</div>
<div v-else class="placeholder">No data</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,118 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
items: {
type: Array,
default: () => [],
},
pagination: {
type: Object,
default: () => ({ page: 1, perPage: 50, total: 0, totalPages: 1 }),
},
loading: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['prev-page', 'next-page']);
const canPrev = computed(() => Number(props.pagination?.page || 1) > 1);
const canNext = computed(() => Number(props.pagination?.page || 1) < Number(props.pagination?.totalPages || 1));
const pageSummary = 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 '顯示 0 / 0';
}
const start = (page - 1) * perPage + 1;
const end = Math.min(page * perPage, total);
return `顯示 ${start} - ${end} / ${total.toLocaleString('zh-TW')}`;
});
function formatNumber(value) {
if (value === null || value === undefined || value === '') {
return '-';
}
return Number(value).toLocaleString('zh-TW');
}
function formatHours(value) {
if (value === null || value === undefined || value === '') {
return '-';
}
return Number(value).toFixed(2);
}
</script>
<template>
<section class="card">
<div class="card-header detail-header">
<div class="card-title">Hold / Release 明細</div>
<div class="table-info">{{ pageSummary }}</div>
</div>
<div class="card-body detail-table-wrap">
<table class="detail-table">
<thead>
<tr>
<th>Lot ID</th>
<th>WorkOrder</th>
<th>站別</th>
<th>Hold Reason</th>
<th>數量</th>
<th>Hold 時間</th>
<th>Hold 人員</th>
<th>Hold Comment</th>
<th>Release 時間</th>
<th>Release 人員</th>
<th>Release Comment</th>
<th>時長(hr)</th>
<th>NCR</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="items.length === 0">
<td colspan="13" class="placeholder">No data</td>
</tr>
<tr v-for="item in items" v-else :key="`${item.lotId}-${item.holdDate}-${item.releaseDate}`">
<td>{{ item.lotId || '-' }}</td>
<td>{{ item.workorder || '-' }}</td>
<td>{{ item.workcenter || '-' }}</td>
<td>{{ item.holdReason || '-' }}</td>
<td>{{ formatNumber(item.qty) }}</td>
<td>{{ item.holdDate || '-' }}</td>
<td>{{ item.holdEmp || '-' }}</td>
<td class="cell-comment">{{ item.holdComment || '-' }}</td>
<td>{{ item.releaseDate || '仍在 Hold' }}</td>
<td>{{ item.releaseEmp || '-' }}</td>
<td class="cell-comment">{{ item.releaseComment || '-' }}</td>
<td>{{ formatHours(item.holdHours) }}</td>
<td>{{ item.ncr || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button type="button" :disabled="!canPrev" @click="emit('prev-page')">Prev</button>
<span class="page-info">Page {{ pagination.page || 1 }} / {{ pagination.totalPages || 1 }}</span>
<button type="button" :disabled="!canNext" @click="emit('next-page')">Next</button>
</div>
</section>
</template>

View File

@@ -0,0 +1,115 @@
<script setup>
import { computed } from 'vue';
import { BarChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent]);
const props = defineProps({
items: {
type: Array,
default: () => [],
},
activeRange: {
type: String,
default: '',
},
});
const emit = defineEmits(['toggle']);
const hasData = computed(() => (props.items || []).length > 0);
const chartOption = computed(() => {
const items = props.items || [];
const labels = items.map((item) => item.range || '-');
const qtys = items.map((item) => Number(item.qty || 0));
const pcts = items.map((item) => Number(item.pct || 0));
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter(params) {
const index = Number(params?.[0]?.dataIndex || 0);
const item = items[index] || {};
return [
`<b>${item.range || '-'}</b>`,
`數量: ${Number(item.qty || 0).toLocaleString('zh-TW')}`,
`Lot 數: ${Number(item.count || 0).toLocaleString('zh-TW')}`,
`占比: ${Number(item.pct || 0).toFixed(2)}%`,
].join('<br/>');
},
},
grid: {
left: 56,
right: 28,
top: 14,
bottom: 24,
},
xAxis: {
type: 'value',
axisLabel: {
formatter: (value) => Number(value || 0).toLocaleString('zh-TW'),
},
},
yAxis: {
type: 'category',
data: labels,
},
series: [
{
type: 'bar',
data: qtys,
barMaxWidth: 26,
itemStyle: {
color(params) {
const range = labels[params.dataIndex] || '';
return range === props.activeRange ? '#dc2626' : '#7c3aed';
},
borderRadius: [0, 4, 4, 0],
},
label: {
show: true,
position: 'right',
formatter(params) {
const pct = Number(pcts[params.dataIndex] || 0).toFixed(1);
const qty = Number(params.value || 0).toLocaleString('zh-TW');
return `${qty} (${pct}%)`;
},
fontSize: 11,
},
},
],
};
});
function handleChartClick(params) {
if (params?.seriesType !== 'bar') {
return;
}
const selected = props.items?.[params.dataIndex]?.range;
if (!selected) {
return;
}
emit('toggle', selected);
}
</script>
<template>
<section class="card">
<div class="card-header">
<div class="card-title">Hold Duration Distribution</div>
</div>
<div class="card-body">
<div v-if="hasData" class="duration-chart-wrap">
<VChart :option="chartOption" autoresize @click="handleChartClick" />
</div>
<div v-else class="placeholder">No data</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,103 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
startDate: {
type: String,
default: '',
},
endDate: {
type: String,
default: '',
},
holdType: {
type: String,
default: 'quality',
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['change']);
function emitChange(next) {
emit('change', {
startDate: next.startDate ?? props.startDate,
endDate: next.endDate ?? props.endDate,
holdType: next.holdType ?? props.holdType,
});
}
const startDateModel = computed({
get() {
return props.startDate || '';
},
set(nextValue) {
emitChange({ startDate: nextValue || '' });
},
});
const endDateModel = computed({
get() {
return props.endDate || '';
},
set(nextValue) {
emitChange({ endDate: nextValue || '' });
},
});
const holdTypeModel = computed({
get() {
return props.holdType || 'quality';
},
set(nextValue) {
emitChange({ holdType: nextValue || 'quality' });
},
});
</script>
<template>
<section class="filter-bar card">
<div class="filter-group date-group">
<label class="filter-label" for="hold-history-start-date">開始日期</label>
<input
id="hold-history-start-date"
v-model="startDateModel"
class="date-input"
type="date"
:disabled="disabled"
/>
</div>
<div class="filter-group date-group">
<label class="filter-label" for="hold-history-end-date">結束日期</label>
<input
id="hold-history-end-date"
v-model="endDateModel"
class="date-input"
type="date"
:disabled="disabled"
/>
</div>
<div class="filter-group hold-type-group">
<span class="filter-label">Hold Type</span>
<div class="radio-group">
<label class="radio-option" :class="{ active: holdTypeModel === 'quality' }">
<input v-model="holdTypeModel" type="radio" value="quality" :disabled="disabled" />
<span>品質異常</span>
</label>
<label class="radio-option" :class="{ active: holdTypeModel === 'non-quality' }">
<input v-model="holdTypeModel" type="radio" value="non-quality" :disabled="disabled" />
<span>非品質異常</span>
</label>
<label class="radio-option" :class="{ active: holdTypeModel === 'all' }">
<input v-model="holdTypeModel" type="radio" value="all" :disabled="disabled" />
<span>全部</span>
</label>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,32 @@
<script setup>
defineProps({
reason: {
type: String,
default: '',
},
durationRange: {
type: String,
default: '',
},
});
const emit = defineEmits(['clear-reason', 'clear-duration']);
</script>
<template>
<section v-if="reason || durationRange" class="reason-filter-indicator">
<span v-if="reason" class="filter-chip reason">Reason 篩選{{ reason }}</span>
<button v-if="reason" type="button" class="btn btn-secondary clear-reason-btn" @click="emit('clear-reason')">
清除
</button>
<span v-if="durationRange" class="filter-chip duration">時長篩選{{ durationRange }}</span>
<button
v-if="durationRange"
type="button"
class="btn btn-secondary clear-reason-btn"
@click="emit('clear-duration')"
>
清除
</button>
</section>
</template>

View File

@@ -0,0 +1,140 @@
<script setup>
import { computed } from 'vue';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
const props = defineProps({
items: {
type: Array,
default: () => [],
},
activeReason: {
type: String,
default: '',
},
});
const emit = defineEmits(['toggle']);
const hasData = computed(() => (props.items || []).length > 0);
const chartOption = computed(() => {
const items = props.items || [];
const reasons = items.map((item) => item.reason || '(未填寫)');
const qtys = items.map((item) => Number(item.qty || 0));
const cumPct = items.map((item) => Number(item.cumPct || 0));
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter(params) {
const index = Number(params?.[0]?.dataIndex || 0);
const item = items[index] || {};
const reason = item.reason || '(未填寫)';
return [
`<b>${reason}</b>`,
`數量: ${Number(item.qty || 0).toLocaleString('zh-TW')}`,
`Lot 數: ${Number(item.count || 0).toLocaleString('zh-TW')}`,
`占比: ${Number(item.pct || 0).toFixed(2)}%`,
`累積占比: ${Number(item.cumPct || 0).toFixed(2)}%`,
].join('<br/>');
},
},
legend: {
data: ['數量', '累積%'],
bottom: 0,
},
grid: {
left: 48,
right: 52,
top: 30,
bottom: 100,
},
xAxis: {
type: 'category',
data: reasons,
axisLabel: {
interval: 0,
rotate: reasons.length > 5 ? 35 : 0,
fontSize: 11,
overflow: 'truncate',
width: 92,
},
},
yAxis: [
{
type: 'value',
name: '數量',
axisLabel: {
formatter: (value) => Number(value || 0).toLocaleString('zh-TW'),
},
},
{
type: 'value',
name: '%',
min: 0,
max: 100,
axisLabel: {
formatter: '{value}%',
},
},
],
series: [
{
name: '數量',
type: 'bar',
data: qtys,
itemStyle: {
color(params) {
const reason = reasons[params.dataIndex] || '';
return reason === props.activeReason ? '#dc2626' : '#1d4ed8';
},
borderRadius: [4, 4, 0, 0],
},
barMaxWidth: 36,
},
{
name: '累積%',
type: 'line',
yAxisIndex: 1,
data: cumPct,
lineStyle: { color: '#f59e0b', width: 2 },
itemStyle: { color: '#f59e0b' },
symbolSize: 6,
},
],
};
});
function handleChartClick(params) {
if (params?.seriesType !== 'bar') {
return;
}
const selected = props.items?.[params.dataIndex]?.reason;
if (!selected) {
return;
}
emit('toggle', selected);
}
</script>
<template>
<section class="card">
<div class="card-header">
<div class="card-title">Reason Pareto</div>
</div>
<div class="card-body">
<div v-if="hasData" class="pareto-chart-wrap">
<VChart :option="chartOption" autoresize @click="handleChartClick" />
</div>
<div v-else class="placeholder">No data</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,53 @@
<script setup>
const props = defineProps({
modelValue: {
type: Array,
default: () => ['new'],
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
function toggle(value) {
const current = props.modelValue || [];
const idx = current.indexOf(value);
let next;
if (idx >= 0) {
next = current.filter((v) => v !== value);
} else {
next = [...current, value];
}
if (next.length === 0) {
return;
}
emit('update:modelValue', next);
}
function isChecked(value) {
return (props.modelValue || []).includes(value);
}
</script>
<template>
<section class="record-type-filter">
<span class="filter-label">Record Type</span>
<div class="checkbox-group">
<label class="checkbox-option" :class="{ active: isChecked('new') }">
<input type="checkbox" :checked="isChecked('new')" :disabled="disabled" @change="toggle('new')" />
<span>New Hold</span>
</label>
<label class="checkbox-option" :class="{ active: isChecked('on_hold') }">
<input type="checkbox" :checked="isChecked('on_hold')" :disabled="disabled" @change="toggle('on_hold')" />
<span>On Hold</span>
</label>
<label class="checkbox-option" :class="{ active: isChecked('released') }">
<input type="checkbox" :checked="isChecked('released')" :disabled="disabled" @change="toggle('released')" />
<span>Released</span>
</label>
</div>
</section>
</template>

View File

@@ -0,0 +1,59 @@
<script setup>
const props = defineProps({
summary: {
type: Object,
default: () => ({
releaseQty: 0,
newHoldQty: 0,
futureHoldQty: 0,
stillOnHoldCount: 0,
netChange: 0,
avgHoldHours: 0,
}),
},
});
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
function formatHours(value) {
return `${Number(value || 0).toFixed(1)} hr`;
}
</script>
<template>
<section class="summary-row hold-history-summary-row">
<article class="summary-card stat-positive">
<div class="summary-label">Release 數量</div>
<div class="summary-value">{{ formatNumber(summary.releaseQty) }}</div>
</article>
<article class="summary-card stat-negative-red">
<div class="summary-label">New Hold 數量</div>
<div class="summary-value">{{ formatNumber(summary.newHoldQty) }}</div>
</article>
<article class="summary-card stat-negative-orange">
<div class="summary-label">Future Hold 數量</div>
<div class="summary-value">{{ formatNumber(summary.futureHoldQty) }}</div>
</article>
<article class="summary-card stat-negative-red">
<div class="summary-label">On Hold 數量</div>
<div class="summary-value">{{ formatNumber(summary.stillOnHoldCount) }}</div>
</article>
<article class="summary-card">
<div class="summary-label">淨變動 (Release - New - Future)</div>
<div class="summary-value" :class="{ positive: summary.netChange >= 0, negative: summary.netChange < 0 }">
{{ formatNumber(summary.netChange) }}
</div>
</article>
<article class="summary-card">
<div class="summary-label">平均 Hold 時長</div>
<div class="summary-value small">{{ formatHours(summary.avgHoldHours) }}</div>
</article>
</section>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,305 @@
.hold-history-header {
background: linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%);
}
.hold-history-page .header h1 {
font-size: 22px;
}
.hold-type-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
}
.card {
background: var(--card-bg);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 14px;
}
.card-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: #f8fafc;
}
.card-title {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.card-body {
padding: 14px 16px;
}
.error-banner {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 6px;
background: #fef2f2;
color: #991b1b;
font-size: 13px;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 16px;
padding: 16px 20px;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-label {
font-size: 12px;
font-weight: 700;
color: #475569;
}
.date-input {
min-width: 170px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
background: #ffffff;
}
.date-input:focus {
outline: none;
border-color: #0ea5e9;
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18);
}
.radio-group,
.checkbox-group {
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
}
.radio-option,
.checkbox-option {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: #ffffff;
cursor: pointer;
font-size: 13px;
}
.radio-option.active,
.checkbox-option.active {
border-color: #0284c7;
background: #e0f2fe;
color: #075985;
font-weight: 700;
}
.record-type-filter {
display: flex;
align-items: center;
gap: 12px;
}
.hold-history-summary-row {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.summary-card {
border: 1px solid var(--border);
}
.summary-value.positive {
color: #15803d;
}
.summary-value.negative {
color: #b91c1c;
}
.stat-positive .summary-value {
color: #15803d;
}
.stat-negative-red .summary-value {
color: #b91c1c;
}
.stat-negative-orange .summary-value {
color: #c2410c;
}
.hold-history-chart-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 14px;
}
.trend-chart-wrap,
.pareto-chart-wrap,
.duration-chart-wrap {
height: 360px;
}
.reason-filter-indicator {
display: flex;
align-items: center;
gap: 10px;
margin: 2px 0 14px;
}
.filter-chip.reason,
.filter-chip.duration {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: #dbeafe;
color: #1e3a8a;
}
.filter-chip.duration {
background: #f3e8ff;
color: #581c87;
}
.clear-reason-btn {
padding: 6px 10px;
font-size: 12px;
}
.department-table-wrap,
.detail-table-wrap {
padding: 0;
overflow: auto;
}
.dept-table,
.detail-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.dept-table th,
.dept-table td,
.detail-table th,
.detail-table td {
padding: 9px 10px;
border-bottom: 1px solid #e5e7eb;
white-space: nowrap;
text-align: left;
}
.dept-table th,
.detail-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f8fafc;
font-weight: 700;
}
.dept-table td:nth-child(3),
.dept-table td:nth-child(4),
.dept-table td:nth-child(5),
.detail-table td:nth-child(11),
.detail-table td:nth-child(12) {
text-align: right;
}
.dept-row {
background: #ffffff;
}
.person-row {
background: #f8fafc;
}
.person-name {
color: #334155;
}
.expand-btn {
width: 24px;
height: 24px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #ffffff;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.table-info {
font-size: 12px;
color: #64748b;
}
.cell-comment {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 1440px) {
.hold-history-summary-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1080px) {
.hold-history-chart-grid {
grid-template-columns: 1fr;
}
.hold-history-summary-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.hold-history-summary-row {
grid-template-columns: 1fr;
}
.filter-bar {
padding: 14px 16px;
}
.date-input {
min-width: 140px;
}
.hold-history-page .header h1 {
font-size: 20px;
}
}

View File

@@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => ({
'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'),
'hold-history': resolve(__dirname, 'src/hold-history/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'),

View File

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

View File

@@ -0,0 +1,94 @@
## Context
Hold Overview (DW_MES_LOT_V, Redis cache) 提供即時快照Hold Detail 深入單一 Reason。但主管缺乏歷史視角——趨勢、時長分析、部門績效都只能透過 BI 工具 (PJMES043) 手動操作。
本設計在既有 Dashboard 架構上新增一個歷史績效頁面,直接查詢 `DWH.DW_MES_HOLDRELEASEHISTORY` 表 (~310K rows),搭配 Redis 快取加速近期資料。
## Goals / Non-Goals
**Goals:**
- 提供自由日期區間的 Hold 歷史每日趨勢圖On Hold/新增/解除/Future Hold
- 提供 Reason Pareto、Duration 分布、負責人統計(部門+個人)分析
- 提供 paginated Hold/Release 明細表
- Reason Pareto 點擊可 cascade filter 負責人統計與明細表
- 近二月資料使用 Redis 快取12hr TTL前端切換 Hold Type 免 re-call
**Non-Goals:**
- 不替代既有 Hold Overview / Hold Detail 的即時功能
- 不引入即時 WebSocket 推送
- 不做跨頁面的 drill-through本頁面自成體系
- 不修改 HOLDRELEASEHISTORY 表結構或新增 index
## Decisions
### 1. 資料來源:直接查詢 HOLDRELEASEHISTORY vs. 預建聚合表
**選擇**: 直接查詢 + Redis 快取聚合結果
**理由**: 310K 行規模適中calendar-spine cross-join 月級查詢在秒級內完成。預建聚合表增加 ETL 複雜度且歷史數據變動低頻12hr 快取已足夠。
**替代方案**: 在 DWH 建立物化視圖 → 拒絕,因需 DBA 協調且 Dashboard 應盡量自包含。
### 2. 快取策略:近二月 Redis vs. 全量快取 vs. 無快取
**選擇**: 近二月(當月 + 前一月Redis 快取12hr TTL
**理由**: 多數使用者查看近期資料。近二月快取命中率高,超過二月的查詢較少且可接受直接 Oracle 查詢的延遲。全量快取浪費記憶體且過期管理複雜。
**Redis key**: `hold_history:daily:{YYYY-MM}`
**結構**: 一份快取包含 quality / non_quality / all 三種 hold_type 的每日聚合,前端切換免 API re-call。
**跨月查詢**: 後端從多個月快取中切出需要的日期範圍後合併回傳。
### 3. trend API 回傳三種 hold_type vs. 按需查詢
**選擇**: trend API 一次回傳三種 hold_type 的每日資料
**理由**: 趨勢是最常操作的圖表,切換 hold_type 應即時響應。三種 hold_type 資料已在同一份 Redis 快取中,回傳全部不增加 I/O但大幅改善 UX。其餘 4 支 API (pareto/duration/department/list) 按 hold_type 過濾,因為它們的 payload 可能很大。
### 4. SQL 集中管理sql/hold_history/ 目錄
**選擇**: SQL 檔案放在 `src/mes_dashboard/sql/hold_history/` 目錄
**理由**: 遵循既有 `sql/query_tool/``sql/dashboard/``sql/resource/``sql/wip/` 的集中管理模式。SQL 與 Python 分離便於 review 和維護。
**檔案規劃**:
- `trend.sql` — calendar-spine cross-join 每日聚合(翻譯自 hold_history.md
- `reason_pareto.sql` — GROUP BY HOLDREASONNAME
- `duration.sql` — 已 release 的 hold 時長分布
- `department.sql` — GROUP BY HOLDEMPDEPTNAME / HOLDEMP
- `list.sql` — paginated 明細查詢
### 5. 商業邏輯07:30 班別邊界
**選擇**: 忠實保留 hold_history.md 的班別邊界邏輯
**理由**: 這是工廠既有的日報定義07:30 後的交易歸入隔天。偏離此定義會導致 Dashboard 數字與既有 BI 報表不一致。
**實作**: 在 SQL 層處理 (`CASE WHEN TO_CHAR(HOLDTXNDATE,'HH24MI') >= '0730' THEN TRUNC(HOLDTXNDATE) + 1 ELSE TRUNC(HOLDTXNDATE) END`)。
### 6. 前端架構
**選擇**: 獨立 Vue 3 SFC 頁面,複用 wip-shared composables
**元件規劃**:
- `App.vue` — 頁面主容器、狀態管理、API 呼叫
- `FilterBar.vue` — DatePicker + Hold Type radio
- `SummaryCards.vue` — 6 張 KPI 卡片
- `DailyTrend.vue` — ECharts 折線+柱狀混合圖
- `ReasonPareto.vue` — ECharts Pareto 圖(可點擊)
- `DurationChart.vue` — ECharts 橫向柱狀圖
- `DepartmentTable.vue` — 可展開的部門/個人統計表
- `DetailTable.vue` — paginated 明細表
### 7. Duration 分布的計算範圍
**選擇**: 僅計算已 Release 的 holdRELEASETXNDATE IS NOT NULL
**理由**: 仍在 hold 中的無法確定最終時長,納入會扭曲分布。明細表中仍顯示未 release 的 hold以 SYSDATE 計算到目前時長)。
## Risks / Trade-offs
- **[HOLDTXNDATE 無 index]** → HOLDRELEASEHISTORY 僅有 HISTORYMAINLINEID 和 CONTAINERID 的 index。日期範圍查詢走 full table scan (~310K rows)。緩解12hr Redis 快取 + 月級查詢粒度限制。若未來資料量成長,可考慮請 DBA 加 HOLDTXNDATE index。
- **[Calendar-spine cross-join 效能]** → 月曆骨幹 × 全表 cross join 是最重的查詢。緩解Redis 快取近二月,超過二月直接查詢但接受較長 loading。
- **[Redis 快取一致性]** → 12hr TTL 意味資料最多延遲 12 小時。緩解:歷史資料本身就是 T-1 更新12hr 延遲對管理決策無影響。
- **[明細表回傳 HOLDCOMMENTS/RELEASECOMMENTS]** → 文字欄位可能很長。緩解:前端 truncate 顯示hover 看全文。

View File

@@ -0,0 +1,32 @@
## Why
Hold Overview 和 Hold Detail 都是基於 DW_MES_LOT_V 的即時快照,只能回答「現在線上有什麼 Hold」。主管需要追蹤歷史趨勢來回答「Hold 狀況是在改善還是惡化?哪些原因最耗時?哪個部門處理最慢?」。目前這些分析只能透過 BI 工具 (PJMES043) 手動查詢,無法即時在 Dashboard 上呈現。
## What Changes
- 新增 `/hold-history` 頁面,提供 Hold/Release 歷史績效 Dashboard
- 新增 5 支 API endpoints (`/api/hold-history/trend`, `reason-pareto`, `duration`, `department`, `list`)
- 新增 `hold_history_service.py` 服務層,查詢 `DWH.DW_MES_HOLDRELEASEHISTORY`
- 新增 SQL 檔案集中管理在 `src/mes_dashboard/sql/hold_history/` 目錄
- trend API 採用 Redis 快取策略近二月聚合資料12hr TTL
- 翻譯 `docs/hold_history.md` 中的 calendar-spine cross-join 商業邏輯為參數化 SQL
- 新增 Vite entry point `src/hold-history/index.html`
- 新增頁面註冊至 `data/page_status.json`
## Capabilities
### New Capabilities
- `hold-history-page`: Hold 歷史績效 Dashboard 前端頁面包含篩選器、Summary KPIs、Daily Trend 圖、Reason Pareto、Duration 分布、負責人統計、明細表,及 Reason Pareto 的 cascade filter 機制
- `hold-history-api`: Hold 歷史績效 API 後端,包含 5 支 endpoints、Oracle 查詢(含 calendar-spine 商業邏輯、Redis 快取策略、SQL 集中管理
### Modified Capabilities
- `vue-vite-page-architecture`: 新增 Hold History entry point 至 Vite 配置
## Impact
- **後端**: 新增 Flask Blueprint `hold_history_routes.py`、服務層 `hold_history_service.py`、SQL 檔案 `sql/hold_history/`
- **前端**: 新增 `frontend/src/hold-history/` 頁面目錄,使用 ECharts (BarChart, LineChart) 及 wip-shared composables
- **資料庫**: 直接查詢 `DWH.DW_MES_HOLDRELEASEHISTORY`~310K rows無 schema 變更
- **Redis**: 新增 `hold_history:daily:{YYYY-MM}` 快取 key
- **配置**: `vite.config.js` 新增 entry、`page_status.json` 新增頁面註冊
- **既有功能**: 無影響,完全獨立的新頁面和新 API

View File

@@ -0,0 +1,172 @@
## ADDED Requirements
### Requirement: Hold History API SHALL provide daily trend data with Redis caching
The API SHALL return daily aggregated hold/release metrics for the selected date range.
#### Scenario: Trend endpoint returns all three hold types
- **WHEN** `GET /api/hold-history/trend?start_date=2025-01-01&end_date=2025-01-31` is called
- **THEN** the response SHALL return `{ success: true, data: { days: [...] } }`
- **THEN** each day item SHALL contain `{ date, quality: { holdQty, newHoldQty, releaseQty, futureHoldQty }, non_quality: { ... }, all: { ... } }`
- **THEN** all three hold_type variants SHALL be included in a single response
#### Scenario: Trend uses shift boundary at 07:30
- **WHEN** daily aggregation is calculated
- **THEN** transactions with time >= 07:30 SHALL be attributed to the next calendar day
- **THEN** transactions with time < 07:30 SHALL be attributed to the current calendar day
#### Scenario: Trend deduplicates same-day multiple holds
- **WHEN** a lot is held multiple times on the same day
- **THEN** only one hold event SHALL be counted for that day (using ROW_NUMBER per CONTAINERID per day)
#### Scenario: Trend deduplicates future holds
- **WHEN** the same lot has multiple future holds for the same reason
- **THEN** only the first occurrence SHALL be counted (using ROW_NUMBER per CONTAINERID per HOLDREASONID)
#### Scenario: Trend hold type classification
- **WHEN** trend data is aggregated by hold type
- **THEN** quality classification SHALL use the same NON_QUALITY_HOLD_REASONS set as existing hold endpoints
- **THEN** holds with HOLDREASONNAME NOT in NON_QUALITY_HOLD_REASONS SHALL be classified as quality
- **THEN** the "all" variant SHALL include both quality and non-quality holds
#### Scenario: Trend Redis cache for recent two months
- **WHEN** the requested date range falls within the current month or previous month
- **THEN** the service SHALL check Redis for cached data at key `hold_history:daily:{YYYY-MM}`
- **THEN** if cache exists, data SHALL be returned from Redis
- **THEN** if cache is missing, data SHALL be queried from Oracle and stored in Redis with 12-hour TTL
#### Scenario: Trend direct Oracle query for older data
- **WHEN** the requested date range includes months older than the previous month
- **THEN** the service SHALL query Oracle directly without caching
#### Scenario: Trend cross-month query assembly
- **WHEN** the requested date range spans multiple months (e.g., 2025-01-15 to 2025-02-15)
- **THEN** the service SHALL fetch each month's data independently (from cache or Oracle)
- **THEN** the service SHALL trim the combined result to the exact requested date range
- **THEN** the response SHALL contain only days within start_date and end_date inclusive
#### Scenario: Trend error
- **WHEN** the database query fails
- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500
### Requirement: Hold History API SHALL provide reason Pareto data
The API SHALL return hold reason distribution for Pareto analysis.
#### Scenario: Reason Pareto endpoint
- **WHEN** `GET /api/hold-history/reason-pareto?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called
- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }`
- **THEN** each item SHALL contain `{ reason, count, qty, pct, cumPct }`
- **THEN** items SHALL be sorted by count descending
- **THEN** pct SHALL be percentage of total hold events
- **THEN** cumPct SHALL be running cumulative percentage
#### Scenario: Reason Pareto uses shift boundary
- **WHEN** hold events are counted for Pareto
- **THEN** the 07:30 shift boundary rule SHALL be applied to HOLDTXNDATE
#### Scenario: Reason Pareto hold type filter
- **WHEN** hold_type is "quality"
- **THEN** only quality hold reasons SHALL be included
- **WHEN** hold_type is "non-quality"
- **THEN** only non-quality hold reasons SHALL be included
- **WHEN** hold_type is "all"
- **THEN** all hold reasons SHALL be included
### Requirement: Hold History API SHALL provide hold duration distribution
The API SHALL return hold duration distribution buckets.
#### Scenario: Duration endpoint
- **WHEN** `GET /api/hold-history/duration?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called
- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }`
- **THEN** items SHALL contain 4 buckets: `{ range: "<4h", count, pct }`, `{ range: "4-24h", count, pct }`, `{ range: "1-3d", count, pct }`, `{ range: ">3d", count, pct }`
#### Scenario: Duration only includes released holds
- **WHEN** duration is calculated
- **THEN** only hold records with RELEASETXNDATE IS NOT NULL SHALL be included
- **THEN** duration SHALL be calculated as RELEASETXNDATE - HOLDTXNDATE
#### Scenario: Duration date range filter
- **WHEN** start_date and end_date are provided
- **THEN** only holds with HOLDTXNDATE within the date range (applying 07:30 shift boundary) SHALL be included
### Requirement: Hold History API SHALL provide department statistics
The API SHALL return hold/release statistics aggregated by department with optional person detail.
#### Scenario: Department endpoint
- **WHEN** `GET /api/hold-history/department?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called
- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }`
- **THEN** each item SHALL contain `{ dept, holdCount, releaseCount, avgHoldHours, persons: [{ name, holdCount, releaseCount, avgHoldHours }] }`
- **THEN** items SHALL be sorted by holdCount descending
#### Scenario: Department with reason filter
- **WHEN** `GET /api/hold-history/department?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&reason=品質確認` is called
- **THEN** only hold records matching the specified reason SHALL be included in department and person statistics
#### Scenario: Department hold count vs release count
- **WHEN** department statistics are calculated
- **THEN** holdCount SHALL count records where HOLDEMPDEPTNAME equals the department AND HOLDTXNDATE is within the date range
- **THEN** releaseCount SHALL count records where RELEASEEMPDEPTNAME equals the department AND RELEASETXNDATE is within the date range
- **THEN** avgHoldHours SHALL be the average of (RELEASETXNDATE - HOLDTXNDATE) in hours for released holds initiated by that department
### Requirement: Hold History API SHALL provide paginated detail list
The API SHALL return a paginated list of individual hold/release records.
#### Scenario: List endpoint
- **WHEN** `GET /api/hold-history/list?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&page=1&per_page=50` is called
- **THEN** the response SHALL return `{ success: true, data: { items: [...], pagination: { page, perPage, total, totalPages } } }`
- **THEN** each item SHALL contain: lotId, workorder, workcenter, holdReason, holdDate, holdEmp, holdComment, releaseDate, releaseEmp, releaseComment, holdHours, ncr
- **THEN** items SHALL be sorted by HOLDTXNDATE descending
#### Scenario: List with reason filter
- **WHEN** `GET /api/hold-history/list?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&reason=品質確認` is called
- **THEN** only records matching the specified HOLDREASONNAME SHALL be returned
#### Scenario: List unreleased hold records
- **WHEN** a hold record has RELEASETXNDATE IS NULL
- **THEN** releaseDate SHALL be null
- **THEN** holdHours SHALL be calculated as (SYSDATE - HOLDTXNDATE) * 24
#### Scenario: List 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: List date range uses shift boundary
- **WHEN** records are filtered by date range
- **THEN** the 07:30 shift boundary rule SHALL be applied to HOLDTXNDATE
### Requirement: Hold History API SHALL use centralized SQL files
The API SHALL load SQL queries from files in the `src/mes_dashboard/sql/hold_history/` directory.
#### Scenario: SQL file organization
- **WHEN** the hold history service executes a query
- **THEN** the SQL SHALL be loaded from `sql/hold_history/<query_name>.sql`
- **THEN** the following SQL files SHALL exist: `trend.sql`, `reason_pareto.sql`, `duration.sql`, `department.sql`, `list.sql`
#### Scenario: SQL parameterization
- **WHEN** SQL queries are executed
- **THEN** all user-provided parameters (dates, hold_type, reason) SHALL be passed as bind parameters
- **THEN** no string interpolation SHALL be used for user input
### Requirement: Hold History API SHALL apply rate limiting
The API SHALL apply rate limiting to expensive endpoints.
#### Scenario: Rate limit on list endpoint
- **WHEN** the list 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 trend endpoint
- **WHEN** the trend endpoint receives excessive requests
- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 60 requests per 60 seconds
### Requirement: Hold History 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-history`
- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/hold-history.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

View File

@@ -0,0 +1,172 @@
## ADDED Requirements
### Requirement: Hold History page SHALL display a filter bar with date range and hold type
The page SHALL provide a filter bar for selecting date range and hold type classification.
#### Scenario: Default date range
- **WHEN** the page loads
- **THEN** the date range SHALL default to the first and last day of the current month
#### Scenario: Hold Type radio default
- **WHEN** the page loads
- **THEN** the Hold Type filter SHALL default to "品質異常"
- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部
#### Scenario: Filter bar change reloads all data
- **WHEN** user changes the date range or Hold Type selection
- **THEN** all API calls (trend, reason-pareto, duration, department, list) SHALL reload with the new parameters
- **THEN** any active Reason Pareto filter SHALL be cleared
- **THEN** pagination SHALL reset to page 1
### Requirement: Hold History page SHALL display summary KPI cards
The page SHALL show 6 summary KPI cards derived from the trend data for the selected period.
#### Scenario: Summary cards rendering
- **WHEN** trend data is loaded
- **THEN** six cards SHALL display: Release 數量, New Hold 數量, Future Hold 數量, 淨變動, 期末 On Hold, 平均 Hold 時長
- **THEN** Release SHALL be displayed as a positive indicator (green)
- **THEN** New Hold and Future Hold SHALL be displayed as negative indicators (red/orange)
- **THEN** 淨變動 SHALL equal Release - New Hold - Future Hold
- **THEN** 期末 On Hold SHALL be the HOLDQTY of the last day in the selected range
- **THEN** number values SHALL use zh-TW number formatting
#### Scenario: Summary reflects filter bar only
- **WHEN** user clicks a Reason Pareto block
- **THEN** summary cards SHALL NOT change (they only respond to filter bar changes)
### Requirement: Hold History page SHALL display a Daily Trend chart
The page SHALL display a mixed line+bar chart showing daily hold stock and flow.
#### Scenario: Daily Trend chart rendering
- **WHEN** trend data is loaded
- **THEN** an ECharts mixed chart SHALL display with dual Y-axes
- **THEN** the left Y-axis SHALL show flow quantities (Release, New Hold, Future Hold)
- **THEN** the right Y-axis SHALL show HOLDQTY stock level
- **THEN** the X-axis SHALL show dates within the selected range
#### Scenario: Bar direction encoding
- **WHEN** daily trend bars are rendered
- **THEN** Release bars SHALL extend upward (positive direction, green color)
- **THEN** New Hold bars SHALL extend downward (negative direction, red color)
- **THEN** Future Hold bars SHALL extend downward (negative direction, orange color, stacked with New Hold)
- **THEN** HOLDQTY SHALL display as a line on the right Y-axis
#### Scenario: Hold Type switching without re-call
- **WHEN** user changes the Hold Type radio on the filter bar
- **THEN** if the date range has not changed, the trend chart SHALL update from locally cached data
- **THEN** no additional API call SHALL be made for the trend endpoint
#### Scenario: Daily Trend reflects filter bar only
- **WHEN** user clicks a Reason Pareto block
- **THEN** the Daily Trend chart SHALL NOT change (it only responds to filter bar changes)
### Requirement: Hold History page SHALL display a Reason Pareto chart
The page SHALL display a Pareto chart showing hold reason distribution.
#### Scenario: Reason Pareto rendering
- **WHEN** reason-pareto data is loaded
- **THEN** a Pareto chart SHALL display with bars (count per reason) and a cumulative percentage line
- **THEN** reasons SHALL be sorted by count descending
- **THEN** the cumulative line SHALL reach 100% at the rightmost bar
#### Scenario: Reason Pareto click filters downstream
- **WHEN** user clicks a reason bar in the Pareto chart
- **THEN** `reasonFilter` SHALL be set to the clicked reason name
- **THEN** Department table SHALL reload filtered by that reason
- **THEN** Detail table SHALL reload filtered by that reason
- **THEN** the clicked bar SHALL show a visual highlight
#### Scenario: Reason Pareto click toggle
- **WHEN** user clicks the same reason bar that is already active
- **THEN** `reasonFilter` SHALL be cleared
- **THEN** Department table and Detail table SHALL reload without reason filter
#### Scenario: Reason Pareto reflects filter bar only
- **WHEN** user clicks a reason bar
- **THEN** Summary KPIs, Daily Trend, and Duration chart SHALL NOT change
### Requirement: Hold History page SHALL display Hold Duration distribution
The page SHALL display a horizontal bar chart showing hold duration distribution.
#### Scenario: Duration chart rendering
- **WHEN** duration data is loaded
- **THEN** a horizontal bar chart SHALL display with 4 buckets: <4h, 4-24h, 1-3天, >3天
- **THEN** each bar SHALL show count and percentage
- **THEN** only released holds (RELEASETXNDATE IS NOT NULL) SHALL be included
#### Scenario: Duration reflects filter bar only
- **WHEN** user clicks a Reason Pareto block
- **THEN** the Duration chart SHALL NOT change (it only responds to filter bar changes)
### Requirement: Hold History page SHALL display Department statistics with expandable rows
The page SHALL display a table showing hold/release statistics per department, expandable to show individual persons.
#### Scenario: Department table rendering
- **WHEN** department data is loaded
- **THEN** a table SHALL display with columns: 部門, Hold 次數, Release 次數, 平均 Hold 時長(hr)
- **THEN** departments SHALL be sorted by Hold 次數 descending
- **THEN** each department row SHALL have an expand toggle
#### Scenario: Department row expansion
- **WHEN** user clicks the expand toggle on a department row
- **THEN** individual person rows SHALL display below the department row
- **THEN** person rows SHALL show: 人員名稱, Hold 次數, Release 次數, 平均 Hold 時長(hr)
#### Scenario: Department table responds to reason filter
- **WHEN** a Reason Pareto filter is active
- **THEN** department data SHALL reload filtered by the selected reason
- **THEN** only holds matching the reason SHALL be included in statistics
### Requirement: Hold History page SHALL display paginated Hold/Release detail list
The page SHALL display a detailed list of individual hold/release records with server-side pagination.
#### Scenario: Detail table columns
- **WHEN** detail data is loaded
- **THEN** a table SHALL display with columns: Lot ID, WorkOrder, 站別, Hold Reason, Hold 時間, Hold 人員, Hold Comment, Release 時間, Release 人員, Release Comment, 時長(hr), NCR
#### Scenario: Unreleased hold display
- **WHEN** a hold record has RELEASETXNDATE IS NULL
- **THEN** the Release 時間 column SHALL display "仍在 Hold"
- **THEN** the 時長 column SHALL display the duration from HOLDTXNDATE to current time
#### Scenario: Detail table pagination
- **WHEN** total records exceed per_page (50)
- **THEN** Prev/Next buttons and page info SHALL display
- **THEN** page info SHALL show "顯示 {start} - {end} / {total}"
#### Scenario: Detail table responds to reason filter
- **WHEN** a Reason Pareto filter is active
- **THEN** detail data SHALL reload filtered by the selected reason
- **THEN** pagination SHALL reset to page 1
#### Scenario: Filter changes reset pagination
- **WHEN** any filter changes (filter bar or Reason Pareto click)
- **THEN** pagination SHALL reset to page 1
### Requirement: Hold History page SHALL display active filter indicator
The page SHALL show a clear indicator when a Reason Pareto filter is active.
#### Scenario: Reason filter indicator
- **WHEN** a reason filter is active
- **THEN** a filter indicator SHALL display above the Department table section
- **THEN** the indicator SHALL show the active reason name
- **THEN** a clear button (✕) SHALL remove the reason filter
### Requirement: Hold History 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
### Requirement: Hold History page SHALL have navigation links
The page SHALL provide navigation to related pages.
#### Scenario: Back to Hold Overview
- **WHEN** user clicks the "← Hold Overview" button in the header
- **THEN** the page SHALL navigate to `/hold-overview`

View File

@@ -0,0 +1,45 @@
## 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, BarChart, LineChart) 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: Hold History entry point
- **WHEN** the hold-history page is added
- **THEN** `vite.config.js` input SHALL include `'hold-history': resolve(__dirname, 'src/hold-history/index.html')`
- **THEN** the build SHALL produce `hold-history.html`, `hold-history.js`, and `hold-history.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-history` imports `useAutoRefresh` from `wip-shared/`)
- **THEN** the composable SHALL be bundled into the importing page's JS output
- **THEN** cross-module imports SHALL NOT create unexpected shared chunks

View File

@@ -0,0 +1,66 @@
## 1. SQL 檔案建立
- [x] 1.1 建立 `src/mes_dashboard/sql/hold_history/` 目錄
- [x] 1.2 建立 `trend.sql` — calendar-spine cross-join 每日聚合查詢(翻譯 hold_history.md 邏輯,含 07:30 班別邊界、同日去重、Future Hold 去重、品質分類)
- [x] 1.3 建立 `reason_pareto.sql` — GROUP BY HOLDREASONNAME含 count/qty/pct/cumPct 計算
- [x] 1.4 建立 `duration.sql` — 已 release hold 的時長分布4 bucket: <4h, 4-24h, 1-3d, >3d
- [x] 1.5 建立 `department.sql` — GROUP BY HOLDEMPDEPTNAME / HOLDEMP含 hold/release 計數及平均時長
- [x] 1.6 建立 `list.sql` — paginated 明細查詢(含 HOLDCOMMENTS/RELEASECOMMENTS未 release 用 SYSDATE 計算時長)
## 2. 後端服務層
- [x] 2.1 建立 `src/mes_dashboard/services/hold_history_service.py`,實作 SQL 載入輔助函式(從 sql/hold_history/ 讀取 .sql 檔案)
- [x] 2.2 實作 `get_hold_history_trend(start_date, end_date)` — 執行 trend.sql回傳三種 hold_type 的每日聚合資料
- [x] 2.3 實作 trend Redis 快取邏輯 — 近二月快取key: `hold_history:daily:{YYYY-MM}`TTL 12hr跨月查詢拼接超過二月直接 Oracle
- [x] 2.4 實作 `get_hold_history_reason_pareto(start_date, end_date, hold_type)` — 執行 reason_pareto.sql
- [x] 2.5 實作 `get_hold_history_duration(start_date, end_date, hold_type)` — 執行 duration.sql
- [x] 2.6 實作 `get_hold_history_department(start_date, end_date, hold_type, reason=None)` — 執行 department.sql回傳部門層級含 persons 陣列
- [x] 2.7 實作 `get_hold_history_list(start_date, end_date, hold_type, reason=None, page=1, per_page=50)` — 執行 list.sql回傳 paginated 結果
## 3. 後端路由層
- [x] 3.1 建立 `src/mes_dashboard/routes/hold_history_routes.py` Flask Blueprint
- [x] 3.2 實作 `GET /hold-history` 頁面路由 — send_from_directory / fallback HTML
- [x] 3.3 實作 `GET /api/hold-history/trend` — 呼叫 servicerate limit 60/60s
- [x] 3.4 實作 `GET /api/hold-history/reason-pareto` — 呼叫 service
- [x] 3.5 實作 `GET /api/hold-history/duration` — 呼叫 service
- [x] 3.6 實作 `GET /api/hold-history/department` — 呼叫 service含 optional reason 參數
- [x] 3.7 實作 `GET /api/hold-history/list` — 呼叫 servicerate limit 90/60s含 optional reason 參數
- [x] 3.8 在 `routes/__init__.py` 註冊 hold_history_bp Blueprint
## 4. 頁面註冊與 Vite 配置
- [x] 4.1 在 `data/page_status.json` 新增 `/hold-history` 頁面status: dev, drawer: reports
- [x] 4.2 在 `frontend/vite.config.js` 新增 `'hold-history': resolve(__dirname, 'src/hold-history/index.html')` entry point
## 5. 前端頁面骨架
- [x] 5.1 建立 `frontend/src/hold-history/` 目錄結構index.html, main.js, App.vue, style.css
- [x] 5.2 實作 `App.vue` — 頁面主容器、狀態管理filterBar, reasonFilter, pagination、API 呼叫流程、cascade filter 邏輯
- [x] 5.3 實作 `FilterBar.vue` — DatePicker預設當月+ Hold Type radio品質異常/非品質異常/全部)
## 6. 前端元件 — KPI 與趨勢圖
- [x] 6.1 實作 `SummaryCards.vue` — 6 張 KPI 卡片Release, New Hold, Future Hold, 淨變動, 期末 On Hold, 平均時長Release 綠色正向、New/Future 紅/橙負向
- [x] 6.2 實作 `DailyTrend.vue` — ECharts 折線+柱狀混合圖,左 Y 軸增減量Release↑綠, New↓紅, Future↓橙 stacked右 Y 軸 On Hold 折線
## 7. 前端元件 — 分析圖表
- [x] 7.1 實作 `ReasonPareto.vue` — ECharts Pareto 圖(柱狀 count + 累積%折線),可點擊觸發 reasonFilter toggle
- [x] 7.2 實作 `DurationChart.vue` — ECharts 橫向柱狀圖(<4h, 4-24h, 1-3天, >3天顯示 count 和百分比
## 8. 前端元件 — 表格
- [x] 8.1 實作 `FilterIndicator.vue` — 顯示 active reason filter 及清除按鈕
- [x] 8.2 實作 `DepartmentTable.vue` — 部門統計表,可展開看個人層級,受 reasonFilter 篩選
- [x] 8.3 實作 `DetailTable.vue` — paginated 明細表12 欄位),未 release 顯示 "仍在 Hold",受 reasonFilter 篩選
## 9. 後端測試
- [x] 9.1 建立 `tests/test_hold_history_routes.py` — 測試頁面路由(含 admin session、5 支 API endpoint 參數傳遞、rate limiting、error handling
- [x] 9.2 建立 `tests/test_hold_history_service.py` — 測試 trend 快取邏輯cache hit/miss/cross-month、各 service function 的 Oracle 查詢與回傳格式、hold_type 分類、shift boundary 邏輯
## 10. 整合驗證
- [x] 10.1 執行既有測試確認無回歸test_hold_overview_routes, test_wip_service, test_page_registry
- [x] 10.2 驗證 vite build 成功產出 hold-history.html/js/css 且不影響既有 entry points

View File

@@ -0,0 +1,172 @@
## ADDED Requirements
### Requirement: Hold History API SHALL provide daily trend data with Redis caching
The API SHALL return daily aggregated hold/release metrics for the selected date range.
#### Scenario: Trend endpoint returns all three hold types
- **WHEN** `GET /api/hold-history/trend?start_date=2025-01-01&end_date=2025-01-31` is called
- **THEN** the response SHALL return `{ success: true, data: { days: [...] } }`
- **THEN** each day item SHALL contain `{ date, quality: { holdQty, newHoldQty, releaseQty, futureHoldQty }, non_quality: { ... }, all: { ... } }`
- **THEN** all three hold_type variants SHALL be included in a single response
#### Scenario: Trend uses shift boundary at 07:30
- **WHEN** daily aggregation is calculated
- **THEN** transactions with time >= 07:30 SHALL be attributed to the next calendar day
- **THEN** transactions with time < 07:30 SHALL be attributed to the current calendar day
#### Scenario: Trend deduplicates same-day multiple holds
- **WHEN** a lot is held multiple times on the same day
- **THEN** only one hold event SHALL be counted for that day (using ROW_NUMBER per CONTAINERID per day)
#### Scenario: Trend deduplicates future holds
- **WHEN** the same lot has multiple future holds for the same reason
- **THEN** only the first occurrence SHALL be counted (using ROW_NUMBER per CONTAINERID per HOLDREASONID)
#### Scenario: Trend hold type classification
- **WHEN** trend data is aggregated by hold type
- **THEN** quality classification SHALL use the same NON_QUALITY_HOLD_REASONS set as existing hold endpoints
- **THEN** holds with HOLDREASONNAME NOT in NON_QUALITY_HOLD_REASONS SHALL be classified as quality
- **THEN** the "all" variant SHALL include both quality and non-quality holds
#### Scenario: Trend Redis cache for recent two months
- **WHEN** the requested date range falls within the current month or previous month
- **THEN** the service SHALL check Redis for cached data at key `hold_history:daily:{YYYY-MM}`
- **THEN** if cache exists, data SHALL be returned from Redis
- **THEN** if cache is missing, data SHALL be queried from Oracle and stored in Redis with 12-hour TTL
#### Scenario: Trend direct Oracle query for older data
- **WHEN** the requested date range includes months older than the previous month
- **THEN** the service SHALL query Oracle directly without caching
#### Scenario: Trend cross-month query assembly
- **WHEN** the requested date range spans multiple months (e.g., 2025-01-15 to 2025-02-15)
- **THEN** the service SHALL fetch each month's data independently (from cache or Oracle)
- **THEN** the service SHALL trim the combined result to the exact requested date range
- **THEN** the response SHALL contain only days within start_date and end_date inclusive
#### Scenario: Trend error
- **WHEN** the database query fails
- **THEN** the response SHALL return `{ success: false, error: '查詢失敗' }` with HTTP 500
### Requirement: Hold History API SHALL provide reason Pareto data
The API SHALL return hold reason distribution for Pareto analysis.
#### Scenario: Reason Pareto endpoint
- **WHEN** `GET /api/hold-history/reason-pareto?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called
- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }`
- **THEN** each item SHALL contain `{ reason, count, qty, pct, cumPct }`
- **THEN** items SHALL be sorted by count descending
- **THEN** pct SHALL be percentage of total hold events
- **THEN** cumPct SHALL be running cumulative percentage
#### Scenario: Reason Pareto uses shift boundary
- **WHEN** hold events are counted for Pareto
- **THEN** the 07:30 shift boundary rule SHALL be applied to HOLDTXNDATE
#### Scenario: Reason Pareto hold type filter
- **WHEN** hold_type is "quality"
- **THEN** only quality hold reasons SHALL be included
- **WHEN** hold_type is "non-quality"
- **THEN** only non-quality hold reasons SHALL be included
- **WHEN** hold_type is "all"
- **THEN** all hold reasons SHALL be included
### Requirement: Hold History API SHALL provide hold duration distribution
The API SHALL return hold duration distribution buckets.
#### Scenario: Duration endpoint
- **WHEN** `GET /api/hold-history/duration?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called
- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }`
- **THEN** items SHALL contain 4 buckets: `{ range: "<4h", count, pct }`, `{ range: "4-24h", count, pct }`, `{ range: "1-3d", count, pct }`, `{ range: ">3d", count, pct }`
#### Scenario: Duration only includes released holds
- **WHEN** duration is calculated
- **THEN** only hold records with RELEASETXNDATE IS NOT NULL SHALL be included
- **THEN** duration SHALL be calculated as RELEASETXNDATE - HOLDTXNDATE
#### Scenario: Duration date range filter
- **WHEN** start_date and end_date are provided
- **THEN** only holds with HOLDTXNDATE within the date range (applying 07:30 shift boundary) SHALL be included
### Requirement: Hold History API SHALL provide department statistics
The API SHALL return hold/release statistics aggregated by department with optional person detail.
#### Scenario: Department endpoint
- **WHEN** `GET /api/hold-history/department?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality` is called
- **THEN** the response SHALL return `{ success: true, data: { items: [...] } }`
- **THEN** each item SHALL contain `{ dept, holdCount, releaseCount, avgHoldHours, persons: [{ name, holdCount, releaseCount, avgHoldHours }] }`
- **THEN** items SHALL be sorted by holdCount descending
#### Scenario: Department with reason filter
- **WHEN** `GET /api/hold-history/department?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&reason=品質確認` is called
- **THEN** only hold records matching the specified reason SHALL be included in department and person statistics
#### Scenario: Department hold count vs release count
- **WHEN** department statistics are calculated
- **THEN** holdCount SHALL count records where HOLDEMPDEPTNAME equals the department AND HOLDTXNDATE is within the date range
- **THEN** releaseCount SHALL count records where RELEASEEMPDEPTNAME equals the department AND RELEASETXNDATE is within the date range
- **THEN** avgHoldHours SHALL be the average of (RELEASETXNDATE - HOLDTXNDATE) in hours for released holds initiated by that department
### Requirement: Hold History API SHALL provide paginated detail list
The API SHALL return a paginated list of individual hold/release records.
#### Scenario: List endpoint
- **WHEN** `GET /api/hold-history/list?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&page=1&per_page=50` is called
- **THEN** the response SHALL return `{ success: true, data: { items: [...], pagination: { page, perPage, total, totalPages } } }`
- **THEN** each item SHALL contain: lotId, workorder, workcenter, holdReason, holdDate, holdEmp, holdComment, releaseDate, releaseEmp, releaseComment, holdHours, ncr
- **THEN** items SHALL be sorted by HOLDTXNDATE descending
#### Scenario: List with reason filter
- **WHEN** `GET /api/hold-history/list?start_date=2025-01-01&end_date=2025-01-31&hold_type=quality&reason=品質確認` is called
- **THEN** only records matching the specified HOLDREASONNAME SHALL be returned
#### Scenario: List unreleased hold records
- **WHEN** a hold record has RELEASETXNDATE IS NULL
- **THEN** releaseDate SHALL be null
- **THEN** holdHours SHALL be calculated as (SYSDATE - HOLDTXNDATE) * 24
#### Scenario: List 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: List date range uses shift boundary
- **WHEN** records are filtered by date range
- **THEN** the 07:30 shift boundary rule SHALL be applied to HOLDTXNDATE
### Requirement: Hold History API SHALL use centralized SQL files
The API SHALL load SQL queries from files in the `src/mes_dashboard/sql/hold_history/` directory.
#### Scenario: SQL file organization
- **WHEN** the hold history service executes a query
- **THEN** the SQL SHALL be loaded from `sql/hold_history/<query_name>.sql`
- **THEN** the following SQL files SHALL exist: `trend.sql`, `reason_pareto.sql`, `duration.sql`, `department.sql`, `list.sql`
#### Scenario: SQL parameterization
- **WHEN** SQL queries are executed
- **THEN** all user-provided parameters (dates, hold_type, reason) SHALL be passed as bind parameters
- **THEN** no string interpolation SHALL be used for user input
### Requirement: Hold History API SHALL apply rate limiting
The API SHALL apply rate limiting to expensive endpoints.
#### Scenario: Rate limit on list endpoint
- **WHEN** the list 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 trend endpoint
- **WHEN** the trend endpoint receives excessive requests
- **THEN** rate limiting SHALL be applied using `configured_rate_limit` with a default of 60 requests per 60 seconds
### Requirement: Hold History 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-history`
- **THEN** Flask SHALL serve the pre-built HTML file from `static/dist/hold-history.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

View File

@@ -0,0 +1,172 @@
## ADDED Requirements
### Requirement: Hold History page SHALL display a filter bar with date range and hold type
The page SHALL provide a filter bar for selecting date range and hold type classification.
#### Scenario: Default date range
- **WHEN** the page loads
- **THEN** the date range SHALL default to the first and last day of the current month
#### Scenario: Hold Type radio default
- **WHEN** the page loads
- **THEN** the Hold Type filter SHALL default to "品質異常"
- **THEN** three radio options SHALL display: 品質異常, 非品質異常, 全部
#### Scenario: Filter bar change reloads all data
- **WHEN** user changes the date range or Hold Type selection
- **THEN** all API calls (trend, reason-pareto, duration, department, list) SHALL reload with the new parameters
- **THEN** any active Reason Pareto filter SHALL be cleared
- **THEN** pagination SHALL reset to page 1
### Requirement: Hold History page SHALL display summary KPI cards
The page SHALL show 6 summary KPI cards derived from the trend data for the selected period.
#### Scenario: Summary cards rendering
- **WHEN** trend data is loaded
- **THEN** six cards SHALL display: Release 數量, New Hold 數量, Future Hold 數量, 淨變動, 期末 On Hold, 平均 Hold 時長
- **THEN** Release SHALL be displayed as a positive indicator (green)
- **THEN** New Hold and Future Hold SHALL be displayed as negative indicators (red/orange)
- **THEN** 淨變動 SHALL equal Release - New Hold - Future Hold
- **THEN** 期末 On Hold SHALL be the HOLDQTY of the last day in the selected range
- **THEN** number values SHALL use zh-TW number formatting
#### Scenario: Summary reflects filter bar only
- **WHEN** user clicks a Reason Pareto block
- **THEN** summary cards SHALL NOT change (they only respond to filter bar changes)
### Requirement: Hold History page SHALL display a Daily Trend chart
The page SHALL display a mixed line+bar chart showing daily hold stock and flow.
#### Scenario: Daily Trend chart rendering
- **WHEN** trend data is loaded
- **THEN** an ECharts mixed chart SHALL display with dual Y-axes
- **THEN** the left Y-axis SHALL show flow quantities (Release, New Hold, Future Hold)
- **THEN** the right Y-axis SHALL show HOLDQTY stock level
- **THEN** the X-axis SHALL show dates within the selected range
#### Scenario: Bar direction encoding
- **WHEN** daily trend bars are rendered
- **THEN** Release bars SHALL extend upward (positive direction, green color)
- **THEN** New Hold bars SHALL extend downward (negative direction, red color)
- **THEN** Future Hold bars SHALL extend downward (negative direction, orange color, stacked with New Hold)
- **THEN** HOLDQTY SHALL display as a line on the right Y-axis
#### Scenario: Hold Type switching without re-call
- **WHEN** user changes the Hold Type radio on the filter bar
- **THEN** if the date range has not changed, the trend chart SHALL update from locally cached data
- **THEN** no additional API call SHALL be made for the trend endpoint
#### Scenario: Daily Trend reflects filter bar only
- **WHEN** user clicks a Reason Pareto block
- **THEN** the Daily Trend chart SHALL NOT change (it only responds to filter bar changes)
### Requirement: Hold History page SHALL display a Reason Pareto chart
The page SHALL display a Pareto chart showing hold reason distribution.
#### Scenario: Reason Pareto rendering
- **WHEN** reason-pareto data is loaded
- **THEN** a Pareto chart SHALL display with bars (count per reason) and a cumulative percentage line
- **THEN** reasons SHALL be sorted by count descending
- **THEN** the cumulative line SHALL reach 100% at the rightmost bar
#### Scenario: Reason Pareto click filters downstream
- **WHEN** user clicks a reason bar in the Pareto chart
- **THEN** `reasonFilter` SHALL be set to the clicked reason name
- **THEN** Department table SHALL reload filtered by that reason
- **THEN** Detail table SHALL reload filtered by that reason
- **THEN** the clicked bar SHALL show a visual highlight
#### Scenario: Reason Pareto click toggle
- **WHEN** user clicks the same reason bar that is already active
- **THEN** `reasonFilter` SHALL be cleared
- **THEN** Department table and Detail table SHALL reload without reason filter
#### Scenario: Reason Pareto reflects filter bar only
- **WHEN** user clicks a reason bar
- **THEN** Summary KPIs, Daily Trend, and Duration chart SHALL NOT change
### Requirement: Hold History page SHALL display Hold Duration distribution
The page SHALL display a horizontal bar chart showing hold duration distribution.
#### Scenario: Duration chart rendering
- **WHEN** duration data is loaded
- **THEN** a horizontal bar chart SHALL display with 4 buckets: <4h, 4-24h, 1-3天, >3天
- **THEN** each bar SHALL show count and percentage
- **THEN** only released holds (RELEASETXNDATE IS NOT NULL) SHALL be included
#### Scenario: Duration reflects filter bar only
- **WHEN** user clicks a Reason Pareto block
- **THEN** the Duration chart SHALL NOT change (it only responds to filter bar changes)
### Requirement: Hold History page SHALL display Department statistics with expandable rows
The page SHALL display a table showing hold/release statistics per department, expandable to show individual persons.
#### Scenario: Department table rendering
- **WHEN** department data is loaded
- **THEN** a table SHALL display with columns: 部門, Hold 次數, Release 次數, 平均 Hold 時長(hr)
- **THEN** departments SHALL be sorted by Hold 次數 descending
- **THEN** each department row SHALL have an expand toggle
#### Scenario: Department row expansion
- **WHEN** user clicks the expand toggle on a department row
- **THEN** individual person rows SHALL display below the department row
- **THEN** person rows SHALL show: 人員名稱, Hold 次數, Release 次數, 平均 Hold 時長(hr)
#### Scenario: Department table responds to reason filter
- **WHEN** a Reason Pareto filter is active
- **THEN** department data SHALL reload filtered by the selected reason
- **THEN** only holds matching the reason SHALL be included in statistics
### Requirement: Hold History page SHALL display paginated Hold/Release detail list
The page SHALL display a detailed list of individual hold/release records with server-side pagination.
#### Scenario: Detail table columns
- **WHEN** detail data is loaded
- **THEN** a table SHALL display with columns: Lot ID, WorkOrder, 站別, Hold Reason, Hold 時間, Hold 人員, Hold Comment, Release 時間, Release 人員, Release Comment, 時長(hr), NCR
#### Scenario: Unreleased hold display
- **WHEN** a hold record has RELEASETXNDATE IS NULL
- **THEN** the Release 時間 column SHALL display "仍在 Hold"
- **THEN** the 時長 column SHALL display the duration from HOLDTXNDATE to current time
#### Scenario: Detail table pagination
- **WHEN** total records exceed per_page (50)
- **THEN** Prev/Next buttons and page info SHALL display
- **THEN** page info SHALL show "顯示 {start} - {end} / {total}"
#### Scenario: Detail table responds to reason filter
- **WHEN** a Reason Pareto filter is active
- **THEN** detail data SHALL reload filtered by the selected reason
- **THEN** pagination SHALL reset to page 1
#### Scenario: Filter changes reset pagination
- **WHEN** any filter changes (filter bar or Reason Pareto click)
- **THEN** pagination SHALL reset to page 1
### Requirement: Hold History page SHALL display active filter indicator
The page SHALL show a clear indicator when a Reason Pareto filter is active.
#### Scenario: Reason filter indicator
- **WHEN** a reason filter is active
- **THEN** a filter indicator SHALL display above the Department table section
- **THEN** the indicator SHALL show the active reason name
- **THEN** a clear button (✕) SHALL remove the reason filter
### Requirement: Hold History 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
### Requirement: Hold History page SHALL have navigation links
The page SHALL provide navigation to related pages.
#### Scenario: Back to Hold Overview
- **WHEN** user clicks the "← Hold Overview" button in the header
- **THEN** the page SHALL navigate to `/hold-overview`

View File

@@ -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 (including TreemapChart) SHALL be split into the existing `vendor-echarts` chunk
- **THEN** ECharts modules (including TreemapChart, BarChart, LineChart) SHALL be split into the existing `vendor-echarts` chunk
- **THEN** chunk splitting SHALL NOT affect existing page bundles
#### Scenario: Migrated page entry replacement
@@ -46,13 +46,18 @@ The Vite build configuration SHALL support Vue Single File Components alongside
- **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: Hold History entry point
- **WHEN** the hold-history page is added
- **THEN** `vite.config.js` input SHALL include `'hold-history': resolve(__dirname, 'src/hold-history/index.html')`
- **THEN** the build SHALL produce `hold-history.html`, `hold-history.js`, and `hold-history.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/`)
- **WHEN** a migrated page imports a composable from another shared module (e.g., `hold-history` imports `useAutoRefresh` from `wip-shared/`)
- **THEN** the composable SHALL be bundled into the importing page's JS output
- **THEN** cross-module imports SHALL NOT create unexpected shared chunks

View File

@@ -10,6 +10,7 @@ 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 .hold_history_routes import hold_history_bp
from .auth_routes import auth_bp
from .admin_routes import admin_bp
from .resource_history_routes import resource_history_bp
@@ -28,6 +29,7 @@ def register_routes(app) -> None:
app.register_blueprint(excel_query_bp)
app.register_blueprint(hold_bp)
app.register_blueprint(hold_overview_bp)
app.register_blueprint(hold_history_bp)
app.register_blueprint(resource_history_bp)
app.register_blueprint(job_query_bp)
app.register_blueprint(query_tool_bp)
@@ -42,6 +44,7 @@ __all__ = [
'excel_query_bp',
'hold_bp',
'hold_overview_bp',
'hold_history_bp',
'auth_bp',
'admin_bp',
'resource_history_bp',

View File

@@ -0,0 +1,212 @@
# -*- coding: utf-8 -*-
"""Hold History page route and API endpoints."""
from __future__ import annotations
import os
from datetime import datetime
from typing import Optional, Tuple
from flask import Blueprint, current_app, jsonify, request, send_from_directory
from mes_dashboard.core.rate_limit import configured_rate_limit
from mes_dashboard.services.hold_history_service import (
get_hold_history_duration,
get_hold_history_list,
get_hold_history_reason_pareto,
get_hold_history_trend,
get_still_on_hold_count,
)
hold_history_bp = Blueprint('hold_history', __name__)
_HOLD_HISTORY_TREND_RATE_LIMIT = configured_rate_limit(
bucket='hold-history-trend',
max_attempts_env='HOLD_HISTORY_TREND_RATE_LIMIT_MAX_REQUESTS',
window_seconds_env='HOLD_HISTORY_TREND_RATE_LIMIT_WINDOW_SECONDS',
default_max_attempts=60,
default_window_seconds=60,
)
_HOLD_HISTORY_LIST_RATE_LIMIT = configured_rate_limit(
bucket='hold-history-list',
max_attempts_env='HOLD_HISTORY_LIST_RATE_LIMIT_MAX_REQUESTS',
window_seconds_env='HOLD_HISTORY_LIST_RATE_LIMIT_WINDOW_SECONDS',
default_max_attempts=90,
default_window_seconds=60,
)
_VALID_HOLD_TYPES = {'quality', 'non-quality', 'all'}
_VALID_RECORD_TYPES = {'new', 'on_hold', 'released'}
_VALID_DURATION_RANGES = {'<4h', '4-24h', '1-3d', '>3d'}
def _parse_date_range() -> tuple[Optional[str], Optional[str], Optional[tuple[dict, int]]]:
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
if not start_date or not end_date:
return None, None, ({'success': False, 'error': '缺少必要參數: start_date, end_date'}, 400)
try:
start = datetime.strptime(start_date, '%Y-%m-%d').date()
end = datetime.strptime(end_date, '%Y-%m-%d').date()
except ValueError:
return None, None, ({'success': False, 'error': '日期格式錯誤,請使用 YYYY-MM-DD'}, 400)
if end < start:
return None, None, ({'success': False, 'error': 'end_date 不可早於 start_date'}, 400)
return start_date, end_date, None
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,
)
return hold_type, None
def _parse_record_type(default: str = 'new') -> tuple[Optional[str], Optional[tuple[dict, int]]]:
raw = request.args.get('record_type', '').strip().lower()
record_type = raw or default
parts = [p.strip() for p in record_type.split(',') if p.strip()]
if not parts:
parts = [default]
for part in parts:
if part not in _VALID_RECORD_TYPES:
return None, (
{'success': False, 'error': 'Invalid record_type. Use new, on_hold, or released'},
400,
)
return ','.join(parts), None
@hold_history_bp.route('/hold-history')
def hold_history_page():
"""Render Hold History page from static Vite output."""
dist_dir = os.path.join(current_app.static_folder or '', 'dist')
dist_html = os.path.join(dist_dir, 'hold-history.html')
if os.path.exists(dist_html):
return send_from_directory(dist_dir, 'hold-history.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 History</title>'
'<script type="module" src="/static/dist/hold-history.js"></script>'
'</head><body><div id="app"></div></body></html>',
200,
)
@hold_history_bp.route('/api/hold-history/trend')
@_HOLD_HISTORY_TREND_RATE_LIMIT
def api_hold_history_trend():
"""Return daily hold history trend data."""
start_date, end_date, date_error = _parse_date_range()
if date_error:
return jsonify(date_error[0]), date_error[1]
result = get_hold_history_trend(start_date, end_date)
if result is not None:
count = get_still_on_hold_count()
if count is not None:
result['stillOnHoldCount'] = count
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@hold_history_bp.route('/api/hold-history/reason-pareto')
def api_hold_history_reason_pareto():
"""Return hold reason Pareto data."""
start_date, end_date, date_error = _parse_date_range()
if date_error:
return jsonify(date_error[0]), date_error[1]
hold_type, hold_type_error = _parse_hold_type(default='quality')
if hold_type_error:
return jsonify(hold_type_error[0]), hold_type_error[1]
record_type, record_type_error = _parse_record_type()
if record_type_error:
return jsonify(record_type_error[0]), record_type_error[1]
result = get_hold_history_reason_pareto(start_date, end_date, hold_type, record_type)
if result is not None:
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@hold_history_bp.route('/api/hold-history/duration')
def api_hold_history_duration():
"""Return hold duration distribution data."""
start_date, end_date, date_error = _parse_date_range()
if date_error:
return jsonify(date_error[0]), date_error[1]
hold_type, hold_type_error = _parse_hold_type(default='quality')
if hold_type_error:
return jsonify(hold_type_error[0]), hold_type_error[1]
record_type, record_type_error = _parse_record_type()
if record_type_error:
return jsonify(record_type_error[0]), record_type_error[1]
result = get_hold_history_duration(start_date, end_date, hold_type, record_type)
if result is not None:
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@hold_history_bp.route('/api/hold-history/list')
@_HOLD_HISTORY_LIST_RATE_LIMIT
def api_hold_history_list():
"""Return paginated hold detail list."""
start_date, end_date, date_error = _parse_date_range()
if date_error:
return jsonify(date_error[0]), date_error[1]
hold_type, hold_type_error = _parse_hold_type(default='quality')
if hold_type_error:
return jsonify(hold_type_error[0]), hold_type_error[1]
record_type, record_type_error = _parse_record_type()
if record_type_error:
return jsonify(record_type_error[0]), record_type_error[1]
reason = request.args.get('reason', '').strip() or None
raw_duration = request.args.get('duration_range', '').strip() or None
if raw_duration and raw_duration not in _VALID_DURATION_RANGES:
return jsonify({'success': False, 'error': 'Invalid duration_range'}), 400
duration_range = raw_duration
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
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_history_list(
start_date=start_date,
end_date=end_date,
hold_type=hold_type,
reason=reason,
record_type=record_type,
duration_range=duration_range,
page=page,
per_page=per_page,
)
if result is not None:
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '查詢失敗'}), 500

View File

@@ -0,0 +1,507 @@
# -*- coding: utf-8 -*-
"""Hold History dashboard service layer."""
from __future__ import annotations
import json
import logging
from datetime import date, datetime, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, Iterator, Optional
import pandas as pd
from mes_dashboard.core.database import (
DatabaseCircuitOpenError,
DatabasePoolExhaustedError,
read_sql_df,
)
from mes_dashboard.core.redis_client import get_key, get_redis_client
from mes_dashboard.services.filter_cache import get_workcenter_group as _get_wc_group
from mes_dashboard.sql.filters import CommonFilters
logger = logging.getLogger('mes_dashboard.hold_history_service')
_SQL_DIR = Path(__file__).resolve().parent.parent / 'sql' / 'hold_history'
_VALID_HOLD_TYPES = {'quality', 'non-quality', 'all'}
_TREND_CACHE_TTL_SECONDS = 12 * 60 * 60
_TREND_CACHE_KEY_PREFIX = 'hold_history:daily'
@lru_cache(maxsize=16)
def _load_hold_history_sql(name: str) -> str:
"""Load hold history SQL by file name without extension."""
path = _SQL_DIR / f'{name}.sql'
if not path.exists():
raise FileNotFoundError(f'SQL file not found: {path}')
sql = path.read_text(encoding='utf-8')
if '{{ NON_QUALITY_REASONS }}' in sql:
sql = sql.replace('{{ NON_QUALITY_REASONS }}', CommonFilters.get_non_quality_reasons_sql())
return sql
def _parse_iso_date(value: str) -> date:
return datetime.strptime(str(value), '%Y-%m-%d').date()
def _format_iso_date(value: date) -> str:
return value.strftime('%Y-%m-%d')
def _iter_days(start: date, end: date) -> Iterator[date]:
current = start
while current <= end:
yield current
current += timedelta(days=1)
def _iter_month_starts(start: date, end: date) -> Iterator[date]:
current = start.replace(day=1)
while current <= end:
yield current
current = (current.replace(day=28) + timedelta(days=4)).replace(day=1)
def _month_end(month_start: date) -> date:
next_month_start = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
return next_month_start - timedelta(days=1)
def _is_cacheable_month(month_start: date, today: Optional[date] = None) -> bool:
current = (today or date.today()).replace(day=1)
previous = (current - timedelta(days=1)).replace(day=1)
return month_start in {current, previous}
def _trend_cache_key(month_start: date) -> str:
return get_key(f'{_TREND_CACHE_KEY_PREFIX}:{month_start.strftime("%Y-%m")}')
def _normalize_hold_type(hold_type: Optional[str], default: str = 'quality') -> str:
normalized = str(hold_type or default).strip().lower()
if normalized not in _VALID_HOLD_TYPES:
return default
return normalized
def _record_type_flags(record_type: Any) -> Dict[str, int]:
"""Convert record_type value(s) to SQL boolean flags."""
if isinstance(record_type, (list, tuple, set)):
types = {str(t).strip().lower() for t in record_type}
else:
types = {t.strip().lower() for t in str(record_type or 'new').split(',')}
return {
'include_new': 1 if 'new' in types else 0,
'include_on_hold': 1 if 'on_hold' in types else 0,
'include_released': 1 if 'released' in types else 0,
}
def _safe_int(value: Any) -> int:
if value is None:
return 0
try:
if pd.isna(value):
return 0
except Exception:
pass
try:
return int(float(value))
except (TypeError, ValueError):
return 0
def _safe_float(value: Any) -> float:
if value is None:
return 0.0
try:
if pd.isna(value):
return 0.0
except Exception:
pass
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _clean_text(value: Any) -> Optional[str]:
if value is None:
return None
try:
if pd.isna(value):
return None
except Exception:
pass
text = str(value).strip()
return text or None
def _format_datetime(value: Any) -> Optional[str]:
if value is None:
return None
try:
if pd.isna(value):
return None
except Exception:
pass
if isinstance(value, datetime):
dt = value
elif isinstance(value, pd.Timestamp):
dt = value.to_pydatetime()
elif isinstance(value, str):
text = value.strip()
if not text:
return None
try:
dt = pd.to_datetime(text).to_pydatetime()
except Exception:
return text
else:
try:
dt = pd.to_datetime(value).to_pydatetime()
except Exception:
return str(value)
return dt.strftime('%Y-%m-%d %H:%M:%S')
def _empty_trend_metrics() -> Dict[str, int]:
return {
'holdQty': 0,
'newHoldQty': 0,
'releaseQty': 0,
'futureHoldQty': 0,
}
def _empty_trend_day(day: str) -> Dict[str, Any]:
return {
'date': day,
'quality': _empty_trend_metrics(),
'non_quality': _empty_trend_metrics(),
'all': _empty_trend_metrics(),
}
def _normalize_trend_day(payload: Dict[str, Any], fallback_day: Optional[str] = None) -> Dict[str, Any]:
day = str(payload.get('date') or fallback_day or '').strip()
normalized = _empty_trend_day(day)
for source_key, target_key in (
('quality', 'quality'),
('non_quality', 'non_quality'),
('non-quality', 'non_quality'),
('all', 'all'),
):
section = payload.get(source_key)
if not isinstance(section, dict):
continue
normalized[target_key] = {
'holdQty': _safe_int(section.get('holdQty')),
'newHoldQty': _safe_int(section.get('newHoldQty')),
'releaseQty': _safe_int(section.get('releaseQty')),
'futureHoldQty': _safe_int(section.get('futureHoldQty')),
}
return normalized
def _build_month_trend_from_df(df: pd.DataFrame) -> list[Dict[str, Any]]:
if df is None or df.empty:
return []
day_map: Dict[str, Dict[str, Any]] = {}
for _, row in df.iterrows():
day = str(row.get('TXN_DATE') or '').strip()
if not day:
continue
if day not in day_map:
day_map[day] = _empty_trend_day(day)
hold_type = str(row.get('HOLD_TYPE') or '').strip().lower()
if hold_type == 'non-quality':
target_key = 'non_quality'
elif hold_type in {'quality', 'all'}:
target_key = hold_type
else:
continue
day_map[day][target_key] = {
'holdQty': _safe_int(row.get('HOLD_QTY')),
'newHoldQty': _safe_int(row.get('NEW_HOLD_QTY')),
'releaseQty': _safe_int(row.get('RELEASE_QTY')),
'futureHoldQty': _safe_int(row.get('FUTURE_HOLD_QTY')),
}
return [day_map[key] for key in sorted(day_map)]
def _query_month_trend(month_start: date) -> list[Dict[str, Any]]:
month_end = _month_end(month_start)
sql = _load_hold_history_sql('trend')
params = {
'start_date': _format_iso_date(month_start),
'end_date': _format_iso_date(month_end),
}
df = read_sql_df(sql, params)
return _build_month_trend_from_df(df)
def _get_month_trend_cache(month_start: date) -> Optional[list[Dict[str, Any]]]:
client = get_redis_client()
if client is None:
return None
key = _trend_cache_key(month_start)
try:
payload = client.get(key)
if not payload:
return None
decoded = json.loads(payload)
if not isinstance(decoded, list):
return None
items: list[Dict[str, Any]] = []
for item in decoded:
if not isinstance(item, dict):
continue
normalized = _normalize_trend_day(item)
if normalized.get('date'):
items.append(normalized)
if not items:
return None
return items
except Exception as exc:
logger.warning('Failed reading hold-history trend cache key %s: %s', key, exc)
return None
def _set_month_trend_cache(month_start: date, items: list[Dict[str, Any]]) -> None:
client = get_redis_client()
if client is None:
return
key = _trend_cache_key(month_start)
try:
client.setex(
key,
_TREND_CACHE_TTL_SECONDS,
json.dumps(items, ensure_ascii=False),
)
except Exception as exc:
logger.warning('Failed writing hold-history trend cache key %s: %s', key, exc)
def _get_month_trend_data(month_start: date) -> list[Dict[str, Any]]:
if _is_cacheable_month(month_start):
cached = _get_month_trend_cache(month_start)
if cached is not None:
return cached
queried = _query_month_trend(month_start)
_set_month_trend_cache(month_start, queried)
return queried
return _query_month_trend(month_start)
def get_hold_history_trend(start_date: str, end_date: str) -> Optional[Dict[str, Any]]:
"""Get daily trend data for all hold-type variants."""
try:
start = _parse_iso_date(start_date)
end = _parse_iso_date(end_date)
if end < start:
return {'days': []}
day_map: Dict[str, Dict[str, Any]] = {}
for month_start in _iter_month_starts(start, end):
month_days = _get_month_trend_data(month_start)
for item in month_days:
normalized = _normalize_trend_day(item)
day = normalized.get('date')
if day:
day_map[day] = normalized
days: list[Dict[str, Any]] = []
for current in _iter_days(start, end):
current_key = _format_iso_date(current)
days.append(day_map.get(current_key, _empty_trend_day(current_key)))
return {'days': days}
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise
except Exception as exc:
logger.error('Hold history trend query failed: %s', exc)
return None
def get_still_on_hold_count() -> Optional[Dict[str, int]]:
"""Count all holds that are still unreleased (factory-wide), by hold type."""
try:
sql = _load_hold_history_sql('still_on_hold_count')
df = read_sql_df(sql, {})
if df is None or df.empty:
return {'quality': 0, 'non_quality': 0, 'all': 0}
row = df.iloc[0]
return {
'quality': _safe_int(row.get('QUALITY_COUNT')),
'non_quality': _safe_int(row.get('NON_QUALITY_COUNT')),
'all': _safe_int(row.get('ALL_COUNT')),
}
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise
except Exception as exc:
logger.error('Hold history still-on-hold count query failed: %s', exc)
return None
def get_hold_history_reason_pareto(
start_date: str,
end_date: str,
hold_type: str,
record_type: str = 'new',
) -> Optional[Dict[str, Any]]:
"""Get reason Pareto items."""
try:
sql = _load_hold_history_sql('reason_pareto')
params = {
'start_date': start_date,
'end_date': end_date,
'hold_type': _normalize_hold_type(hold_type),
**_record_type_flags(record_type),
}
df = read_sql_df(sql, params)
items: list[Dict[str, Any]] = []
if df is not None and not df.empty:
for _, row in df.iterrows():
items.append({
'reason': _clean_text(row.get('REASON')) or '(未填寫)',
'count': _safe_int(row.get('ITEM_COUNT')),
'qty': _safe_int(row.get('QTY')),
'pct': round(_safe_float(row.get('PCT')), 2),
'cumPct': round(_safe_float(row.get('CUM_PCT')), 2),
})
return {'items': items}
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise
except Exception as exc:
logger.error('Hold history reason pareto query failed: %s', exc)
return None
def get_hold_history_duration(
start_date: str,
end_date: str,
hold_type: str,
record_type: str = 'new',
) -> Optional[Dict[str, Any]]:
"""Get hold duration distribution buckets."""
try:
sql = _load_hold_history_sql('duration')
params = {
'start_date': start_date,
'end_date': end_date,
'hold_type': _normalize_hold_type(hold_type),
**_record_type_flags(record_type),
}
df = read_sql_df(sql, params)
items: list[Dict[str, Any]] = []
if df is not None and not df.empty:
for _, row in df.iterrows():
items.append({
'range': _clean_text(row.get('RANGE_LABEL')) or '-',
'count': _safe_int(row.get('ITEM_COUNT')),
'qty': _safe_int(row.get('QTY')),
'pct': round(_safe_float(row.get('PCT')), 2),
})
return {'items': items}
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise
except Exception as exc:
logger.error('Hold history duration query failed: %s', exc)
return None
def get_hold_history_list(
start_date: str,
end_date: str,
hold_type: str,
reason: Optional[str] = None,
record_type: str = 'new',
duration_range: Optional[str] = None,
page: int = 1,
per_page: int = 50,
) -> Optional[Dict[str, Any]]:
"""Get paginated hold history detail list."""
try:
page = max(int(page or 1), 1)
per_page = max(1, min(int(per_page or 50), 200))
offset = (page - 1) * per_page
sql = _load_hold_history_sql('list')
params = {
'start_date': start_date,
'end_date': end_date,
'hold_type': _normalize_hold_type(hold_type),
'reason': reason,
**_record_type_flags(record_type),
'duration_range': duration_range,
'offset': offset,
'limit': per_page,
}
df = read_sql_df(sql, params)
items: list[Dict[str, Any]] = []
total = 0
if df is not None and not df.empty:
for _, row in df.iterrows():
if total == 0:
total = _safe_int(row.get('TOTAL_COUNT'))
wc_name = _clean_text(row.get('WORKCENTER'))
wc_group = _get_wc_group(wc_name) if wc_name else None
items.append({
'lotId': _clean_text(row.get('LOT_ID')),
'workorder': _clean_text(row.get('WORKORDER')),
'workcenter': wc_group or wc_name,
'holdReason': _clean_text(row.get('HOLD_REASON')),
'qty': _safe_int(row.get('QTY')),
'holdDate': _format_datetime(row.get('HOLD_DATE')),
'holdEmp': _clean_text(row.get('HOLD_EMP')),
'holdComment': _clean_text(row.get('HOLD_COMMENT')),
'releaseDate': _format_datetime(row.get('RELEASE_DATE')),
'releaseEmp': _clean_text(row.get('RELEASE_EMP')),
'releaseComment': _clean_text(row.get('RELEASE_COMMENT')),
'holdHours': round(_safe_float(row.get('HOLD_HOURS')), 2),
'ncr': _clean_text(row.get('NCR_ID')),
})
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
return {
'items': items,
'pagination': {
'page': page,
'perPage': per_page,
'total': total,
'totalPages': total_pages,
},
}
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise
except Exception as exc:
logger.error('Hold history list query failed: %s', exc)
return None

View File

@@ -2651,6 +2651,7 @@ def get_hold_detail_lots(
'lotId': _safe_value(row.get('LOTID')),
'workorder': _safe_value(row.get('WORKORDER')),
'qty': int(row.get('QTY', 0) or 0),
'product': _safe_value(row.get('PRODUCT')),
'package': _safe_value(row.get('PACKAGE_LEF')),
'workcenter': _safe_value(row.get('WORKCENTER_GROUP')),
'holdReason': _safe_value(row.get('HOLDREASONNAME')),
@@ -2658,7 +2659,8 @@ def get_hold_detail_lots(
'age': round(float(row.get('AGEBYDAYS', 0) or 0), 1),
'holdBy': _safe_value(row.get('HOLDEMP')),
'dept': _safe_value(row.get('DEPTNAME')),
'holdComment': _safe_value(row.get('COMMENT_HOLD'))
'holdComment': _safe_value(row.get('COMMENT_HOLD')),
'futureHoldComment': _safe_value(row.get('COMMENT_FUTURE')),
})
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
@@ -2760,6 +2762,7 @@ def _get_hold_detail_lots_from_oracle(
LOTID,
WORKORDER,
QTY,
PRODUCT,
PACKAGE_LEF AS PACKAGE,
WORKCENTER_GROUP AS WORKCENTER,
HOLDREASONNAME AS HOLD_REASON,
@@ -2768,6 +2771,7 @@ def _get_hold_detail_lots_from_oracle(
HOLDEMP AS HOLD_BY,
DEPTNAME AS DEPT,
COMMENT_HOLD AS HOLD_COMMENT,
COMMENT_FUTURE AS FUTURE_HOLD_COMMENT,
ROW_NUMBER() OVER (ORDER BY AGEBYDAYS DESC, LOTID) AS RN
FROM {WIP_VIEW}
{where_clause}
@@ -2784,6 +2788,7 @@ def _get_hold_detail_lots_from_oracle(
'lotId': _safe_value(row['LOTID']),
'workorder': _safe_value(row['WORKORDER']),
'qty': int(row['QTY'] or 0),
'product': _safe_value(row['PRODUCT']),
'package': _safe_value(row['PACKAGE']),
'workcenter': _safe_value(row['WORKCENTER']),
'holdReason': _safe_value(row['HOLD_REASON']),
@@ -2791,7 +2796,8 @@ def _get_hold_detail_lots_from_oracle(
'age': float(row['AGE']) if row['AGE'] else 0,
'holdBy': _safe_value(row['HOLD_BY']),
'dept': _safe_value(row['DEPT']),
'holdComment': _safe_value(row['HOLD_COMMENT'])
'holdComment': _safe_value(row['HOLD_COMMENT']),
'futureHoldComment': _safe_value(row['FUTURE_HOLD_COMMENT']),
})
total_pages = (total + page_size - 1) // page_size if total > 0 else 1

View File

@@ -0,0 +1,73 @@
WITH history_base AS (
SELECT
CASE
WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1
ELSE TRUNC(h.HOLDTXNDATE)
END AS hold_day,
h.HOLDTXNDATE,
h.RELEASETXNDATE,
NVL(h.QTY, 0) AS qty,
CASE
WHEN h.HOLDREASONNAME IN ({{ NON_QUALITY_REASONS }}) THEN 'non-quality'
ELSE 'quality'
END AS hold_type
FROM DWH.DW_MES_HOLDRELEASEHISTORY h
),
filtered AS (
SELECT
CASE
WHEN RELEASETXNDATE IS NULL THEN (SYSDATE - HOLDTXNDATE) * 24
ELSE (RELEASETXNDATE - HOLDTXNDATE) * 24
END AS hold_hours,
qty
FROM history_base
WHERE hold_day BETWEEN TO_DATE(:start_date, 'YYYY-MM-DD') AND TO_DATE(:end_date, 'YYYY-MM-DD')
AND (:hold_type = 'all' OR hold_type = :hold_type)
AND (:include_new = 1
OR (:include_on_hold = 1 AND RELEASETXNDATE IS NULL)
OR (:include_released = 1 AND RELEASETXNDATE IS NOT NULL))
),
bucketed AS (
SELECT
CASE
WHEN hold_hours < 4 THEN '<4h'
WHEN hold_hours < 24 THEN '4-24h'
WHEN hold_hours < 72 THEN '1-3d'
ELSE '>3d'
END AS range_label,
qty
FROM filtered
),
bucket_counts AS (
SELECT
range_label,
COUNT(*) AS item_count,
SUM(qty) AS qty
FROM bucketed
GROUP BY range_label
),
totals AS (
SELECT SUM(qty) AS total_qty FROM bucket_counts
),
buckets AS (
SELECT '<4h' AS range_label, 1 AS order_key FROM dual
UNION ALL
SELECT '4-24h' AS range_label, 2 AS order_key FROM dual
UNION ALL
SELECT '1-3d' AS range_label, 3 AS order_key FROM dual
UNION ALL
SELECT '>3d' AS range_label, 4 AS order_key FROM dual
)
SELECT
b.range_label,
NVL(c.item_count, 0) AS item_count,
NVL(c.qty, 0) AS qty,
CASE
WHEN t.total_qty = 0 THEN 0
ELSE ROUND(NVL(c.qty, 0) * 100 / t.total_qty, 2)
END AS pct,
b.order_key
FROM buckets b
LEFT JOIN bucket_counts c ON c.range_label = b.range_label
CROSS JOIN totals t
ORDER BY b.order_key

View File

@@ -0,0 +1,95 @@
WITH history_base AS (
SELECT
CASE
WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1
ELSE TRUNC(h.HOLDTXNDATE)
END AS hold_day,
h.CONTAINERID,
h.PJ_WORKORDER,
h.WORKCENTERNAME,
h.HOLDREASONNAME,
h.HOLDTXNDATE,
NVL(h.QTY, 0) AS QTY,
h.HOLDEMP,
h.HOLDCOMMENTS,
h.RELEASETXNDATE,
h.RELEASEEMP,
h.RELEASECOMMENTS,
h.NCRID,
CASE
WHEN h.HOLDREASONNAME IN ({{ NON_QUALITY_REASONS }}) THEN 'non-quality'
ELSE 'quality'
END AS hold_type
FROM DWH.DW_MES_HOLDRELEASEHISTORY h
),
filtered AS (
SELECT
b.*,
CASE
WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24
ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24
END AS hold_hours
FROM history_base b
WHERE b.hold_day BETWEEN TO_DATE(:start_date, 'YYYY-MM-DD') AND TO_DATE(:end_date, 'YYYY-MM-DD')
AND (:hold_type = 'all' OR b.hold_type = :hold_type)
AND (:reason IS NULL OR b.HOLDREASONNAME = :reason)
AND (:include_new = 1
OR (:include_on_hold = 1 AND b.RELEASETXNDATE IS NULL)
OR (:include_released = 1 AND b.RELEASETXNDATE IS NOT NULL))
AND (:duration_range IS NULL
OR (:duration_range = '<4h' AND
CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24
ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END < 4)
OR (:duration_range = '4-24h' AND
CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24
ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END >= 4 AND
CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24
ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END < 24)
OR (:duration_range = '1-3d' AND
CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24
ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END >= 24 AND
CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24
ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END < 72)
OR (:duration_range = '>3d' AND
CASE WHEN b.RELEASETXNDATE IS NULL THEN (SYSDATE - b.HOLDTXNDATE) * 24
ELSE (b.RELEASETXNDATE - b.HOLDTXNDATE) * 24 END >= 72))
),
ranked AS (
SELECT
NVL(l.LOTID, TRIM(f.CONTAINERID)) AS lot_id,
f.PJ_WORKORDER AS workorder,
f.WORKCENTERNAME AS workcenter,
f.HOLDREASONNAME AS hold_reason,
f.QTY AS qty,
f.HOLDTXNDATE AS hold_date,
f.HOLDEMP AS hold_emp,
f.HOLDCOMMENTS AS hold_comment,
f.RELEASETXNDATE AS release_date,
f.RELEASEEMP AS release_emp,
f.RELEASECOMMENTS AS release_comment,
f.hold_hours,
f.NCRID AS ncr_id,
ROW_NUMBER() OVER (ORDER BY f.HOLDTXNDATE DESC, f.CONTAINERID) AS rn,
COUNT(*) OVER () AS total_count
FROM filtered f
LEFT JOIN DWH.DW_MES_LOT_V l ON l.CONTAINERID = f.CONTAINERID
)
SELECT
lot_id,
workorder,
workcenter,
hold_reason,
qty,
hold_date,
hold_emp,
hold_comment,
release_date,
release_emp,
release_comment,
hold_hours,
ncr_id,
total_count
FROM ranked
WHERE rn > :offset
AND rn <= :offset + :limit
ORDER BY rn

View File

@@ -0,0 +1,86 @@
WITH history_base AS (
SELECT
CASE
WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1
ELSE TRUNC(h.HOLDTXNDATE)
END AS hold_day,
h.CONTAINERID,
h.HOLDREASONID,
h.HOLDREASONNAME,
h.RELEASETXNDATE,
NVL(h.QTY, 0) AS qty,
CASE
WHEN h.HOLDREASONNAME IN ({{ NON_QUALITY_REASONS }}) THEN 'non-quality'
ELSE 'quality'
END AS hold_type,
CASE
WHEN h.FUTUREHOLDCOMMENTS IS NOT NULL THEN 1
ELSE 0
END AS is_future_hold,
ROW_NUMBER() OVER (
PARTITION BY
h.CONTAINERID,
CASE
WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1
ELSE TRUNC(h.HOLDTXNDATE)
END
ORDER BY h.HOLDTXNDATE DESC
) AS rn_hold_day,
ROW_NUMBER() OVER (
PARTITION BY h.CONTAINERID, h.HOLDREASONID
ORDER BY h.HOLDTXNDATE
) AS rn_future_reason
FROM DWH.DW_MES_HOLDRELEASEHISTORY h
),
filtered AS (
SELECT
NVL(TRIM(HOLDREASONNAME), '(未填寫)') AS reason,
qty
FROM history_base
WHERE hold_day BETWEEN TO_DATE(:start_date, 'YYYY-MM-DD') AND TO_DATE(:end_date, 'YYYY-MM-DD')
AND (:hold_type = 'all' OR hold_type = :hold_type)
AND (:include_new = 1
OR (:include_on_hold = 1 AND RELEASETXNDATE IS NULL)
OR (:include_released = 1 AND RELEASETXNDATE IS NOT NULL))
AND rn_hold_day = 1
AND (
CASE
WHEN is_future_hold = 1 AND rn_future_reason <> 1 THEN 0
ELSE 1
END
) = 1
),
grouped AS (
SELECT
reason,
COUNT(*) AS item_count,
SUM(qty) AS qty
FROM filtered
GROUP BY reason
),
ordered AS (
SELECT
reason,
item_count,
qty,
SUM(qty) OVER () AS total_qty,
SUM(qty) OVER (
ORDER BY qty DESC, reason
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_qty
FROM grouped
)
SELECT
reason,
item_count,
qty,
CASE
WHEN total_qty = 0 THEN 0
ELSE ROUND(qty * 100 / total_qty, 2)
END AS pct,
CASE
WHEN total_qty = 0 THEN 0
ELSE ROUND(running_qty * 100 / total_qty, 2)
END AS cum_pct
FROM ordered
ORDER BY qty DESC, reason

View File

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

View File

@@ -0,0 +1,130 @@
WITH calendar AS (
SELECT TRUNC(TO_DATE(:start_date, 'YYYY-MM-DD')) + LEVEL - 1 AS day_date
FROM dual
CONNECT BY LEVEL <= (
TRUNC(TO_DATE(:end_date, 'YYYY-MM-DD')) - TRUNC(TO_DATE(:start_date, 'YYYY-MM-DD')) + 1
)
),
hold_types AS (
SELECT 'quality' AS hold_type FROM dual
UNION ALL
SELECT 'non-quality' AS hold_type FROM dual
UNION ALL
SELECT 'all' AS hold_type FROM dual
),
history_base AS (
SELECT
CASE
WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1
ELSE TRUNC(h.HOLDTXNDATE)
END AS hold_day,
CASE
WHEN h.RELEASETXNDATE IS NULL THEN NULL
WHEN TO_CHAR(h.RELEASETXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.RELEASETXNDATE) + 1
ELSE TRUNC(h.RELEASETXNDATE)
END AS release_day,
h.HOLDTXNDATE,
h.RELEASETXNDATE,
h.CONTAINERID,
NVL(h.QTY, 0) AS qty,
h.HOLDREASONID,
h.HOLDREASONNAME,
CASE
WHEN h.FUTUREHOLDCOMMENTS IS NOT NULL THEN 1
ELSE 0
END AS is_future_hold,
ROW_NUMBER() OVER (
PARTITION BY
h.CONTAINERID,
CASE
WHEN TO_CHAR(h.HOLDTXNDATE, 'HH24MI') >= '0730' THEN TRUNC(h.HOLDTXNDATE) + 1
ELSE TRUNC(h.HOLDTXNDATE)
END
ORDER BY h.HOLDTXNDATE DESC
) AS rn_hold_day,
ROW_NUMBER() OVER (
PARTITION BY h.CONTAINERID, h.HOLDREASONID
ORDER BY h.HOLDTXNDATE
) AS rn_future_reason
FROM DWH.DW_MES_HOLDRELEASEHISTORY h
WHERE (
h.HOLDTXNDATE >= TO_DATE(:start_date || ' 073000', 'YYYY-MM-DD HH24MISS') - 1
OR h.RELEASETXNDATE >= TO_DATE(:start_date || ' 073000', 'YYYY-MM-DD HH24MISS') - 1
OR h.RELEASETXNDATE IS NULL
)
AND (
h.HOLDTXNDATE <= TO_DATE(:end_date || ' 073000', 'YYYY-MM-DD HH24MISS')
OR h.RELEASETXNDATE <= TO_DATE(:end_date || ' 073000', 'YYYY-MM-DD HH24MISS')
OR h.RELEASETXNDATE IS NULL
)
),
history_enriched AS (
SELECT
hold_day,
release_day,
HOLDTXNDATE,
RELEASETXNDATE,
CONTAINERID,
qty,
HOLDREASONID,
HOLDREASONNAME,
rn_hold_day,
CASE
WHEN is_future_hold = 1 AND rn_future_reason <> 1 THEN 0
ELSE 1
END AS future_hold_flag,
CASE
WHEN HOLDREASONNAME IN ({{ NON_QUALITY_REASONS }}) THEN 'non-quality'
ELSE 'quality'
END AS hold_type
FROM history_base
)
SELECT
TO_CHAR(c.day_date, 'YYYY-MM-DD') AS txn_date,
t.hold_type,
SUM(
CASE
WHEN (t.hold_type = 'all' OR h.hold_type = t.hold_type)
AND h.hold_day <= c.day_date
AND (h.release_day IS NULL OR c.day_date < h.release_day)
AND c.day_date <= TRUNC(SYSDATE)
AND h.rn_hold_day = 1
THEN h.qty
ELSE 0
END
) AS hold_qty,
SUM(
CASE
WHEN (t.hold_type = 'all' OR h.hold_type = t.hold_type)
AND h.hold_day = c.day_date
AND (h.release_day IS NULL OR c.day_date <= h.release_day)
AND h.future_hold_flag = 1
THEN h.qty
ELSE 0
END
) AS new_hold_qty,
SUM(
CASE
WHEN (t.hold_type = 'all' OR h.hold_type = t.hold_type)
AND h.release_day = c.day_date
AND h.release_day >= h.hold_day
THEN h.qty
ELSE 0
END
) AS release_qty,
SUM(
CASE
WHEN (t.hold_type = 'all' OR h.hold_type = t.hold_type)
AND h.hold_day = c.day_date
AND (h.release_day IS NULL OR c.day_date <= h.release_day)
AND h.rn_hold_day = 1
AND h.future_hold_flag = 0
THEN h.qty
ELSE 0
END
) AS future_hold_qty
FROM calendar c
CROSS JOIN hold_types t
LEFT JOIN history_enriched h ON 1 = 1
GROUP BY c.day_date, t.hold_type
ORDER BY c.day_date, t.hold_type

View File

@@ -0,0 +1,256 @@
# -*- coding: utf-8 -*-
"""Unit tests for Hold History 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 TestHoldHistoryRoutesBase(unittest.TestCase):
"""Base class for Hold History 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 TestHoldHistoryPageRoute(TestHoldHistoryRoutesBase):
"""Test GET /hold-history page route."""
@patch('mes_dashboard.routes.hold_history_routes.os.path.exists', return_value=False)
def test_hold_history_page_includes_vite_entry(self, _mock_exists):
with self.client.session_transaction() as sess:
sess['admin'] = {'displayName': 'Test Admin', 'employeeNo': 'A001'}
response = self.client.get('/hold-history')
self.assertEqual(response.status_code, 200)
self.assertIn(b'/static/dist/hold-history.js', response.data)
@patch('mes_dashboard.routes.hold_history_routes.os.path.exists', return_value=False)
def test_hold_history_page_returns_403_without_admin(self, _mock_exists):
response = self.client.get('/hold-history')
self.assertEqual(response.status_code, 403)
class TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/trend endpoint."""
@patch('mes_dashboard.routes.hold_history_routes.get_still_on_hold_count')
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_trend')
def test_trend_passes_date_range(self, mock_trend, mock_count):
mock_trend.return_value = {
'days': [
{
'date': '2026-02-01',
'quality': {'holdQty': 10, 'newHoldQty': 2, 'releaseQty': 3, 'futureHoldQty': 1},
'non_quality': {'holdQty': 5, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0},
'all': {'holdQty': 15, 'newHoldQty': 3, 'releaseQty': 5, 'futureHoldQty': 1},
}
]
}
mock_count.return_value = {'quality': 4, 'non_quality': 2, 'all': 6}
response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(payload['success'])
self.assertEqual(payload['data']['stillOnHoldCount'], {'quality': 4, 'non_quality': 2, 'all': 6})
mock_trend.assert_called_once_with('2026-02-01', '2026-02-07')
mock_count.assert_called_once_with()
def test_trend_invalid_date_returns_400(self):
response = self.client.get('/api/hold-history/trend?start_date=2026/02/01&end_date=2026-02-07')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.get_still_on_hold_count')
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_trend')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 8))
def test_trend_rate_limited_returns_429(self, _mock_limit, mock_service, _mock_count):
response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 429)
self.assertEqual(payload['error']['code'], 'TOO_MANY_REQUESTS')
self.assertEqual(response.headers.get('Retry-After'), '8')
mock_service.assert_not_called()
class TestHoldHistoryReasonParetoRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/reason-pareto endpoint."""
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto')
def test_reason_pareto_passes_hold_type_and_record_type(self, mock_service):
mock_service.return_value = {'items': []}
response = self.client.get(
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07'
'&hold_type=non-quality&record_type=on_hold'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'non-quality', 'on_hold')
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto')
def test_reason_pareto_defaults_record_type_to_new(self, mock_service):
mock_service.return_value = {'items': []}
response = self.client.get(
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&hold_type=quality'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'new')
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto')
def test_reason_pareto_multi_record_type(self, mock_service):
mock_service.return_value = {'items': []}
response = self.client.get(
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07'
'&hold_type=quality&record_type=on_hold,released'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'on_hold,released')
def test_reason_pareto_invalid_record_type_returns_400(self):
response = self.client.get(
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&record_type=invalid'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
def test_reason_pareto_partial_invalid_record_type_returns_400(self):
response = self.client.get(
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&record_type=on_hold,bogus'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
class TestHoldHistoryDurationRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/duration endpoint."""
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_duration')
def test_duration_failure_returns_500(self, mock_service):
mock_service.return_value = None
response = self.client.get('/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertFalse(payload['success'])
def test_duration_invalid_hold_type(self):
response = self.client.get(
'/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&hold_type=invalid'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_duration')
def test_duration_passes_record_type(self, mock_service):
mock_service.return_value = {'items': []}
response = self.client.get(
'/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&record_type=released'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'released')
def test_duration_invalid_record_type_returns_400(self):
response = self.client.get(
'/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&record_type=bogus'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
class TestHoldHistoryListRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/list endpoint."""
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list')
def test_list_caps_per_page_and_sets_page_floor(self, mock_service):
mock_service.return_value = {
'items': [],
'pagination': {'page': 1, 'perPage': 200, 'total': 0, 'totalPages': 1},
}
response = self.client.get(
'/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07'
'&hold_type=all&page=0&per_page=500&reason=品質確認'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
start_date='2026-02-01',
end_date='2026-02-07',
hold_type='all',
reason='品質確認',
record_type='new',
duration_range=None,
page=1,
per_page=200,
)
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list')
def test_list_passes_duration_range(self, mock_service):
mock_service.return_value = {
'items': [],
'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
}
response = self.client.get(
'/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07&duration_range=<4h'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
start_date='2026-02-01',
end_date='2026-02-07',
hold_type='quality',
reason=None,
record_type='new',
duration_range='<4h',
page=1,
per_page=50,
)
def test_list_invalid_duration_range_returns_400(self):
response = self.client.get(
'/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07&duration_range=invalid'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5))
def test_list_rate_limited_returns_429(self, _mock_limit, mock_service):
response = self.client.get('/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07')
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'), '5')
mock_service.assert_not_called()

View File

@@ -0,0 +1,373 @@
# -*- coding: utf-8 -*-
"""Unit tests for hold_history_service module."""
from __future__ import annotations
import json
import unittest
from datetime import date, datetime, timedelta
from unittest.mock import MagicMock, patch
import pandas as pd
from mes_dashboard.services import hold_history_service
class TestHoldHistoryTrendCache(unittest.TestCase):
"""Test trend cache hit/miss/cross-month behavior."""
def setUp(self):
hold_history_service._load_hold_history_sql.cache_clear()
def _trend_rows_for_days(self, days: list[str]) -> pd.DataFrame:
rows = []
for day in days:
rows.append(
{
'TXN_DATE': day,
'HOLD_TYPE': 'quality',
'HOLD_QTY': 10,
'NEW_HOLD_QTY': 2,
'RELEASE_QTY': 3,
'FUTURE_HOLD_QTY': 1,
}
)
rows.append(
{
'TXN_DATE': day,
'HOLD_TYPE': 'non-quality',
'HOLD_QTY': 4,
'NEW_HOLD_QTY': 1,
'RELEASE_QTY': 1,
'FUTURE_HOLD_QTY': 0,
}
)
rows.append(
{
'TXN_DATE': day,
'HOLD_TYPE': 'all',
'HOLD_QTY': 14,
'NEW_HOLD_QTY': 3,
'RELEASE_QTY': 4,
'FUTURE_HOLD_QTY': 1,
}
)
return pd.DataFrame(rows)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
@patch('mes_dashboard.services.hold_history_service.get_redis_client')
def test_trend_cache_hit_for_recent_month(self, mock_get_redis_client, mock_read_sql_df):
today = date.today()
start = today.replace(day=1)
end = start + timedelta(days=1)
cached_days = [
{
'date': start.strftime('%Y-%m-%d'),
'quality': {'holdQty': 11, 'newHoldQty': 2, 'releaseQty': 4, 'futureHoldQty': 1},
'non_quality': {'holdQty': 5, 'newHoldQty': 1, 'releaseQty': 1, 'futureHoldQty': 0},
'all': {'holdQty': 16, 'newHoldQty': 3, 'releaseQty': 5, 'futureHoldQty': 1},
},
{
'date': end.strftime('%Y-%m-%d'),
'quality': {'holdQty': 12, 'newHoldQty': 3, 'releaseQty': 5, 'futureHoldQty': 1},
'non_quality': {'holdQty': 4, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0},
'all': {'holdQty': 16, 'newHoldQty': 4, 'releaseQty': 7, 'futureHoldQty': 1},
},
]
mock_redis = MagicMock()
mock_redis.get.return_value = json.dumps(cached_days)
mock_get_redis_client.return_value = mock_redis
result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat())
self.assertIsNotNone(result)
self.assertEqual(len(result['days']), 2)
self.assertEqual(result['days'][0]['quality']['holdQty'], 11)
self.assertEqual(result['days'][1]['all']['releaseQty'], 7)
mock_read_sql_df.assert_not_called()
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
@patch('mes_dashboard.services.hold_history_service.get_redis_client')
def test_trend_cache_miss_populates_cache(self, mock_get_redis_client, mock_read_sql_df):
today = date.today()
start = today.replace(day=1)
end = start + timedelta(days=1)
mock_redis = MagicMock()
mock_redis.get.return_value = None
mock_get_redis_client.return_value = mock_redis
mock_read_sql_df.return_value = self._trend_rows_for_days([start.isoformat(), end.isoformat()])
result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat())
self.assertIsNotNone(result)
self.assertEqual(len(result['days']), 2)
self.assertEqual(result['days'][0]['all']['holdQty'], 14)
self.assertEqual(mock_read_sql_df.call_count, 1)
mock_redis.setex.assert_called_once()
cache_key = mock_redis.setex.call_args.args[0]
self.assertIn('hold_history:daily', cache_key)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
@patch('mes_dashboard.services.hold_history_service.get_redis_client')
def test_trend_cross_month_assembly_from_cache(self, mock_get_redis_client, mock_read_sql_df):
today = date.today()
current_month_start = today.replace(day=1)
previous_month_end = current_month_start - timedelta(days=1)
start = previous_month_end - timedelta(days=1)
end = current_month_start + timedelta(days=1)
previous_cache = [
{
'date': start.strftime('%Y-%m-%d'),
'quality': {'holdQty': 9, 'newHoldQty': 2, 'releaseQty': 1, 'futureHoldQty': 0},
'non_quality': {'holdQty': 3, 'newHoldQty': 1, 'releaseQty': 0, 'futureHoldQty': 0},
'all': {'holdQty': 12, 'newHoldQty': 3, 'releaseQty': 1, 'futureHoldQty': 0},
},
{
'date': (start + timedelta(days=1)).strftime('%Y-%m-%d'),
'quality': {'holdQty': 8, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0},
'non_quality': {'holdQty': 2, 'newHoldQty': 1, 'releaseQty': 1, 'futureHoldQty': 0},
'all': {'holdQty': 10, 'newHoldQty': 2, 'releaseQty': 3, 'futureHoldQty': 0},
},
]
current_cache = [
{
'date': current_month_start.strftime('%Y-%m-%d'),
'quality': {'holdQty': 7, 'newHoldQty': 2, 'releaseQty': 3, 'futureHoldQty': 1},
'non_quality': {'holdQty': 2, 'newHoldQty': 1, 'releaseQty': 1, 'futureHoldQty': 0},
'all': {'holdQty': 9, 'newHoldQty': 3, 'releaseQty': 4, 'futureHoldQty': 1},
},
{
'date': (current_month_start + timedelta(days=1)).strftime('%Y-%m-%d'),
'quality': {'holdQty': 6, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0},
'non_quality': {'holdQty': 1, 'newHoldQty': 1, 'releaseQty': 0, 'futureHoldQty': 0},
'all': {'holdQty': 7, 'newHoldQty': 2, 'releaseQty': 2, 'futureHoldQty': 0},
},
]
mock_redis = MagicMock()
mock_redis.get.side_effect = [json.dumps(previous_cache), json.dumps(current_cache)]
mock_get_redis_client.return_value = mock_redis
result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat())
self.assertIsNotNone(result)
self.assertEqual(len(result['days']), (end - start).days + 1)
self.assertEqual(result['days'][0]['date'], start.isoformat())
self.assertEqual(result['days'][-1]['date'], end.isoformat())
self.assertEqual(result['days'][0]['all']['holdQty'], 12)
self.assertEqual(result['days'][-1]['quality']['releaseQty'], 2)
mock_read_sql_df.assert_not_called()
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
@patch('mes_dashboard.services.hold_history_service.get_redis_client')
def test_trend_older_month_queries_oracle_without_cache(self, mock_get_redis_client, mock_read_sql_df):
today = date.today()
current_month_start = today.replace(day=1)
old_month_start = (current_month_start - timedelta(days=100)).replace(day=1)
start = old_month_start
end = old_month_start + timedelta(days=1)
mock_redis = MagicMock()
mock_get_redis_client.return_value = mock_redis
mock_read_sql_df.return_value = self._trend_rows_for_days([start.isoformat(), end.isoformat()])
result = hold_history_service.get_hold_history_trend(start.isoformat(), end.isoformat())
self.assertIsNotNone(result)
self.assertEqual(len(result['days']), 2)
self.assertEqual(mock_read_sql_df.call_count, 1)
mock_redis.get.assert_not_called()
class TestHoldHistoryServiceFunctions(unittest.TestCase):
"""Test non-trend service function formatting and behavior."""
def setUp(self):
hold_history_service._load_hold_history_sql.cache_clear()
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_reason_pareto_formats_response(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame(
[
{'REASON': '品質確認', 'ITEM_COUNT': 10, 'QTY': 2000, 'PCT': 40.0, 'CUM_PCT': 40.0},
{'REASON': '工程驗證', 'ITEM_COUNT': 8, 'QTY': 1800, 'PCT': 32.0, 'CUM_PCT': 72.0},
]
)
result = hold_history_service.get_hold_history_reason_pareto('2026-02-01', '2026-02-07', 'quality')
self.assertIsNotNone(result)
self.assertEqual(len(result['items']), 2)
self.assertEqual(result['items'][0]['reason'], '品質確認')
self.assertEqual(result['items'][0]['count'], 10)
self.assertEqual(result['items'][1]['cumPct'], 72.0)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_reason_pareto_passes_record_type_flags(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame([])
hold_history_service.get_hold_history_reason_pareto(
'2026-02-01', '2026-02-07', 'quality', record_type='on_hold'
)
params = mock_read_sql_df.call_args.args[1]
self.assertEqual(params['include_new'], 0)
self.assertEqual(params['include_on_hold'], 1)
self.assertEqual(params['include_released'], 0)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_reason_pareto_multi_record_type_flags(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame([])
hold_history_service.get_hold_history_reason_pareto(
'2026-02-01', '2026-02-07', 'quality', record_type='on_hold,released'
)
params = mock_read_sql_df.call_args.args[1]
self.assertEqual(params['include_new'], 0)
self.assertEqual(params['include_on_hold'], 1)
self.assertEqual(params['include_released'], 1)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_reason_pareto_normalizes_invalid_hold_type(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame([])
hold_history_service.get_hold_history_reason_pareto('2026-02-01', '2026-02-07', 'invalid')
params = mock_read_sql_df.call_args.args[1]
self.assertEqual(params['hold_type'], 'quality')
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_duration_formats_response(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame(
[
{'RANGE_LABEL': '<4h', 'ITEM_COUNT': 5, 'QTY': 500, 'PCT': 25.0},
{'RANGE_LABEL': '4-24h', 'ITEM_COUNT': 7, 'QTY': 700, 'PCT': 35.0},
{'RANGE_LABEL': '1-3d', 'ITEM_COUNT': 4, 'QTY': 400, 'PCT': 20.0},
{'RANGE_LABEL': '>3d', 'ITEM_COUNT': 4, 'QTY': 400, 'PCT': 20.0},
]
)
result = hold_history_service.get_hold_history_duration('2026-02-01', '2026-02-07', 'quality')
self.assertIsNotNone(result)
self.assertEqual(len(result['items']), 4)
self.assertEqual(result['items'][0]['range'], '<4h')
self.assertEqual(result['items'][0]['qty'], 500)
self.assertEqual(result['items'][1]['count'], 7)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_duration_passes_record_type_flags(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame([])
hold_history_service.get_hold_history_duration(
'2026-02-01', '2026-02-07', 'quality', record_type='released'
)
params = mock_read_sql_df.call_args.args[1]
self.assertEqual(params['include_new'], 0)
self.assertEqual(params['include_on_hold'], 0)
self.assertEqual(params['include_released'], 1)
@patch('mes_dashboard.services.hold_history_service._get_wc_group')
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_list_formats_response_and_pagination(self, mock_read_sql_df, mock_wc_group):
mock_wc_group.side_effect = lambda wc: {'WB': '焊接_WB', 'DB': '焊接_DB'}.get(wc)
mock_read_sql_df.return_value = pd.DataFrame(
[
{
'LOT_ID': 'LOT001',
'WORKORDER': 'GA26010001',
'WORKCENTER': 'WB',
'HOLD_REASON': '品質確認',
'QTY': 250,
'HOLD_DATE': datetime(2026, 2, 1, 8, 30, 0),
'HOLD_EMP': '王小明',
'HOLD_COMMENT': '確認中',
'RELEASE_DATE': None,
'RELEASE_EMP': None,
'RELEASE_COMMENT': None,
'HOLD_HOURS': 12.345,
'NCR_ID': 'NCR-001',
'TOTAL_COUNT': 3,
},
{
'LOT_ID': 'LOT002',
'WORKORDER': 'GA26010002',
'WORKCENTER': 'DB',
'HOLD_REASON': '工程驗證',
'QTY': 100,
'HOLD_DATE': datetime(2026, 2, 1, 9, 10, 0),
'HOLD_EMP': '陳小華',
'HOLD_COMMENT': '待確認',
'RELEASE_DATE': datetime(2026, 2, 1, 12, 0, 0),
'RELEASE_EMP': '李主管',
'RELEASE_COMMENT': '已解除',
'HOLD_HOURS': 2.5,
'NCR_ID': None,
'TOTAL_COUNT': 3,
},
]
)
result = hold_history_service.get_hold_history_list(
start_date='2026-02-01',
end_date='2026-02-07',
hold_type='quality',
reason=None,
page=1,
per_page=2,
)
self.assertIsNotNone(result)
self.assertEqual(len(result['items']), 2)
self.assertEqual(result['items'][0]['workcenter'], '焊接_WB')
self.assertEqual(result['items'][1]['workcenter'], '焊接_DB')
self.assertEqual(result['items'][0]['qty'], 250)
self.assertEqual(result['items'][1]['qty'], 100)
self.assertEqual(result['items'][0]['releaseDate'], None)
self.assertEqual(result['items'][0]['holdHours'], 12.35)
self.assertEqual(result['pagination']['total'], 3)
self.assertEqual(result['pagination']['totalPages'], 2)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_still_on_hold_count_formats_response(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame(
[{'QUALITY_COUNT': 4, 'NON_QUALITY_COUNT': 2, 'ALL_COUNT': 6}]
)
result = hold_history_service.get_still_on_hold_count()
self.assertIsNotNone(result)
self.assertEqual(result['quality'], 4)
self.assertEqual(result['non_quality'], 2)
self.assertEqual(result['all'], 6)
@patch('mes_dashboard.services.hold_history_service.read_sql_df')
def test_still_on_hold_count_empty_returns_zeros(self, mock_read_sql_df):
mock_read_sql_df.return_value = pd.DataFrame()
result = hold_history_service.get_still_on_hold_count()
self.assertIsNotNone(result)
self.assertEqual(result, {'quality': 0, 'non_quality': 0, 'all': 0})
def test_trend_sql_contains_shift_boundary_logic(self):
sql = hold_history_service._load_hold_history_sql('trend')
self.assertIn('0730', sql)
self.assertIn('ROW_NUMBER', sql)
self.assertIn('FUTUREHOLDCOMMENTS', sql)
if __name__ == '__main__': # pragma: no cover
unittest.main()

View File

@@ -684,6 +684,8 @@ class TestHoldOverviewServiceCachePath(unittest.TestCase):
'HOLDEMP': ['EMP1', 'EMP2', 'EMP3', 'EMP4', 'EMP5'],
'DEPTNAME': ['QC', 'PD', 'QC', 'RUN', 'QC'],
'COMMENT_HOLD': ['C1', 'C2', 'C3', 'C4', 'C5'],
'COMMENT_FUTURE': ['FC1', None, 'FC3', None, 'FC5'],
'PRODUCT': ['PROD-A', 'PROD-B', 'PROD-A', 'PROD-Z', 'PROD-C'],
'PJ_TYPE': ['T1', 'T2', 'T1', 'T9', 'T3'],
})
@@ -732,6 +734,19 @@ class TestHoldOverviewServiceCachePath(unittest.TestCase):
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_hold_detail_lots_includes_product_and_future_hold_comment(self, mock_cached_wip):
mock_cached_wip.return_value = self._sample_hold_df()
result = get_hold_detail_lots(reason='品質確認', page=1, page_size=10)
lot = result['lots'][0]
self.assertEqual(lot['product'], 'PROD-A')
self.assertEqual(lot['futureHoldComment'], 'FC3')
lot2 = result['lots'][1]
self.assertEqual(lot2['product'], 'PROD-A')
self.assertEqual(lot2['futureHoldComment'], 'FC1')
@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()