feat(reject-history): fix silent data loss by propagating partial failure metadata to frontend

Chunk failures in BatchQueryEngine were silently discarded — `has_partial_failure` was tracked
in Redis but never surfaced to the API response or frontend. Users could see incomplete data
without any warning. This commit closes the gap end-to-end:

Backend:
- Track failed chunk time ranges (`failed_ranges`) in batch engine progress metadata
- Add single retry for transient Oracle errors (timeout, connection) in `_execute_single_chunk`
- Read `get_batch_progress()` after merge but before `redis_clear_batch()` cleanup
- Inject `has_partial_failure`, `failed_chunk_count`, `failed_ranges` into API response meta
- Persist partial failure flag to independent Redis key with TTL aligned to data storage layer
- Add shared container-resolution policy module with wildcard/expansion guardrails
- Refactor reason filter from single-value to multi-select (`reason` → `reasons`)

Frontend:
- Add client-side date range validation (730-day limit) before API submission
- Display amber warning banner on partial failure with specific failed date ranges
- Support generic fallback message for container-mode queries without date ranges
- Update FilterPanel to support multi-select reason chips

Specs & tests:
- Create batch-query-resilience spec; update reject-history-api and reject-history-page specs
- Add 7 new tests for retry, memory guard, failed ranges, partial failure propagation, TTL
- Cross-service regression verified (hold, resource, job, msd — 411 tests pass)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-03-03 14:00:07 +08:00
parent f1506787fb
commit a275c30c0e
35 changed files with 3028 additions and 1460 deletions

View File

@@ -35,7 +35,7 @@ export function toRejectFilterSnapshot(input = {}) {
endDate: normalizeText(input.endDate),
workcenterGroups: normalizeArray(input.workcenterGroups),
packages: normalizeArray(input.packages),
reason: normalizeText(input.reason),
reasons: normalizeArray(input.reasons),
includeExcludedScrap: normalizeBoolean(input.includeExcludedScrap, false),
excludeMaterialScrap: normalizeBoolean(input.excludeMaterialScrap, true),
excludePbDiode: normalizeBoolean(input.excludePbDiode, true),
@@ -77,7 +77,7 @@ export function pruneRejectFilterSelections(filters = {}, options = {}) {
const removed = {
workcenterGroups: [],
packages: [],
reason: '',
reasons: [],
};
if (hasWorkcenterOptions) {
@@ -100,9 +100,14 @@ export function pruneRejectFilterSelections(filters = {}, options = {}) {
});
}
if (next.reason && hasReasonOptions && !validReasons.has(next.reason)) {
removed.reason = next.reason;
next.reason = '';
if (hasReasonOptions) {
next.reasons = next.reasons.filter((value) => {
if (validReasons.has(value)) {
return true;
}
removed.reasons.push(value);
return false;
});
}
return {
@@ -111,7 +116,7 @@ export function pruneRejectFilterSelections(filters = {}, options = {}) {
removedCount:
removed.workcenterGroups.length +
removed.packages.length +
(removed.reason ? 1 : 0),
removed.reasons.length,
};
}
@@ -126,13 +131,13 @@ export function buildRejectOptionsRequestParams(filters = {}) {
exclude_material_scrap: next.excludeMaterialScrap,
exclude_pb_diode: next.excludePbDiode,
};
if (next.reason) {
params.reason = next.reason;
if (next.reasons.length > 0) {
params.reasons = next.reasons;
}
return params;
}
export function buildRejectCommonQueryParams(filters = {}, { reason = '' } = {}) {
export function buildRejectCommonQueryParams(filters = {}, { reasons: extraReasons = [] } = {}) {
const next = toRejectFilterSnapshot(filters);
const params = {
start_date: next.startDate,
@@ -143,9 +148,9 @@ export function buildRejectCommonQueryParams(filters = {}, { reason = '' } = {})
exclude_material_scrap: next.excludeMaterialScrap,
exclude_pb_diode: next.excludePbDiode,
};
const effectiveReason = normalizeText(reason) || next.reason;
if (effectiveReason) {
params.reasons = [effectiveReason];
const merged = normalizeArray([...next.reasons, ...normalizeArray(extraReasons)]);
if (merged.length > 0) {
params.reasons = merged;
}
return params;
}
@@ -168,6 +173,30 @@ export function parseMultiLineInput(text) {
return result;
}
export function validateDateRange(startDate, endDate) {
const MAX_QUERY_DAYS = 730;
const start = normalizeText(startDate);
const end = normalizeText(endDate);
if (!start || !end) {
return '請先設定開始與結束日期';
}
const startDt = new Date(`${start}T00:00:00`);
const endDt = new Date(`${end}T00:00:00`);
if (Number.isNaN(startDt.getTime()) || Number.isNaN(endDt.getTime())) {
return '日期格式不正確';
}
if (endDt < startDt) {
return '結束日期必須大於起始日期';
}
const dayMs = 24 * 60 * 60 * 1000;
const days = Math.floor((endDt - startDt) / dayMs) + 1;
if (days > MAX_QUERY_DAYS) {
return '查詢範圍不可超過 730 天(約兩年)';
}
return '';
}
export function buildViewParams(queryId, {
supplementaryFilters = {},
metricFilter = 'all',
@@ -185,8 +214,8 @@ export function buildViewParams(queryId, {
if (supplementaryFilters.workcenterGroups?.length > 0) {
params.workcenter_groups = supplementaryFilters.workcenterGroups;
}
if (supplementaryFilters.reason) {
params.reason = supplementaryFilters.reason;
if (supplementaryFilters.reasons?.length > 0) {
params.reasons = supplementaryFilters.reasons;
}
if (metricFilter && metricFilter !== 'all') {
params.metric_filter = metricFilter;

View File

@@ -5,6 +5,7 @@ import { apiGet, apiPost } from '../core/api.js';
import {
buildViewParams,
parseMultiLineInput,
validateDateRange,
} from '../core/reject-history-filters.js';
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
@@ -104,14 +105,14 @@ const availableFilters = ref({ workcenterGroups: [], packages: [], reasons: [] }
const supplementaryFilters = reactive({
packages: [],
workcenterGroups: [],
reason: '',
reasons: [],
});
// ---- Interactive state ----
const page = ref(1);
const selectedTrendDates = ref([]);
const trendLegendSelected = ref({ '扣帳報廢量': true, '不扣帳報廢量': true });
const paretoDisplayScope = ref('all');
const paretoDisplayScope = ref('top20');
const paretoSelections = reactive(createEmptyParetoSelections());
const paretoData = reactive(createEmptyParetoData());
@@ -146,6 +147,7 @@ const loading = reactive({
exporting: false,
});
const errorMessage = ref('');
const partialFailureWarning = ref('');
const lastQueryAt = ref('');
// ---- Request staleness tracking ----
@@ -241,8 +243,8 @@ function buildBatchParetoParams() {
if (supplementaryFilters.workcenterGroups.length > 0) {
params.workcenter_groups = supplementaryFilters.workcenterGroups;
}
if (supplementaryFilters.reason) {
params.reason = supplementaryFilters.reason;
if (supplementaryFilters.reasons.length > 0) {
params.reasons = supplementaryFilters.reasons;
}
if (selectedTrendDates.value.length > 0) {
params.trend_dates = selectedTrendDates.value;
@@ -301,11 +303,20 @@ async function executePrimaryQuery() {
loading.querying = true;
loading.list = true;
errorMessage.value = '';
partialFailureWarning.value = '';
try {
const body = { mode: queryMode.value };
if (queryMode.value === 'date_range') {
const dateValidationError = validateDateRange(
draftFilters.startDate,
draftFilters.endDate,
);
if (dateValidationError) {
errorMessage.value = dateValidationError;
return;
}
body.start_date = draftFilters.startDate;
body.end_date = draftFilters.endDate;
} else {
@@ -321,6 +332,19 @@ async function executePrimaryQuery() {
if (isStaleRequest(requestId)) return;
const result = unwrapApiResult(resp, '主查詢執行失敗');
const meta = result.meta || {};
if (meta.has_partial_failure) {
const failedChunkCount = Number(meta.failed_chunk_count || 0);
const failedRanges = Array.isArray(meta.failed_ranges) ? meta.failed_ranges : [];
if (failedRanges.length > 0) {
const rangesText = failedRanges
.map((item) => `${item.start} ~ ${item.end}`)
.join('、');
partialFailureWarning.value = `警告:以下日期區間的資料擷取失敗(${failedChunkCount} 個批次):${rangesText}。目前顯示結果可能不完整。`;
} else {
partialFailureWarning.value = `警告:${failedChunkCount} 個查詢批次的資料擷取失敗。目前顯示結果可能不完整。`;
}
}
committedPrimary.mode = queryMode.value;
committedPrimary.startDate = draftFilters.startDate;
@@ -344,7 +368,7 @@ async function executePrimaryQuery() {
supplementaryFilters.packages = [];
supplementaryFilters.workcenterGroups = [];
supplementaryFilters.reason = '';
supplementaryFilters.reasons = [];
page.value = 1;
selectedTrendDates.value = [];
resetParetoSelections();
@@ -445,7 +469,7 @@ function clearFilters() {
draftFilters.excludeMaterialScrap = true;
draftFilters.excludePbDiode = true;
draftFilters.paretoTop80 = true;
paretoDisplayScope.value = 'all';
paretoDisplayScope.value = 'top20';
resetParetoSelections();
void executePrimaryQuery();
}
@@ -520,7 +544,7 @@ function clearParetoSelection() {
function onSupplementaryChange(filters) {
supplementaryFilters.packages = filters.packages || [];
supplementaryFilters.workcenterGroups = filters.workcenterGroups || [];
supplementaryFilters.reason = filters.reason || '';
supplementaryFilters.reasons = filters.reasons || [];
page.value = 1;
selectedTrendDates.value = [];
resetParetoSelections();
@@ -545,7 +569,7 @@ function removeFilterChip(chip) {
}
if (chip.type === 'reason') {
supplementaryFilters.reason = '';
supplementaryFilters.reasons = supplementaryFilters.reasons.filter((r) => r !== chip.value);
page.value = 1;
updateUrlState();
void Promise.all([refreshView(), fetchBatchPareto()]);
@@ -584,7 +608,7 @@ async function exportCsv() {
params.set('query_id', queryId.value);
for (const pkg of supplementaryFilters.packages) params.append('packages', pkg);
for (const wc of supplementaryFilters.workcenterGroups) params.append('workcenter_groups', wc);
if (supplementaryFilters.reason) params.set('reason', supplementaryFilters.reason);
for (const r of supplementaryFilters.reasons) params.append('reasons', r);
params.set('metric_filter', metricFilterParam());
for (const date of selectedTrendDates.value) params.append('trend_dates', date);
for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) {
@@ -760,13 +784,13 @@ const activeFilterChips = computed(() => {
value: '',
});
if (supplementaryFilters.reason) {
for (const reason of supplementaryFilters.reasons) {
chips.push({
key: `reason:${supplementaryFilters.reason}`,
label: `原因: ${supplementaryFilters.reason}`,
key: `reason:${reason}`,
label: `原因: ${reason}`,
removable: true,
type: 'reason',
value: supplementaryFilters.reason,
value: reason,
});
}
@@ -866,16 +890,14 @@ function updateUrlState() {
appendArrayParams(params, 'packages', supplementaryFilters.packages);
appendArrayParams(params, 'workcenter_groups', supplementaryFilters.workcenterGroups);
if (supplementaryFilters.reason) {
params.set('reason', supplementaryFilters.reason);
}
appendArrayParams(params, 'reasons', supplementaryFilters.reasons);
appendArrayParams(params, 'trend_dates', selectedTrendDates.value);
for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) {
appendArrayParams(params, key, paretoSelections[dimension] || []);
}
if (paretoDisplayScope.value !== 'all') {
if (paretoDisplayScope.value !== 'top20') {
params.set('pareto_display_scope', paretoDisplayScope.value);
}
if (!committedPrimary.paretoTop80) {
@@ -945,7 +967,7 @@ function restoreFromUrl() {
supplementaryFilters.packages = readArrayParam(params, 'packages');
supplementaryFilters.workcenterGroups = readArrayParam(params, 'workcenter_groups');
supplementaryFilters.reason = String(params.get('reason') || '').trim();
supplementaryFilters.reasons = readArrayParam(params, 'reasons');
selectedTrendDates.value = readArrayParam(params, 'trend_dates');
@@ -969,7 +991,7 @@ function restoreFromUrl() {
}
const urlParetoDisplayScope = String(params.get('pareto_display_scope') || '').trim().toLowerCase();
paretoDisplayScope.value = urlParetoDisplayScope === 'top20' ? 'top20' : 'all';
paretoDisplayScope.value = urlParetoDisplayScope === 'all' ? 'all' : 'top20';
const parsedPage = Number(params.get('page') || '1');
page.value = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
@@ -1001,6 +1023,9 @@ onMounted(() => {
</header>
<div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div>
<div v-if="partialFailureWarning" class="warning-banner">
{{ partialFailureWarning }}
</div>
<FilterPanel
:filters="draftFilters"

View File

@@ -8,23 +8,23 @@ const props = defineProps({
containerInput: { type: String, default: '' },
availableFilters: { type: Object, default: () => ({}) },
supplementaryFilters: { type: Object, default: () => ({}) },
queryId: { type: String, default: '' },
resolutionInfo: { type: Object, default: null },
loading: { type: Object, required: true },
activeFilterChips: { type: Array, default: () => [] },
paretoDisplayScope: { type: String, default: 'all' },
});
queryId: { type: String, default: '' },
resolutionInfo: { type: Object, default: null },
loading: { type: Object, required: true },
activeFilterChips: { type: Array, default: () => [] },
paretoDisplayScope: { type: String, default: 'all' },
});
const emit = defineEmits([
'apply',
'clear',
'export-csv',
'remove-chip',
'pareto-scope-toggle',
'pareto-display-scope-change',
'update:queryMode',
'update:containerInputType',
'update:containerInput',
'remove-chip',
'pareto-scope-toggle',
'pareto-display-scope-change',
'update:queryMode',
'update:containerInputType',
'update:containerInput',
'supplementary-change',
]);
@@ -32,7 +32,7 @@ function emitSupplementary(patch) {
emit('supplementary-change', {
packages: props.supplementaryFilters.packages || [],
workcenterGroups: props.supplementaryFilters.workcenterGroups || [],
reason: props.supplementaryFilters.reason || '',
reasons: props.supplementaryFilters.reasons || [],
...patch,
});
}
@@ -86,23 +86,23 @@ function emitSupplementary(patch) {
<!-- Container mode -->
<template v-else>
<div class="filter-group">
<label class="filter-label" for="container-type">輸入類型</label>
<select
id="container-type"
class="filter-input"
:value="containerInputType"
@change="$emit('update:containerInputType', $event.target.value)"
>
<option value="lot">LOT</option>
<option value="work_order">工單</option>
<option value="wafer_lot">WAFER LOT</option>
</select>
</div>
<div class="filter-group filter-group-wide">
<label class="filter-label" for="container-input"
>輸入值 (每行一個支援 * % wildcard)</label
>
<div class="filter-group filter-group-full container-input-group">
<div class="container-label-row">
<label class="filter-label" for="container-type">輸入類型</label>
<select
id="container-type"
class="filter-input container-type-select"
:value="containerInputType"
@change="$emit('update:containerInputType', $event.target.value)"
>
<option value="lot">LOT</option>
<option value="work_order">工單</option>
<option value="wafer_lot">WAFER LOT</option>
</select>
<label class="filter-label" for="container-input"
>輸入值 (每行一個支援 * % wildcard)</label
>
</div>
<textarea
id="container-input"
class="filter-input filter-textarea"
@@ -124,12 +124,12 @@ function emitSupplementary(patch) {
<input v-model="filters.excludeMaterialScrap" type="checkbox" />
排除原物料報廢
</label>
<label class="checkbox-pill">
<input v-model="filters.excludePbDiode" type="checkbox" />
排除 PB_* 系列
</label>
</div>
<div class="filter-actions">
<label class="checkbox-pill">
<input v-model="filters.excludePbDiode" type="checkbox" />
排除 PB_* 系列
</label>
</div>
<div class="filter-actions">
<button
class="btn btn-primary"
:disabled="loading.querying"
@@ -181,30 +181,30 @@ function emitSupplementary(patch) {
</template>
</div>
<!-- Supplementary filters (only after primary query) -->
<div v-if="queryId" class="supplementary-panel">
<div class="supplementary-header">補充篩選 (快取內篩選)</div>
<div class="supplementary-toolbar">
<label class="checkbox-pill">
<input
:checked="filters.paretoTop80"
type="checkbox"
@change="$emit('pareto-scope-toggle', $event.target.checked)"
/>
Pareto 僅顯示累計前 80%
</label>
<label class="filter-label">顯示範圍</label>
<select
class="dimension-select pareto-scope-select"
:value="paretoDisplayScope"
@change="$emit('pareto-display-scope-change', $event.target.value)"
>
<option value="all">全部顯示</option>
<option value="top20">只顯示 TOP 20</option>
</select>
</div>
<div class="supplementary-row">
<div class="filter-group">
<!-- Supplementary filters (only after primary query) -->
<div v-if="queryId" class="supplementary-panel">
<div class="supplementary-header">補充篩選 (快取內篩選)</div>
<div class="supplementary-toolbar">
<label class="checkbox-pill">
<input
:checked="filters.paretoTop80"
type="checkbox"
@change="$emit('pareto-scope-toggle', $event.target.checked)"
/>
Pareto 僅顯示累計前 80%
</label>
<label class="filter-label">顯示範圍</label>
<select
class="dimension-select pareto-scope-select"
:value="paretoDisplayScope"
@change="$emit('pareto-display-scope-change', $event.target.value)"
>
<option value="all">全部顯示</option>
<option value="top20">只顯示 TOP 20</option>
</select>
</div>
<div class="supplementary-row">
<div class="filter-group">
<label class="filter-label">WORKCENTER GROUP</label>
<MultiSelect
:model-value="supplementaryFilters.workcenterGroups"
@@ -227,22 +227,14 @@ function emitSupplementary(patch) {
</div>
<div class="filter-group">
<label class="filter-label" for="supp-reason">報廢原因</label>
<select
id="supp-reason"
class="filter-input"
:value="supplementaryFilters.reason"
@change="emitSupplementary({ reason: $event.target.value })"
>
<option value="">全部原因</option>
<option
v-for="r in availableFilters.reasons || []"
:key="r"
:value="r"
>
{{ r }}
</option>
</select>
<label class="filter-label">報廢原因</label>
<MultiSelect
:model-value="supplementaryFilters.reasons"
:options="availableFilters.reasons || []"
placeholder="全部原因"
searchable
@update:model-value="emitSupplementary({ reasons: $event })"
/>
</div>
</div>
</div>

View File

@@ -41,6 +41,19 @@
line-height: 1.5;
}
.container-label-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.container-type-select {
width: auto;
min-width: 120px;
max-width: 180px;
}
.supplementary-panel {
border-top: 1px solid var(--border);
padding: 16px 18px;
@@ -119,6 +132,15 @@
font-size: 13px;
}
.warning-banner {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 6px;
background: #fffbeb;
color: #b45309;
font-size: 13px;
}
.filter-panel {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));