fix(hold-history): align KPI cards with trend data, improve filters and UX across pages
Use Daily Trend as single source of truth for On Hold and New Hold KPI cards instead of separate snapshot SQL queries, eliminating value mismatches. Fix timezone bug in default date range (toISOString UTC offset), add 1st-of-month fallback to previous month, replace Hold Type radio buttons with select dropdown, reorder/relabel summary cards with 累計 prefix, add job-query MultiSelect for equipment filter, and fix heatmap chart X-axis overlap with visualMap legend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,14 +15,14 @@
|
|||||||
{
|
{
|
||||||
"route": "/hold-overview",
|
"route": "/hold-overview",
|
||||||
"name": "Hold 即時概況",
|
"name": "Hold 即時概況",
|
||||||
"status": "dev",
|
"status": "released",
|
||||||
"drawer_id": "reports",
|
"drawer_id": "reports",
|
||||||
"order": 2
|
"order": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/hold-history",
|
"route": "/hold-history",
|
||||||
"name": "Hold 歷史績效",
|
"name": "Hold 歷史績效",
|
||||||
"status": "dev",
|
"status": "released",
|
||||||
"drawer_id": "drawer-2",
|
"drawer_id": "drawer-2",
|
||||||
"order": 3
|
"order": 3
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ const errorMessage = ref('');
|
|||||||
let activeRequestId = 0;
|
let activeRequestId = 0;
|
||||||
|
|
||||||
function toDateString(value) {
|
function toDateString(value) {
|
||||||
return value.toISOString().slice(0, 10);
|
const y = value.getFullYear();
|
||||||
|
const m = String(value.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(value.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUrlParam(name) {
|
function getUrlParam(name) {
|
||||||
@@ -75,8 +78,19 @@ function parseRecordTypeCsv(value) {
|
|||||||
|
|
||||||
function setDefaultDateRange() {
|
function setDefaultDateRange() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
let year = now.getFullYear();
|
||||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
let month = now.getMonth();
|
||||||
|
|
||||||
|
if (now.getDate() === 1) {
|
||||||
|
month -= 1;
|
||||||
|
if (month < 0) {
|
||||||
|
month = 11;
|
||||||
|
year -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(year, month, 1);
|
||||||
|
const end = new Date(year, month + 1, 0);
|
||||||
filterBar.startDate = toDateString(start);
|
filterBar.startDate = toDateString(start);
|
||||||
filterBar.endDate = toDateString(end);
|
filterBar.endDate = toDateString(end);
|
||||||
}
|
}
|
||||||
@@ -359,14 +373,18 @@ const summary = computed(() => {
|
|||||||
const netChange = releaseQty - newHoldQty - futureHoldQty;
|
const netChange = releaseQty - newHoldQty - futureHoldQty;
|
||||||
const avgHoldHours = estimateAvgHoldHours(durationData.value?.items || []);
|
const avgHoldHours = estimateAvgHoldHours(durationData.value?.items || []);
|
||||||
|
|
||||||
const counts = trendData.value?.stillOnHoldCount || {};
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const stillOnHoldCount = Number(counts[trendTypeKey.value] || 0);
|
const pastDays = days.filter((d) => d.date <= today);
|
||||||
|
const lastDay = pastDays.length > 0 ? pastDays[pastDays.length - 1] : {};
|
||||||
|
const stillOnHoldCount = Number(lastDay.holdQty || 0);
|
||||||
|
const newHoldSnapshotCount = Number(lastDay.newHoldQty || 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
releaseQty,
|
releaseQty,
|
||||||
newHoldQty,
|
newHoldQty,
|
||||||
futureHoldQty,
|
futureHoldQty,
|
||||||
stillOnHoldCount,
|
stillOnHoldCount,
|
||||||
|
newHoldSnapshotCount,
|
||||||
netChange,
|
netChange,
|
||||||
avgHoldHours,
|
avgHoldHours,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -83,21 +83,17 @@ const holdTypeModel = computed({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-group hold-type-group">
|
<div class="filter-group hold-type-group">
|
||||||
<span class="filter-label">Hold Type</span>
|
<label class="filter-label" for="hold-history-hold-type">Hold Type</label>
|
||||||
<div class="radio-group">
|
<select
|
||||||
<label class="radio-option" :class="{ active: holdTypeModel === 'quality' }">
|
id="hold-history-hold-type"
|
||||||
<input v-model="holdTypeModel" type="radio" value="quality" :disabled="disabled" />
|
v-model="holdTypeModel"
|
||||||
<span>品質異常</span>
|
class="hold-type-select"
|
||||||
</label>
|
:disabled="disabled"
|
||||||
<label class="radio-option" :class="{ active: holdTypeModel === 'non-quality' }">
|
>
|
||||||
<input v-model="holdTypeModel" type="radio" value="non-quality" :disabled="disabled" />
|
<option value="quality">品質異常</option>
|
||||||
<span>非品質異常</span>
|
<option value="non-quality">非品質異常</option>
|
||||||
</label>
|
<option value="all">全部</option>
|
||||||
<label class="radio-option" :class="{ active: holdTypeModel === 'all' }">
|
</select>
|
||||||
<input v-model="holdTypeModel" type="radio" value="all" :disabled="disabled" />
|
|
||||||
<span>全部</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const props = defineProps({
|
|||||||
newHoldQty: 0,
|
newHoldQty: 0,
|
||||||
futureHoldQty: 0,
|
futureHoldQty: 0,
|
||||||
stillOnHoldCount: 0,
|
stillOnHoldCount: 0,
|
||||||
|
newHoldSnapshotCount: 0,
|
||||||
netChange: 0,
|
netChange: 0,
|
||||||
avgHoldHours: 0,
|
avgHoldHours: 0,
|
||||||
}),
|
}),
|
||||||
@@ -24,28 +25,33 @@ function formatHours(value) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="summary-row hold-history-summary-row">
|
<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">
|
<article class="summary-card stat-negative-red">
|
||||||
<div class="summary-label">On Hold 數量</div>
|
<div class="summary-label">On Hold 數量</div>
|
||||||
<div class="summary-value">{{ formatNumber(summary.stillOnHoldCount) }}</div>
|
<div class="summary-value">{{ formatNumber(summary.stillOnHoldCount) }}</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="summary-card stat-negative-orange">
|
||||||
|
<div class="summary-label">最末日新增 Hold</div>
|
||||||
|
<div class="summary-value">{{ formatNumber(summary.newHoldSnapshotCount) }}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="summary-card stat-negative-red">
|
||||||
|
<div class="summary-label">累計新增 Hold</div>
|
||||||
|
<div class="summary-value">{{ formatNumber(summary.newHoldQty) }}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<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-orange">
|
||||||
|
<div class="summary-label">累計 Future Hold</div>
|
||||||
|
<div class="summary-value">{{ formatNumber(summary.futureHoldQty) }}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="summary-card">
|
<article class="summary-card">
|
||||||
<div class="summary-label">淨變動 (Release - New - Future)</div>
|
<div class="summary-label">累計淨變動 (Release - New - Future)</div>
|
||||||
<div class="summary-value" :class="{ positive: summary.netChange >= 0, negative: summary.netChange < 0 }">
|
<div class="summary-value" :class="{ positive: summary.netChange >= 0, negative: summary.netChange < 0 }">
|
||||||
{{ formatNumber(summary.netChange) }}
|
{{ formatNumber(summary.netChange) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +59,7 @@ function formatHours(value) {
|
|||||||
|
|
||||||
<article class="summary-card">
|
<article class="summary-card">
|
||||||
<div class="summary-label">平均 Hold 時長</div>
|
<div class="summary-label">平均 Hold 時長</div>
|
||||||
<div class="summary-value small">{{ formatHours(summary.avgHoldHours) }}</div>
|
<div class="summary-value">{{ formatHours(summary.avgHoldHours) }}</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -85,14 +85,27 @@
|
|||||||
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18);
|
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-group,
|
.hold-type-select {
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hold-type-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0ea5e9;
|
||||||
|
box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-group {
|
.checkbox-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-option,
|
|
||||||
.checkbox-option {
|
.checkbox-option {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -105,7 +118,6 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-option.active,
|
|
||||||
.checkbox-option.active {
|
.checkbox-option.active {
|
||||||
border-color: #0284c7;
|
border-color: #0284c7;
|
||||||
background: #e0f2fe;
|
background: #e0f2fe;
|
||||||
@@ -120,7 +132,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hold-history-summary-row {
|
.hold-history-summary-row {
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card {
|
.summary-card {
|
||||||
@@ -290,7 +302,7 @@
|
|||||||
|
|
||||||
@media (max-width: 1440px) {
|
@media (max-width: 1440px) {
|
||||||
.hold-history-summary-row {
|
.hold-history-summary-row {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue';
|
import { computed, onMounted } from 'vue';
|
||||||
|
|
||||||
|
import MultiSelect from '../resource-shared/components/MultiSelect.vue';
|
||||||
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
|
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
|
||||||
import SectionCard from '../shared-ui/components/SectionCard.vue';
|
import SectionCard from '../shared-ui/components/SectionCard.vue';
|
||||||
import StatusBadge from '../shared-ui/components/StatusBadge.vue';
|
import StatusBadge from '../shared-ui/components/StatusBadge.vue';
|
||||||
@@ -20,18 +21,23 @@ const {
|
|||||||
selectedJobId,
|
selectedJobId,
|
||||||
txnRows,
|
txnRows,
|
||||||
txnColumns,
|
txnColumns,
|
||||||
filteredResources,
|
|
||||||
selectedResourceCount,
|
selectedResourceCount,
|
||||||
resetDateRangeToLast90Days,
|
resetDateRangeToLast90Days,
|
||||||
hydrateFiltersFromUrl,
|
hydrateFiltersFromUrl,
|
||||||
loadResources,
|
loadResources,
|
||||||
toggleResource,
|
|
||||||
queryJobs,
|
queryJobs,
|
||||||
loadTxn,
|
loadTxn,
|
||||||
exportCsv,
|
exportCsv,
|
||||||
getStatusTone,
|
getStatusTone,
|
||||||
} = useJobQueryData();
|
} = useJobQueryData();
|
||||||
|
|
||||||
|
const resourceOptions = computed(() =>
|
||||||
|
resources.value.map((item) => ({
|
||||||
|
value: item.RESOURCEID,
|
||||||
|
label: item.RESOURCENAME || item.RESOURCEID,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
function formatCellValue(value) {
|
function formatCellValue(value) {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return '-';
|
return '-';
|
||||||
@@ -77,8 +83,15 @@ onMounted(async () => {
|
|||||||
<input v-model="filters.endDate" type="date" />
|
<input v-model="filters.endDate" type="date" />
|
||||||
</label>
|
</label>
|
||||||
<label class="job-query-filter">
|
<label class="job-query-filter">
|
||||||
<span>設備搜尋</span>
|
<span>設備(複選)</span>
|
||||||
<input v-model="filters.searchText" type="text" placeholder="輸入設備/站點/群組..." />
|
<MultiSelect
|
||||||
|
:model-value="filters.resourceIds"
|
||||||
|
:options="resourceOptions"
|
||||||
|
:disabled="loadingResources"
|
||||||
|
placeholder="全部設備"
|
||||||
|
searchable
|
||||||
|
@update:model-value="filters.resourceIds = $event"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
@@ -90,27 +103,6 @@ onMounted(async () => {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</FilterToolbar>
|
</FilterToolbar>
|
||||||
|
|
||||||
<div class="job-query-resource-grid">
|
|
||||||
<div v-if="loadingResources" class="job-query-empty">載入設備中...</div>
|
|
||||||
<label
|
|
||||||
v-for="resource in filteredResources"
|
|
||||||
:key="resource.RESOURCEID"
|
|
||||||
class="job-query-resource"
|
|
||||||
:class="{ selected: filters.resourceIds.includes(resource.RESOURCEID) }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="filters.resourceIds.includes(resource.RESOURCEID)"
|
|
||||||
@change="toggleResource(resource.RESOURCEID)"
|
|
||||||
/>
|
|
||||||
<div class="job-query-resource-meta">
|
|
||||||
<strong>{{ resource.RESOURCENAME || resource.RESOURCEID }}</strong>
|
|
||||||
<span>{{ resource.WORKCENTERNAME || '-' }} / {{ resource.RESOURCEFAMILYNAME || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<div v-if="!loadingResources && filteredResources.length === 0" class="job-query-empty">無可用設備</div>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="job-query-error">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="job-query-error">{{ errorMessage }}</p>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
|
|||||||
),
|
),
|
||||||
'/job-query': createNativeLoader(
|
'/job-query': createNativeLoader(
|
||||||
() => import('../job-query/App.vue'),
|
() => import('../job-query/App.vue'),
|
||||||
[() => import('../job-query/style.css')],
|
[() => import('../resource-shared/styles.css'), () => import('../job-query/style.css')],
|
||||||
),
|
),
|
||||||
'/excel-query': createNativeLoader(
|
'/excel-query': createNativeLoader(
|
||||||
() => import('../excel-query/App.vue'),
|
() => import('../excel-query/App.vue'),
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const chartOption = computed(() => {
|
|||||||
left: 110,
|
left: 110,
|
||||||
right: 20,
|
right: 20,
|
||||||
top: 20,
|
top: 20,
|
||||||
bottom: 64,
|
bottom: 100,
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
@@ -88,7 +88,7 @@ const chartOption = computed(() => {
|
|||||||
max: 100,
|
max: 100,
|
||||||
orient: 'horizontal',
|
orient: 'horizontal',
|
||||||
left: 'center',
|
left: 'center',
|
||||||
bottom: 10,
|
bottom: 4,
|
||||||
inRange: {
|
inRange: {
|
||||||
color: ['#ef4444', '#f59e0b', '#22c55e'],
|
color: ['#ef4444', '#f59e0b', '#22c55e'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from mes_dashboard.services.hold_history_service import (
|
|||||||
get_hold_history_list,
|
get_hold_history_list,
|
||||||
get_hold_history_reason_pareto,
|
get_hold_history_reason_pareto,
|
||||||
get_hold_history_trend,
|
get_hold_history_trend,
|
||||||
get_still_on_hold_count,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
hold_history_bp = Blueprint('hold_history', __name__)
|
hold_history_bp = Blueprint('hold_history', __name__)
|
||||||
@@ -114,9 +113,6 @@ def api_hold_history_trend():
|
|||||||
|
|
||||||
result = get_hold_history_trend(start_date, end_date)
|
result = get_hold_history_trend(start_date, end_date)
|
||||||
if result is not None:
|
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': True, 'data': result})
|
||||||
return jsonify({'success': False, 'error': '查詢失敗'}), 500
|
return jsonify({'success': False, 'error': '查詢失敗'}), 500
|
||||||
|
|
||||||
|
|||||||
@@ -341,28 +341,6 @@ def get_hold_history_trend(start_date: str, end_date: str) -> Optional[Dict[str,
|
|||||||
return None
|
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(
|
def get_hold_history_reason_pareto(
|
||||||
start_date: str,
|
start_date: str,
|
||||||
end_date: str,
|
end_date: str,
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -41,9 +41,8 @@ class TestHoldHistoryPageRoute(TestHoldHistoryRoutesBase):
|
|||||||
class TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase):
|
class TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase):
|
||||||
"""Test GET /api/hold-history/trend endpoint."""
|
"""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')
|
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_trend')
|
||||||
def test_trend_passes_date_range(self, mock_trend, mock_count):
|
def test_trend_passes_date_range(self, mock_trend):
|
||||||
mock_trend.return_value = {
|
mock_trend.return_value = {
|
||||||
'days': [
|
'days': [
|
||||||
{
|
{
|
||||||
@@ -54,16 +53,14 @@ class TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase):
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
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')
|
response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07')
|
||||||
payload = json.loads(response.data)
|
payload = json.loads(response.data)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTrue(payload['success'])
|
self.assertTrue(payload['success'])
|
||||||
self.assertEqual(payload['data']['stillOnHoldCount'], {'quality': 4, 'non_quality': 2, 'all': 6})
|
self.assertIn('days', payload['data'])
|
||||||
mock_trend.assert_called_once_with('2026-02-01', '2026-02-07')
|
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):
|
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')
|
response = self.client.get('/api/hold-history/trend?start_date=2026/02/01&end_date=2026-02-07')
|
||||||
@@ -72,10 +69,9 @@ class TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase):
|
|||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertFalse(payload['success'])
|
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.routes.hold_history_routes.get_hold_history_trend')
|
||||||
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 8))
|
@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):
|
def test_trend_rate_limited_returns_429(self, _mock_limit, mock_service):
|
||||||
response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07')
|
response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07')
|
||||||
payload = json.loads(response.data)
|
payload = json.loads(response.data)
|
||||||
|
|
||||||
|
|||||||
@@ -339,28 +339,6 @@ class TestHoldHistoryServiceFunctions(unittest.TestCase):
|
|||||||
self.assertEqual(result['pagination']['total'], 3)
|
self.assertEqual(result['pagination']['total'], 3)
|
||||||
self.assertEqual(result['pagination']['totalPages'], 2)
|
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):
|
def test_trend_sql_contains_shift_boundary_logic(self):
|
||||||
sql = hold_history_service._load_hold_history_sql('trend')
|
sql = hold_history_service._load_hold_history_sql('trend')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user