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:
@@ -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
67
docs/hold_history.md
Normal 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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
500
frontend/src/hold-history/App.vue
Normal file
500
frontend/src/hold-history/App.vue
Normal 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>
|
||||
131
frontend/src/hold-history/components/DailyTrend.vue
Normal file
131
frontend/src/hold-history/components/DailyTrend.vue
Normal 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>
|
||||
118
frontend/src/hold-history/components/DetailTable.vue
Normal file
118
frontend/src/hold-history/components/DetailTable.vue
Normal 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>
|
||||
115
frontend/src/hold-history/components/DurationChart.vue
Normal file
115
frontend/src/hold-history/components/DurationChart.vue
Normal 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>
|
||||
103
frontend/src/hold-history/components/FilterBar.vue
Normal file
103
frontend/src/hold-history/components/FilterBar.vue
Normal 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>
|
||||
32
frontend/src/hold-history/components/FilterIndicator.vue
Normal file
32
frontend/src/hold-history/components/FilterIndicator.vue
Normal 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>
|
||||
140
frontend/src/hold-history/components/ReasonPareto.vue
Normal file
140
frontend/src/hold-history/components/ReasonPareto.vue
Normal 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>
|
||||
53
frontend/src/hold-history/components/RecordTypeFilter.vue
Normal file
53
frontend/src/hold-history/components/RecordTypeFilter.vue
Normal 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>
|
||||
59
frontend/src/hold-history/components/SummaryCards.vue
Normal file
59
frontend/src/hold-history/components/SummaryCards.vue
Normal 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>
|
||||
12
frontend/src/hold-history/index.html
Normal file
12
frontend/src/hold-history/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hold History</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
7
frontend/src/hold-history/main.js
Normal file
7
frontend/src/hold-history/main.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import App from './App.vue';
|
||||
import '../wip-shared/styles.css';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
305
frontend/src/hold-history/style.css
Normal file
305
frontend/src/hold-history/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
@@ -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 的 hold(RELEASETXNDATE 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 看全文。
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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` — 呼叫 service,rate 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` — 呼叫 service,rate 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
|
||||
172
openspec/specs/hold-history-api/spec.md
Normal file
172
openspec/specs/hold-history-api/spec.md
Normal 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
|
||||
172
openspec/specs/hold-history-page/spec.md
Normal file
172
openspec/specs/hold-history-page/spec.md
Normal 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`
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
212
src/mes_dashboard/routes/hold_history_routes.py
Normal file
212
src/mes_dashboard/routes/hold_history_routes.py
Normal 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
|
||||
507
src/mes_dashboard/services/hold_history_service.py
Normal file
507
src/mes_dashboard/services/hold_history_service.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
73
src/mes_dashboard/sql/hold_history/duration.sql
Normal file
73
src/mes_dashboard/sql/hold_history/duration.sql
Normal 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
|
||||
95
src/mes_dashboard/sql/hold_history/list.sql
Normal file
95
src/mes_dashboard/sql/hold_history/list.sql
Normal 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
|
||||
86
src/mes_dashboard/sql/hold_history/reason_pareto.sql
Normal file
86
src/mes_dashboard/sql/hold_history/reason_pareto.sql
Normal 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
|
||||
16
src/mes_dashboard/sql/hold_history/still_on_hold_count.sql
Normal file
16
src/mes_dashboard/sql/hold_history/still_on_hold_count.sql
Normal 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
|
||||
130
src/mes_dashboard/sql/hold_history/trend.sql
Normal file
130
src/mes_dashboard/sql/hold_history/trend.sql
Normal 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
|
||||
256
tests/test_hold_history_routes.py
Normal file
256
tests/test_hold_history_routes.py
Normal 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()
|
||||
373
tests/test_hold_history_service.py
Normal file
373
tests/test_hold_history_service.py
Normal 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()
|
||||
@@ -9,36 +9,36 @@ from unittest.mock import patch, MagicMock
|
||||
from functools import wraps
|
||||
import pandas as pd
|
||||
|
||||
from mes_dashboard.services.wip_service import (
|
||||
WIP_VIEW,
|
||||
get_wip_summary,
|
||||
get_wip_matrix,
|
||||
get_wip_hold_summary,
|
||||
get_wip_detail,
|
||||
get_hold_detail_summary,
|
||||
get_hold_detail_lots,
|
||||
get_hold_overview_treemap,
|
||||
get_workcenters,
|
||||
get_packages,
|
||||
search_workorders,
|
||||
search_lot_ids,
|
||||
)
|
||||
from mes_dashboard.services.wip_service import (
|
||||
WIP_VIEW,
|
||||
get_wip_summary,
|
||||
get_wip_matrix,
|
||||
get_wip_hold_summary,
|
||||
get_wip_detail,
|
||||
get_hold_detail_summary,
|
||||
get_hold_detail_lots,
|
||||
get_hold_overview_treemap,
|
||||
get_workcenters,
|
||||
get_packages,
|
||||
search_workorders,
|
||||
search_lot_ids,
|
||||
)
|
||||
|
||||
|
||||
def disable_cache(func):
|
||||
"""Decorator to disable Redis cache for Oracle fallback tests."""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
import mes_dashboard.services.wip_service as wip_service
|
||||
|
||||
with wip_service._wip_search_index_lock:
|
||||
wip_service._wip_search_index_cache.clear()
|
||||
with wip_service._wip_snapshot_lock:
|
||||
wip_service._wip_snapshot_cache.clear()
|
||||
with patch('mes_dashboard.services.wip_service.get_cached_wip_data', return_value=None):
|
||||
with patch('mes_dashboard.services.wip_service.get_cached_sys_date', return_value=None):
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
def disable_cache(func):
|
||||
"""Decorator to disable Redis cache for Oracle fallback tests."""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
import mes_dashboard.services.wip_service as wip_service
|
||||
|
||||
with wip_service._wip_search_index_lock:
|
||||
wip_service._wip_search_index_cache.clear()
|
||||
with wip_service._wip_snapshot_lock:
|
||||
wip_service._wip_snapshot_cache.clear()
|
||||
with patch('mes_dashboard.services.wip_service.get_cached_wip_data', return_value=None):
|
||||
with patch('mes_dashboard.services.wip_service.get_cached_sys_date', return_value=None):
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
class TestWipServiceConfig(unittest.TestCase):
|
||||
@@ -392,7 +392,7 @@ class TestSearchWorkorders(unittest.TestCase):
|
||||
self.assertNotIn("LOTID NOT LIKE '%DUMMY%'", call_args)
|
||||
|
||||
|
||||
class TestSearchLotIds(unittest.TestCase):
|
||||
class TestSearchLotIds(unittest.TestCase):
|
||||
"""Test search_lot_ids function."""
|
||||
|
||||
@disable_cache
|
||||
@@ -448,40 +448,40 @@ class TestSearchLotIds(unittest.TestCase):
|
||||
|
||||
search_lot_ids('GA26')
|
||||
|
||||
call_args = mock_read_sql.call_args[0][0]
|
||||
self.assertIn("LOTID NOT LIKE '%DUMMY%'", call_args)
|
||||
|
||||
|
||||
class TestWipSearchIndexShortcut(unittest.TestCase):
|
||||
"""Test derived search index fast-path behavior."""
|
||||
|
||||
@patch('mes_dashboard.services.wip_service._search_workorders_from_oracle')
|
||||
@patch('mes_dashboard.services.wip_service._get_wip_search_index')
|
||||
def test_workorder_search_uses_index_without_cross_filters(self, mock_index, mock_oracle):
|
||||
mock_index.return_value = {
|
||||
"workorders": ["GA26012001", "GA26012002", "GB00000001"]
|
||||
}
|
||||
|
||||
result = search_workorders("GA26", limit=10)
|
||||
|
||||
self.assertEqual(result, ["GA26012001", "GA26012002"])
|
||||
mock_oracle.assert_not_called()
|
||||
|
||||
@patch('mes_dashboard.services.wip_service._search_workorders_from_oracle')
|
||||
@patch('mes_dashboard.services.wip_service._get_wip_search_index')
|
||||
def test_workorder_search_with_cross_filters_falls_back(self, mock_index, mock_oracle):
|
||||
mock_index.return_value = {
|
||||
"workorders": ["GA26012001", "GA26012002"]
|
||||
}
|
||||
mock_oracle.return_value = ["GA26012001"]
|
||||
|
||||
result = search_workorders("GA26", package="SOT-23")
|
||||
|
||||
self.assertEqual(result, ["GA26012001"])
|
||||
mock_oracle.assert_called_once()
|
||||
|
||||
|
||||
class TestDummyExclusionInAllFunctions(unittest.TestCase):
|
||||
call_args = mock_read_sql.call_args[0][0]
|
||||
self.assertIn("LOTID NOT LIKE '%DUMMY%'", call_args)
|
||||
|
||||
|
||||
class TestWipSearchIndexShortcut(unittest.TestCase):
|
||||
"""Test derived search index fast-path behavior."""
|
||||
|
||||
@patch('mes_dashboard.services.wip_service._search_workorders_from_oracle')
|
||||
@patch('mes_dashboard.services.wip_service._get_wip_search_index')
|
||||
def test_workorder_search_uses_index_without_cross_filters(self, mock_index, mock_oracle):
|
||||
mock_index.return_value = {
|
||||
"workorders": ["GA26012001", "GA26012002", "GB00000001"]
|
||||
}
|
||||
|
||||
result = search_workorders("GA26", limit=10)
|
||||
|
||||
self.assertEqual(result, ["GA26012001", "GA26012002"])
|
||||
mock_oracle.assert_not_called()
|
||||
|
||||
@patch('mes_dashboard.services.wip_service._search_workorders_from_oracle')
|
||||
@patch('mes_dashboard.services.wip_service._get_wip_search_index')
|
||||
def test_workorder_search_with_cross_filters_falls_back(self, mock_index, mock_oracle):
|
||||
mock_index.return_value = {
|
||||
"workorders": ["GA26012001", "GA26012002"]
|
||||
}
|
||||
mock_oracle.return_value = ["GA26012001"]
|
||||
|
||||
result = search_workorders("GA26", package="SOT-23")
|
||||
|
||||
self.assertEqual(result, ["GA26012001"])
|
||||
mock_oracle.assert_called_once()
|
||||
|
||||
|
||||
class TestDummyExclusionInAllFunctions(unittest.TestCase):
|
||||
"""Test DUMMY exclusion is applied in all WIP functions."""
|
||||
|
||||
@disable_cache
|
||||
@@ -657,140 +657,155 @@ class TestMultipleFilterConditions(unittest.TestCase):
|
||||
|
||||
|
||||
|
||||
class TestHoldOverviewServiceCachePath(unittest.TestCase):
|
||||
"""Test hold overview related behavior on cache path."""
|
||||
|
||||
def setUp(self):
|
||||
import mes_dashboard.services.wip_service as wip_service
|
||||
with wip_service._wip_search_index_lock:
|
||||
wip_service._wip_search_index_cache.clear()
|
||||
with wip_service._wip_snapshot_lock:
|
||||
wip_service._wip_snapshot_cache.clear()
|
||||
|
||||
@staticmethod
|
||||
def _sample_hold_df() -> pd.DataFrame:
|
||||
return pd.DataFrame({
|
||||
'LOTID': ['L1', 'L2', 'L3', 'L4', 'L5'],
|
||||
'WORKORDER': ['WO1', 'WO2', 'WO3', 'WO4', 'WO5'],
|
||||
'QTY': [100, 50, 80, 60, 20],
|
||||
'PACKAGE_LEF': ['PKG-A', 'PKG-B', 'PKG-A', 'PKG-Z', 'PKG-C'],
|
||||
'WORKCENTER_GROUP': ['WC-A', 'WC-B', 'WC-A', 'WC-Z', 'WC-C'],
|
||||
'WORKCENTERSEQUENCE_GROUP': [1, 2, 1, 9, 3],
|
||||
'HOLDREASONNAME': ['品質確認', '特殊需求管控', '品質確認', None, '設備異常'],
|
||||
'AGEBYDAYS': [2.0, 3.0, 5.0, 0.3, 1.2],
|
||||
'EQUIPMENTCOUNT': [0, 0, 0, 1, 0],
|
||||
'CURRENTHOLDCOUNT': [1, 1, 1, 0, 1],
|
||||
'SPECNAME': ['S1', 'S2', 'S1', 'S9', 'S3'],
|
||||
'HOLDEMP': ['EMP1', 'EMP2', 'EMP3', 'EMP4', 'EMP5'],
|
||||
'DEPTNAME': ['QC', 'PD', 'QC', 'RUN', 'QC'],
|
||||
'COMMENT_HOLD': ['C1', 'C2', 'C3', 'C4', 'C5'],
|
||||
'PJ_TYPE': ['T1', 'T2', 'T1', 'T9', 'T3'],
|
||||
})
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.get_cached_sys_date', return_value='2026-02-10 10:00:00')
|
||||
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
|
||||
def test_get_hold_detail_summary_supports_optional_reason_and_hold_type(
|
||||
self,
|
||||
mock_cached_wip,
|
||||
_mock_sys_date,
|
||||
):
|
||||
mock_cached_wip.return_value = self._sample_hold_df()
|
||||
|
||||
reason_summary = get_hold_detail_summary(reason='品質確認')
|
||||
self.assertEqual(reason_summary['totalLots'], 2)
|
||||
self.assertEqual(reason_summary['totalQty'], 180)
|
||||
self.assertEqual(reason_summary['workcenterCount'], 1)
|
||||
self.assertEqual(reason_summary['dataUpdateDate'], '2026-02-10 10:00:00')
|
||||
|
||||
quality_summary = get_hold_detail_summary(hold_type='quality')
|
||||
self.assertEqual(quality_summary['totalLots'], 3)
|
||||
self.assertEqual(quality_summary['totalQty'], 200)
|
||||
self.assertEqual(quality_summary['workcenterCount'], 2)
|
||||
|
||||
all_hold_summary = get_hold_detail_summary()
|
||||
self.assertEqual(all_hold_summary['totalLots'], 4)
|
||||
self.assertEqual(all_hold_summary['totalQty'], 250)
|
||||
self.assertEqual(all_hold_summary['workcenterCount'], 3)
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
|
||||
def test_get_hold_detail_lots_returns_hold_reason_and_treemap_filter(self, mock_cached_wip):
|
||||
mock_cached_wip.return_value = self._sample_hold_df()
|
||||
|
||||
reason_result = get_hold_detail_lots(reason='品質確認', page=1, page_size=10)
|
||||
self.assertEqual(len(reason_result['lots']), 2)
|
||||
self.assertEqual(reason_result['lots'][0]['lotId'], 'L3')
|
||||
self.assertEqual(reason_result['lots'][0]['holdReason'], '品質確認')
|
||||
|
||||
treemap_result = get_hold_detail_lots(
|
||||
reason=None,
|
||||
hold_type=None,
|
||||
treemap_reason='特殊需求管控',
|
||||
page=1,
|
||||
page_size=10,
|
||||
)
|
||||
self.assertEqual(len(treemap_result['lots']), 1)
|
||||
self.assertEqual(treemap_result['lots'][0]['lotId'], 'L2')
|
||||
self.assertEqual(treemap_result['lots'][0]['holdReason'], '特殊需求管控')
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
|
||||
def test_get_wip_matrix_reason_filter_keeps_backward_compatibility(self, mock_cached_wip):
|
||||
mock_cached_wip.return_value = self._sample_hold_df()
|
||||
|
||||
hold_quality_all = get_wip_matrix(status='HOLD', hold_type='quality')
|
||||
self.assertEqual(hold_quality_all['grand_total'], 200)
|
||||
|
||||
hold_quality_reason = get_wip_matrix(
|
||||
status='HOLD',
|
||||
hold_type='quality',
|
||||
reason='品質確認',
|
||||
)
|
||||
self.assertEqual(hold_quality_reason['grand_total'], 180)
|
||||
self.assertEqual(hold_quality_reason['workcenters'], ['WC-A'])
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
|
||||
def test_get_hold_overview_treemap_groups_by_workcenter_and_reason(self, mock_cached_wip):
|
||||
mock_cached_wip.return_value = self._sample_hold_df()
|
||||
|
||||
result = get_hold_overview_treemap(hold_type='quality')
|
||||
self.assertIsNotNone(result)
|
||||
items = result['items']
|
||||
self.assertEqual(len(items), 2)
|
||||
expected = {(item['workcenter'], item['reason']): item for item in items}
|
||||
self.assertEqual(expected[('WC-A', '品質確認')]['lots'], 2)
|
||||
self.assertEqual(expected[('WC-A', '品質確認')]['qty'], 180)
|
||||
self.assertAlmostEqual(expected[('WC-A', '品質確認')]['avgAge'], 3.5)
|
||||
self.assertEqual(expected[('WC-C', '設備異常')]['lots'], 1)
|
||||
|
||||
|
||||
class TestHoldOverviewServiceOracleFallback(unittest.TestCase):
|
||||
"""Test reason filtering behavior on Oracle fallback path."""
|
||||
|
||||
@disable_cache
|
||||
@patch('mes_dashboard.services.wip_service.read_sql_df')
|
||||
def test_get_wip_matrix_oracle_applies_reason_for_hold_status(self, mock_read_sql):
|
||||
mock_read_sql.return_value = pd.DataFrame()
|
||||
|
||||
get_wip_matrix(status='HOLD', reason='品質確認')
|
||||
|
||||
call_args = mock_read_sql.call_args
|
||||
sql = call_args[0][0]
|
||||
params = call_args[0][1] if len(call_args[0]) > 1 else {}
|
||||
self.assertIn('HOLDREASONNAME', sql)
|
||||
self.assertTrue(any(v == '品質確認' for v in params.values()))
|
||||
|
||||
@disable_cache
|
||||
@patch('mes_dashboard.services.wip_service.read_sql_df')
|
||||
def test_get_wip_matrix_oracle_ignores_reason_for_non_hold_status(self, mock_read_sql):
|
||||
mock_read_sql.return_value = pd.DataFrame()
|
||||
|
||||
get_wip_matrix(status='RUN', reason='品質確認')
|
||||
|
||||
call_args = mock_read_sql.call_args
|
||||
sql = call_args[0][0]
|
||||
self.assertNotIn('HOLDREASONNAME', sql)
|
||||
|
||||
|
||||
import pytest
|
||||
class TestHoldOverviewServiceCachePath(unittest.TestCase):
|
||||
"""Test hold overview related behavior on cache path."""
|
||||
|
||||
def setUp(self):
|
||||
import mes_dashboard.services.wip_service as wip_service
|
||||
with wip_service._wip_search_index_lock:
|
||||
wip_service._wip_search_index_cache.clear()
|
||||
with wip_service._wip_snapshot_lock:
|
||||
wip_service._wip_snapshot_cache.clear()
|
||||
|
||||
@staticmethod
|
||||
def _sample_hold_df() -> pd.DataFrame:
|
||||
return pd.DataFrame({
|
||||
'LOTID': ['L1', 'L2', 'L3', 'L4', 'L5'],
|
||||
'WORKORDER': ['WO1', 'WO2', 'WO3', 'WO4', 'WO5'],
|
||||
'QTY': [100, 50, 80, 60, 20],
|
||||
'PACKAGE_LEF': ['PKG-A', 'PKG-B', 'PKG-A', 'PKG-Z', 'PKG-C'],
|
||||
'WORKCENTER_GROUP': ['WC-A', 'WC-B', 'WC-A', 'WC-Z', 'WC-C'],
|
||||
'WORKCENTERSEQUENCE_GROUP': [1, 2, 1, 9, 3],
|
||||
'HOLDREASONNAME': ['品質確認', '特殊需求管控', '品質確認', None, '設備異常'],
|
||||
'AGEBYDAYS': [2.0, 3.0, 5.0, 0.3, 1.2],
|
||||
'EQUIPMENTCOUNT': [0, 0, 0, 1, 0],
|
||||
'CURRENTHOLDCOUNT': [1, 1, 1, 0, 1],
|
||||
'SPECNAME': ['S1', 'S2', 'S1', 'S9', 'S3'],
|
||||
'HOLDEMP': ['EMP1', 'EMP2', 'EMP3', 'EMP4', 'EMP5'],
|
||||
'DEPTNAME': ['QC', 'PD', 'QC', 'RUN', 'QC'],
|
||||
'COMMENT_HOLD': ['C1', 'C2', 'C3', 'C4', 'C5'],
|
||||
'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'],
|
||||
})
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.get_cached_sys_date', return_value='2026-02-10 10:00:00')
|
||||
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
|
||||
def test_get_hold_detail_summary_supports_optional_reason_and_hold_type(
|
||||
self,
|
||||
mock_cached_wip,
|
||||
_mock_sys_date,
|
||||
):
|
||||
mock_cached_wip.return_value = self._sample_hold_df()
|
||||
|
||||
reason_summary = get_hold_detail_summary(reason='品質確認')
|
||||
self.assertEqual(reason_summary['totalLots'], 2)
|
||||
self.assertEqual(reason_summary['totalQty'], 180)
|
||||
self.assertEqual(reason_summary['workcenterCount'], 1)
|
||||
self.assertEqual(reason_summary['dataUpdateDate'], '2026-02-10 10:00:00')
|
||||
|
||||
quality_summary = get_hold_detail_summary(hold_type='quality')
|
||||
self.assertEqual(quality_summary['totalLots'], 3)
|
||||
self.assertEqual(quality_summary['totalQty'], 200)
|
||||
self.assertEqual(quality_summary['workcenterCount'], 2)
|
||||
|
||||
all_hold_summary = get_hold_detail_summary()
|
||||
self.assertEqual(all_hold_summary['totalLots'], 4)
|
||||
self.assertEqual(all_hold_summary['totalQty'], 250)
|
||||
self.assertEqual(all_hold_summary['workcenterCount'], 3)
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
|
||||
def test_get_hold_detail_lots_returns_hold_reason_and_treemap_filter(self, mock_cached_wip):
|
||||
mock_cached_wip.return_value = self._sample_hold_df()
|
||||
|
||||
reason_result = get_hold_detail_lots(reason='品質確認', page=1, page_size=10)
|
||||
self.assertEqual(len(reason_result['lots']), 2)
|
||||
self.assertEqual(reason_result['lots'][0]['lotId'], 'L3')
|
||||
self.assertEqual(reason_result['lots'][0]['holdReason'], '品質確認')
|
||||
|
||||
treemap_result = get_hold_detail_lots(
|
||||
reason=None,
|
||||
hold_type=None,
|
||||
treemap_reason='特殊需求管控',
|
||||
page=1,
|
||||
page_size=10,
|
||||
)
|
||||
self.assertEqual(len(treemap_result['lots']), 1)
|
||||
self.assertEqual(treemap_result['lots'][0]['lotId'], 'L2')
|
||||
self.assertEqual(treemap_result['lots'][0]['holdReason'], '特殊需求管控')
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
|
||||
def test_get_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()
|
||||
|
||||
hold_quality_all = get_wip_matrix(status='HOLD', hold_type='quality')
|
||||
self.assertEqual(hold_quality_all['grand_total'], 200)
|
||||
|
||||
hold_quality_reason = get_wip_matrix(
|
||||
status='HOLD',
|
||||
hold_type='quality',
|
||||
reason='品質確認',
|
||||
)
|
||||
self.assertEqual(hold_quality_reason['grand_total'], 180)
|
||||
self.assertEqual(hold_quality_reason['workcenters'], ['WC-A'])
|
||||
|
||||
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
|
||||
def test_get_hold_overview_treemap_groups_by_workcenter_and_reason(self, mock_cached_wip):
|
||||
mock_cached_wip.return_value = self._sample_hold_df()
|
||||
|
||||
result = get_hold_overview_treemap(hold_type='quality')
|
||||
self.assertIsNotNone(result)
|
||||
items = result['items']
|
||||
self.assertEqual(len(items), 2)
|
||||
expected = {(item['workcenter'], item['reason']): item for item in items}
|
||||
self.assertEqual(expected[('WC-A', '品質確認')]['lots'], 2)
|
||||
self.assertEqual(expected[('WC-A', '品質確認')]['qty'], 180)
|
||||
self.assertAlmostEqual(expected[('WC-A', '品質確認')]['avgAge'], 3.5)
|
||||
self.assertEqual(expected[('WC-C', '設備異常')]['lots'], 1)
|
||||
|
||||
|
||||
class TestHoldOverviewServiceOracleFallback(unittest.TestCase):
|
||||
"""Test reason filtering behavior on Oracle fallback path."""
|
||||
|
||||
@disable_cache
|
||||
@patch('mes_dashboard.services.wip_service.read_sql_df')
|
||||
def test_get_wip_matrix_oracle_applies_reason_for_hold_status(self, mock_read_sql):
|
||||
mock_read_sql.return_value = pd.DataFrame()
|
||||
|
||||
get_wip_matrix(status='HOLD', reason='品質確認')
|
||||
|
||||
call_args = mock_read_sql.call_args
|
||||
sql = call_args[0][0]
|
||||
params = call_args[0][1] if len(call_args[0]) > 1 else {}
|
||||
self.assertIn('HOLDREASONNAME', sql)
|
||||
self.assertTrue(any(v == '品質確認' for v in params.values()))
|
||||
|
||||
@disable_cache
|
||||
@patch('mes_dashboard.services.wip_service.read_sql_df')
|
||||
def test_get_wip_matrix_oracle_ignores_reason_for_non_hold_status(self, mock_read_sql):
|
||||
mock_read_sql.return_value = pd.DataFrame()
|
||||
|
||||
get_wip_matrix(status='RUN', reason='品質確認')
|
||||
|
||||
call_args = mock_read_sql.call_args
|
||||
sql = call_args[0][0]
|
||||
self.assertNotIn('HOLDREASONNAME', sql)
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestWipServiceIntegration:
|
||||
|
||||
Reference in New Issue
Block a user