feat: archive filter strategy change and optimize reject-history filters

This commit is contained in:
egg
2026-02-22 12:50:05 +08:00
parent 7bf9e33cd5
commit 5e5cc487ac
27 changed files with 1764 additions and 396 deletions

View File

@@ -226,6 +226,9 @@ RUNTIME_CONTRACT_ENFORCE=false
# Health endpoint memo cache TTL in seconds # Health endpoint memo cache TTL in seconds
HEALTH_MEMO_TTL_SECONDS=5 HEALTH_MEMO_TTL_SECONDS=5
# Reject history options API cache TTL in seconds (default: 14400 = 4 hours)
REJECT_HISTORY_OPTIONS_CACHE_TTL_SECONDS=14400
# ============================================================ # ============================================================
# Runtime Resilience Diagnostics Thresholds # Runtime Resilience Diagnostics Thresholds
# ============================================================ # ============================================================

View File

@@ -0,0 +1,151 @@
function normalizeText(value) {
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
function normalizeArray(values) {
if (!Array.isArray(values)) {
return [];
}
const seen = new Set();
const result = [];
for (const item of values) {
const text = normalizeText(item);
if (!text || seen.has(text)) {
continue;
}
seen.add(text);
result.push(text);
}
return result;
}
function normalizeBoolean(value, fallback = false) {
if (value === undefined) {
return fallback;
}
return Boolean(value);
}
export function toRejectFilterSnapshot(input = {}) {
return {
startDate: normalizeText(input.startDate),
endDate: normalizeText(input.endDate),
workcenterGroups: normalizeArray(input.workcenterGroups),
packages: normalizeArray(input.packages),
reason: normalizeText(input.reason),
includeExcludedScrap: normalizeBoolean(input.includeExcludedScrap, false),
excludeMaterialScrap: normalizeBoolean(input.excludeMaterialScrap, true),
excludePbDiode: normalizeBoolean(input.excludePbDiode, true),
paretoTop80: normalizeBoolean(input.paretoTop80, true),
};
}
export function extractWorkcenterGroupValues(options = []) {
if (!Array.isArray(options)) {
return [];
}
const values = [];
const seen = new Set();
for (const option of options) {
let value = '';
if (option && typeof option === 'object') {
value = normalizeText(option.name || option.value || option.label);
} else {
value = normalizeText(option);
}
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
values.push(value);
}
return values;
}
export function pruneRejectFilterSelections(filters = {}, options = {}) {
const next = toRejectFilterSnapshot(filters);
const hasWorkcenterOptions = Array.isArray(options.workcenterGroups);
const hasPackageOptions = Array.isArray(options.packages);
const hasReasonOptions = Array.isArray(options.reasons);
const validWorkcenters = new Set(extractWorkcenterGroupValues(options.workcenterGroups || []));
const validPackages = new Set(normalizeArray(options.packages));
const validReasons = new Set(normalizeArray(options.reasons));
const removed = {
workcenterGroups: [],
packages: [],
reason: '',
};
if (hasWorkcenterOptions) {
next.workcenterGroups = next.workcenterGroups.filter((value) => {
if (validWorkcenters.has(value)) {
return true;
}
removed.workcenterGroups.push(value);
return false;
});
}
if (hasPackageOptions) {
next.packages = next.packages.filter((value) => {
if (validPackages.has(value)) {
return true;
}
removed.packages.push(value);
return false;
});
}
if (next.reason && hasReasonOptions && !validReasons.has(next.reason)) {
removed.reason = next.reason;
next.reason = '';
}
return {
filters: next,
removed,
removedCount:
removed.workcenterGroups.length +
removed.packages.length +
(removed.reason ? 1 : 0),
};
}
export function buildRejectOptionsRequestParams(filters = {}) {
const next = toRejectFilterSnapshot(filters);
const params = {
start_date: next.startDate,
end_date: next.endDate,
workcenter_groups: next.workcenterGroups,
packages: next.packages,
include_excluded_scrap: next.includeExcludedScrap,
exclude_material_scrap: next.excludeMaterialScrap,
exclude_pb_diode: next.excludePbDiode,
};
if (next.reason) {
params.reason = next.reason;
}
return params;
}
export function buildRejectCommonQueryParams(filters = {}, { reason = '' } = {}) {
const next = toRejectFilterSnapshot(filters);
const params = {
start_date: next.startDate,
end_date: next.endDate,
workcenter_groups: next.workcenterGroups,
packages: next.packages,
include_excluded_scrap: next.includeExcludedScrap,
exclude_material_scrap: next.excludeMaterialScrap,
exclude_pb_diode: next.excludePbDiode,
};
const effectiveReason = normalizeText(reason) || next.reason;
if (effectiveReason) {
params.reasons = [effectiveReason];
}
return params;
}

View File

@@ -0,0 +1,159 @@
function normalizeText(value) {
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
function normalizeBoolean(value, fallback = false) {
if (value === undefined) {
return fallback;
}
return Boolean(value);
}
function normalizeArray(values) {
if (!Array.isArray(values)) {
return [];
}
const seen = new Set();
const result = [];
for (const item of values) {
const text = normalizeText(item);
if (!text || seen.has(text)) {
continue;
}
seen.add(text);
result.push(text);
}
return result;
}
function applyUpstreamResourceFilters(resources, filters) {
let list = Array.isArray(resources) ? resources : [];
const groups = new Set(normalizeArray(filters.workcenterGroups));
if (groups.size > 0) {
list = list.filter((resource) => groups.has(normalizeText(resource.workcenterGroup)));
}
if (filters.isProduction) {
list = list.filter((resource) => Boolean(resource.isProduction));
}
if (filters.isKey) {
list = list.filter((resource) => Boolean(resource.isKey));
}
if (filters.isMonitor) {
list = list.filter((resource) => Boolean(resource.isMonitor));
}
return list;
}
export function toResourceFilterSnapshot(input = {}) {
return {
startDate: normalizeText(input.startDate),
endDate: normalizeText(input.endDate),
granularity: normalizeText(input.granularity) || 'day',
workcenterGroups: normalizeArray(input.workcenterGroups),
families: normalizeArray(input.families),
machines: normalizeArray(input.machines),
isProduction: normalizeBoolean(input.isProduction, false),
isKey: normalizeBoolean(input.isKey, false),
isMonitor: normalizeBoolean(input.isMonitor, false),
};
}
export function deriveResourceFamilyOptions(resources = [], filters = {}) {
const next = toResourceFilterSnapshot(filters);
const filtered = applyUpstreamResourceFilters(resources, next);
const families = new Set();
for (const resource of filtered) {
const value = normalizeText(resource.family);
if (value) {
families.add(value);
}
}
return [...families].sort((left, right) => left.localeCompare(right));
}
export function deriveResourceMachineOptions(resources = [], filters = {}) {
const next = toResourceFilterSnapshot(filters);
let filtered = applyUpstreamResourceFilters(resources, next);
const families = new Set(next.families);
if (families.size > 0) {
filtered = filtered.filter((resource) => families.has(normalizeText(resource.family)));
}
return filtered
.map((resource) => ({
label: normalizeText(resource.name),
value: normalizeText(resource.id),
}))
.filter((option) => option.label && option.value)
.sort((left, right) => left.label.localeCompare(right.label));
}
export function pruneResourceFilterSelections(filters = {}, { familyOptions = [], machineOptions = [] } = {}) {
const next = toResourceFilterSnapshot(filters);
const hasFamilyOptions = Array.isArray(familyOptions);
const hasMachineOptions = Array.isArray(machineOptions);
const validFamilies = new Set(normalizeArray(familyOptions));
const validMachines = new Set(
(Array.isArray(machineOptions) ? machineOptions : [])
.map((option) => normalizeText(option?.value))
.filter(Boolean)
);
const removed = {
families: [],
machines: [],
};
if (hasFamilyOptions) {
next.families = next.families.filter((value) => {
if (validFamilies.has(value)) {
return true;
}
removed.families.push(value);
return false;
});
}
if (hasMachineOptions) {
next.machines = next.machines.filter((value) => {
if (validMachines.has(value)) {
return true;
}
removed.machines.push(value);
return false;
});
}
return {
filters: next,
removed,
removedCount: removed.families.length + removed.machines.length,
};
}
export function buildResourceHistoryQueryParams(filters = {}) {
const next = toResourceFilterSnapshot(filters);
const params = {
start_date: next.startDate,
end_date: next.endDate,
granularity: next.granularity,
workcenter_groups: next.workcenterGroups,
families: next.families,
resource_ids: next.machines,
};
if (next.isProduction) {
params.is_production = '1';
}
if (next.isKey) {
params.is_key = '1';
}
if (next.isMonitor) {
params.is_monitor = '1';
}
return params;
}

View File

@@ -1,7 +1,13 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { apiGet } from '../core/api.js'; import { apiGet } from '../core/api.js';
import {
buildRejectCommonQueryParams,
buildRejectOptionsRequestParams,
pruneRejectFilterSelections,
toRejectFilterSnapshot,
} from '../core/reject-history-filters.js';
import { replaceRuntimeHistory } from '../core/shell-navigation.js'; import { replaceRuntimeHistory } from '../core/shell-navigation.js';
import DetailTable from './components/DetailTable.vue'; import DetailTable from './components/DetailTable.vue';
@@ -12,18 +18,19 @@ import TrendChart from './components/TrendChart.vue';
const API_TIMEOUT = 60000; const API_TIMEOUT = 60000;
const DEFAULT_PER_PAGE = 50; const DEFAULT_PER_PAGE = 50;
const OPTIONS_DEBOUNCE_MS = 300;
const filters = reactive({ function createDefaultFilters() {
startDate: '', return toRejectFilterSnapshot({
endDate: '', includeExcludedScrap: false,
workcenterGroups: [], excludeMaterialScrap: true,
packages: [], excludePbDiode: true,
reason: '', paretoTop80: true,
includeExcludedScrap: false, });
excludeMaterialScrap: true, }
excludePbDiode: true,
paretoTop80: true, const draftFilters = reactive(createDefaultFilters());
}); const committedFilters = reactive(createDefaultFilters());
const page = ref(1); const page = ref(1);
const detailReason = ref(''); const detailReason = ref('');
@@ -67,6 +74,7 @@ const loading = reactive({
}); });
const errorMessage = ref(''); const errorMessage = ref('');
const autoPruneHint = ref('');
const lastQueryAt = ref(''); const lastQueryAt = ref('');
const lastPolicyMeta = ref({ const lastPolicyMeta = ref({
include_excluded_scrap: false, include_excluded_scrap: false,
@@ -74,15 +82,30 @@ const lastPolicyMeta = ref({
excluded_reason_count: 0, excluded_reason_count: 0,
}); });
let activeRequestId = 0; const draftWatchReady = ref(false);
function nextRequestId() { let activeDataRequestId = 0;
activeRequestId += 1; let activeOptionsRequestId = 0;
return activeRequestId; let optionsDebounceHandle = null;
let suppressDraftOptionReload = false;
let lastLoadedOptionsSignature = '';
function nextDataRequestId() {
activeDataRequestId += 1;
return activeDataRequestId;
} }
function isStaleRequest(requestId) { function isStaleDataRequest(requestId) {
return requestId !== activeRequestId; return requestId !== activeDataRequestId;
}
function nextOptionsRequestId() {
activeOptionsRequestId += 1;
return activeOptionsRequestId;
}
function isStaleOptionsRequest(requestId) {
return requestId !== activeOptionsRequestId;
} }
function toDateString(value) { function toDateString(value) {
@@ -92,14 +115,42 @@ function toDateString(value) {
return `${y}-${m}-${d}`; return `${y}-${m}-${d}`;
} }
function setDefaultDateRange() { function setDefaultDateRange(target) {
const today = new Date(); const today = new Date();
const end = new Date(today); const end = new Date(today);
end.setDate(end.getDate() - 1); end.setDate(end.getDate() - 1);
const start = new Date(end); const start = new Date(end);
start.setDate(start.getDate() - 29); start.setDate(start.getDate() - 29);
filters.startDate = toDateString(start); target.startDate = toDateString(start);
filters.endDate = toDateString(end); target.endDate = toDateString(end);
}
function assignFilterState(target, source) {
const snapshot = toRejectFilterSnapshot(source);
target.startDate = snapshot.startDate;
target.endDate = snapshot.endDate;
target.workcenterGroups = [...snapshot.workcenterGroups];
target.packages = [...snapshot.packages];
target.reason = snapshot.reason;
target.includeExcludedScrap = snapshot.includeExcludedScrap;
target.excludeMaterialScrap = snapshot.excludeMaterialScrap;
target.excludePbDiode = snapshot.excludePbDiode;
target.paretoTop80 = snapshot.paretoTop80;
}
function resetToDefaultFilters(target) {
const defaults = createDefaultFilters();
setDefaultDateRange(defaults);
assignFilterState(target, defaults);
}
function runWithDraftReloadSuppressed(callback) {
suppressDraftOptionReload = true;
try {
callback();
} finally {
suppressDraftOptionReload = false;
}
} }
function readArrayParam(params, key) { function readArrayParam(params, key) {
@@ -121,29 +172,29 @@ function readBooleanParam(params, key, defaultValue = false) {
return ['1', 'true', 'yes', 'y', 'on'].includes(value); return ['1', 'true', 'yes', 'y', 'on'].includes(value);
} }
function restoreFromUrl() { function restoreCommittedFiltersFromUrl() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const startDate = String(params.get('start_date') || '').trim(); const startDate = String(params.get('start_date') || '').trim();
const endDate = String(params.get('end_date') || '').trim(); const endDate = String(params.get('end_date') || '').trim();
if (startDate && endDate) { if (startDate && endDate) {
filters.startDate = startDate; committedFilters.startDate = startDate;
filters.endDate = endDate; committedFilters.endDate = endDate;
} }
const wcGroups = readArrayParam(params, 'workcenter_groups'); const wcGroups = readArrayParam(params, 'workcenter_groups');
if (wcGroups.length > 0) { if (wcGroups.length > 0) {
filters.workcenterGroups = wcGroups; committedFilters.workcenterGroups = wcGroups;
} }
const packages = readArrayParam(params, 'packages'); const packages = readArrayParam(params, 'packages');
if (packages.length > 0) { if (packages.length > 0) {
filters.packages = packages; committedFilters.packages = packages;
} }
const reason = String(params.get('reason') || '').trim(); const reason = String(params.get('reason') || '').trim();
if (reason) { if (reason) {
filters.reason = reason; committedFilters.reason = reason;
} }
const detailReasonFromUrl = String(params.get('detail_reason') || '').trim(); const detailReasonFromUrl = String(params.get('detail_reason') || '').trim();
if (detailReasonFromUrl) { if (detailReasonFromUrl) {
@@ -154,38 +205,44 @@ function restoreFromUrl() {
selectedTrendDates.value = trendDates; selectedTrendDates.value = trendDates;
} }
filters.includeExcludedScrap = readBooleanParam(params, 'include_excluded_scrap', false); committedFilters.includeExcludedScrap = readBooleanParam(params, 'include_excluded_scrap', false);
filters.excludeMaterialScrap = readBooleanParam(params, 'exclude_material_scrap', true); committedFilters.excludeMaterialScrap = readBooleanParam(params, 'exclude_material_scrap', true);
filters.excludePbDiode = readBooleanParam(params, 'exclude_pb_diode', true); committedFilters.excludePbDiode = readBooleanParam(params, 'exclude_pb_diode', true);
filters.paretoTop80 = !readBooleanParam(params, 'pareto_scope_all', false); committedFilters.paretoTop80 = !readBooleanParam(params, 'pareto_scope_all', false);
const parsedPage = Number(params.get('page') || '1'); const parsedPage = Number(params.get('page') || '1');
page.value = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1; page.value = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
} }
function appendArrayParams(params, key, values) {
for (const value of values || []) {
params.append(key, value);
}
}
function updateUrlState() { function updateUrlState() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('start_date', filters.startDate); params.set('start_date', committedFilters.startDate);
params.set('end_date', filters.endDate); params.set('end_date', committedFilters.endDate);
filters.workcenterGroups.forEach((item) => params.append('workcenter_groups', item)); appendArrayParams(params, 'workcenter_groups', committedFilters.workcenterGroups);
filters.packages.forEach((item) => params.append('packages', item)); appendArrayParams(params, 'packages', committedFilters.packages);
if (filters.reason) { if (committedFilters.reason) {
params.set('reason', filters.reason); params.set('reason', committedFilters.reason);
} }
if (detailReason.value) { if (detailReason.value) {
params.set('detail_reason', detailReason.value); params.set('detail_reason', detailReason.value);
} }
selectedTrendDates.value.forEach((d) => params.append('trend_dates', d)); appendArrayParams(params, 'trend_dates', selectedTrendDates.value);
if (filters.includeExcludedScrap) { if (committedFilters.includeExcludedScrap) {
params.set('include_excluded_scrap', 'true'); params.set('include_excluded_scrap', 'true');
} }
params.set('exclude_material_scrap', String(filters.excludeMaterialScrap)); params.set('exclude_material_scrap', String(committedFilters.excludeMaterialScrap));
params.set('exclude_pb_diode', String(filters.excludePbDiode)); params.set('exclude_pb_diode', String(committedFilters.excludePbDiode));
if (!filters.paretoTop80) { if (!committedFilters.paretoTop80) {
params.set('pareto_scope_all', 'true'); params.set('pareto_scope_all', 'true');
} }
@@ -206,26 +263,12 @@ function unwrapApiResult(result, fallbackMessage) {
return result; return result;
} }
function buildCommonParams({ reason = filters.reason } = {}) { function buildCommonParams({ reason = committedFilters.reason } = {}) {
const params = { return buildRejectCommonQueryParams(committedFilters, { reason });
start_date: filters.startDate,
end_date: filters.endDate,
workcenter_groups: filters.workcenterGroups,
packages: filters.packages,
include_excluded_scrap: filters.includeExcludedScrap,
exclude_material_scrap: filters.excludeMaterialScrap,
exclude_pb_diode: filters.excludePbDiode,
};
if (reason) {
params.reasons = [reason];
}
return params;
} }
function buildListParams() { function buildListParams() {
const effectiveReason = detailReason.value || filters.reason; const effectiveReason = detailReason.value || committedFilters.reason;
const params = { const params = {
...buildCommonParams({ reason: effectiveReason }), ...buildCommonParams({ reason: effectiveReason }),
page: page.value, page: page.value,
@@ -239,15 +282,9 @@ function buildListParams() {
return params; return params;
} }
async function fetchOptions() { async function fetchDraftOptions() {
const response = await apiGet('/api/reject-history/options', { const response = await apiGet('/api/reject-history/options', {
params: { params: buildRejectOptionsRequestParams(draftFilters),
start_date: filters.startDate,
end_date: filters.endDate,
include_excluded_scrap: filters.includeExcludedScrap,
exclude_material_scrap: filters.excludeMaterialScrap,
exclude_pb_diode: filters.excludePbDiode,
},
timeout: API_TIMEOUT, timeout: API_TIMEOUT,
}); });
const payload = unwrapApiResult(response, '載入篩選選項失敗'); const payload = unwrapApiResult(response, '載入篩選選項失敗');
@@ -283,38 +320,145 @@ function mergePolicyMeta(meta) {
}; };
} }
function normalizeFiltersByOptions() { function applyOptionsPayload(payload) {
if (filters.reason && !options.reasons.includes(filters.reason)) { options.workcenterGroups = Array.isArray(payload?.workcenter_groups)
filters.reason = ''; ? payload.workcenter_groups
} : [];
options.reasons = Array.isArray(payload?.reasons)
? payload.reasons
: [];
options.packages = Array.isArray(payload?.packages)
? payload.packages
: [];
}
if (filters.packages.length > 0) { function formatPruneHint(removed) {
const packageSet = new Set(options.packages); const parts = [];
filters.packages = filters.packages.filter((pkg) => packageSet.has(pkg)); if (removed.workcenterGroups.length > 0) {
parts.push(`WORKCENTER GROUP: ${removed.workcenterGroups.join(', ')}`);
}
if (removed.packages.length > 0) {
parts.push(`Package: ${removed.packages.join(', ')}`);
}
if (removed.reason) {
parts.push(`原因: ${removed.reason}`);
}
if (parts.length === 0) {
return '';
}
return `已自動清除失效篩選:${parts.join('')}`;
}
function pruneDraftByCurrentOptions({ showHint = true } = {}) {
const result = pruneRejectFilterSelections(draftFilters, options);
if (result.removedCount > 0) {
runWithDraftReloadSuppressed(() => {
assignFilterState(draftFilters, result.filters);
});
if (showHint) {
autoPruneHint.value = formatPruneHint(result.removed);
}
}
return result;
}
function commitDraftFilters() {
const result = pruneDraftByCurrentOptions({ showHint: true });
assignFilterState(committedFilters, draftFilters);
return result;
}
function clearOptionsDebounce() {
if (optionsDebounceHandle) {
clearTimeout(optionsDebounceHandle);
optionsDebounceHandle = null;
} }
} }
async function loadAllData({ loadOptions = true } = {}) { async function reloadDraftOptions() {
const requestId = nextRequestId(); if (!draftFilters.startDate || !draftFilters.endDate) {
return;
}
const signature = draftOptionsSignature.value;
const requestId = nextOptionsRequestId();
loading.options = true;
try {
const payload = await fetchDraftOptions();
if (isStaleOptionsRequest(requestId)) {
return;
}
applyOptionsPayload(payload);
lastLoadedOptionsSignature = signature;
const pruneResult = pruneDraftByCurrentOptions({ showHint: true });
if (pruneResult.removedCount > 0) {
scheduleOptionsReload();
}
} catch (error) {
if (isStaleOptionsRequest(requestId)) {
return;
}
errorMessage.value = error?.message || '載入篩選選項失敗';
} finally {
if (isStaleOptionsRequest(requestId)) {
return;
}
loading.options = false;
}
}
function scheduleOptionsReload() {
if (!draftWatchReady.value || suppressDraftOptionReload) {
return;
}
clearOptionsDebounce();
optionsDebounceHandle = setTimeout(() => {
optionsDebounceHandle = null;
void reloadDraftOptions();
}, OPTIONS_DEBOUNCE_MS);
}
const draftOptionsSignature = computed(() => {
return JSON.stringify({
startDate: draftFilters.startDate,
endDate: draftFilters.endDate,
workcenterGroups: draftFilters.workcenterGroups,
packages: draftFilters.packages,
reason: draftFilters.reason,
includeExcludedScrap: draftFilters.includeExcludedScrap,
excludeMaterialScrap: draftFilters.excludeMaterialScrap,
excludePbDiode: draftFilters.excludePbDiode,
});
});
watch(draftOptionsSignature, () => {
scheduleOptionsReload();
});
async function ensureDraftOptionsFresh() {
clearOptionsDebounce();
const signature = draftOptionsSignature.value;
if (signature !== lastLoadedOptionsSignature) {
await reloadDraftOptions();
return;
}
pruneDraftByCurrentOptions({ showHint: true });
}
async function loadDataSections() {
const requestId = nextDataRequestId();
loading.querying = true; loading.querying = true;
loading.list = true; loading.list = true;
errorMessage.value = ''; errorMessage.value = '';
try { try {
const tasks = [fetchAnalytics(), fetchList()]; const [analyticsResp, listResp] = await Promise.all([fetchAnalytics(), fetchList()]);
if (loadOptions) { if (isStaleDataRequest(requestId)) {
loading.options = true;
tasks.push(fetchOptions());
}
const responses = await Promise.all(tasks);
if (isStaleRequest(requestId)) {
return; return;
} }
const [analyticsResp, listResp, optionsResp] = responses;
const analyticsData = analyticsResp.data || {}; const analyticsData = analyticsResp.data || {};
summary.value = analyticsData.summary || summary.value; summary.value = analyticsData.summary || summary.value;
trend.value = analyticsData.trend || trend.value; trend.value = analyticsData.trend || trend.value;
@@ -327,83 +471,70 @@ async function loadAllData({ loadOptions = true } = {}) {
}; };
mergePolicyMeta(meta); mergePolicyMeta(meta);
if (loadOptions && optionsResp) {
options.workcenterGroups = Array.isArray(optionsResp.workcenter_groups)
? optionsResp.workcenter_groups
: [];
options.reasons = Array.isArray(optionsResp.reasons)
? optionsResp.reasons
: [];
options.packages = Array.isArray(optionsResp.packages)
? optionsResp.packages
: [];
normalizeFiltersByOptions();
}
lastQueryAt.value = new Date().toLocaleString('zh-TW'); lastQueryAt.value = new Date().toLocaleString('zh-TW');
updateUrlState(); updateUrlState();
} catch (error) { } catch (error) {
if (isStaleRequest(requestId)) { if (isStaleDataRequest(requestId)) {
return; return;
} }
errorMessage.value = error?.message || '載入資料失敗'; errorMessage.value = error?.message || '載入資料失敗';
} finally { } finally {
if (isStaleRequest(requestId)) { if (isStaleDataRequest(requestId)) {
return; return;
} }
loading.initial = false; loading.initial = false;
loading.querying = false; loading.querying = false;
loading.options = false;
loading.list = false; loading.list = false;
} }
} }
async function loadListOnly() { async function loadListOnly() {
const requestId = nextRequestId(); const requestId = nextDataRequestId();
loading.list = true; loading.list = true;
errorMessage.value = ''; errorMessage.value = '';
try { try {
const listResp = await fetchList(); const listResp = await fetchList();
if (isStaleRequest(requestId)) { if (isStaleDataRequest(requestId)) {
return; return;
} }
detail.value = listResp.data || detail.value; detail.value = listResp.data || detail.value;
mergePolicyMeta(listResp.meta || {}); mergePolicyMeta(listResp.meta || {});
updateUrlState(); updateUrlState();
} catch (error) { } catch (error) {
if (isStaleRequest(requestId)) { if (isStaleDataRequest(requestId)) {
return; return;
} }
errorMessage.value = error?.message || '載入明細資料失敗'; errorMessage.value = error?.message || '載入明細資料失敗';
} finally { } finally {
if (isStaleRequest(requestId)) { if (isStaleDataRequest(requestId)) {
return; return;
} }
loading.list = false; loading.list = false;
} }
} }
function applyFilters() { async function applyFilters() {
page.value = 1; page.value = 1;
detailReason.value = ''; detailReason.value = '';
selectedTrendDates.value = []; selectedTrendDates.value = [];
void loadAllData({ loadOptions: true }); await ensureDraftOptionsFresh();
commitDraftFilters();
await loadDataSections();
} }
function clearFilters() { async function clearFilters() {
setDefaultDateRange(); runWithDraftReloadSuppressed(() => {
filters.workcenterGroups = []; resetToDefaultFilters(draftFilters);
filters.packages = []; });
filters.reason = ''; autoPruneHint.value = '';
detailReason.value = ''; detailReason.value = '';
selectedTrendDates.value = []; selectedTrendDates.value = [];
filters.includeExcludedScrap = false;
filters.excludeMaterialScrap = true;
filters.excludePbDiode = true;
filters.paretoTop80 = true;
page.value = 1; page.value = 1;
void loadAllData({ loadOptions: true }); lastLoadedOptionsSignature = '';
await ensureDraftOptionsFresh();
commitDraftFilters();
await loadDataSections();
} }
function goToPage(nextPage) { function goToPage(nextPage) {
@@ -443,54 +574,70 @@ function onParetoClick(reason) {
} }
function handleParetoScopeToggle(checked) { function handleParetoScopeToggle(checked) {
filters.paretoTop80 = Boolean(checked); const value = Boolean(checked);
runWithDraftReloadSuppressed(() => {
draftFilters.paretoTop80 = value;
});
committedFilters.paretoTop80 = value;
updateUrlState(); updateUrlState();
} }
function removeFilterChip(chip) { async function removeFilterChip(chip) {
if (!chip?.removable) { if (!chip?.removable) {
return; return;
} }
if (chip.type === 'reason') { if (chip.type === 'detail-reason') {
filters.reason = '';
detailReason.value = '';
} else if (chip.type === 'workcenter') {
filters.workcenterGroups = filters.workcenterGroups.filter((item) => item !== chip.value);
} else if (chip.type === 'package') {
filters.packages = filters.packages.filter((item) => item !== chip.value);
} else if (chip.type === 'detail-reason') {
detailReason.value = ''; detailReason.value = '';
page.value = 1; page.value = 1;
void loadListOnly(); await loadListOnly();
return; return;
} else if (chip.type === 'trend-dates') { }
if (chip.type === 'trend-dates') {
selectedTrendDates.value = []; selectedTrendDates.value = [];
page.value = 1; page.value = 1;
void loadListOnly(); await loadListOnly();
return; return;
}
const nextFilters = toRejectFilterSnapshot(committedFilters);
if (chip.type === 'reason') {
nextFilters.reason = '';
detailReason.value = '';
} else if (chip.type === 'workcenter') {
nextFilters.workcenterGroups = nextFilters.workcenterGroups.filter((item) => item !== chip.value);
} else if (chip.type === 'package') {
nextFilters.packages = nextFilters.packages.filter((item) => item !== chip.value);
} else { } else {
return; return;
} }
runWithDraftReloadSuppressed(() => {
assignFilterState(draftFilters, nextFilters);
});
assignFilterState(committedFilters, nextFilters);
page.value = 1; page.value = 1;
void loadAllData({ loadOptions: false }); lastLoadedOptionsSignature = '';
await ensureDraftOptionsFresh();
commitDraftFilters();
await loadDataSections();
} }
function exportCsv() { function exportCsv() {
const effectiveReason = detailReason.value || committedFilters.reason;
const queryParams = buildRejectCommonQueryParams(committedFilters, { reason: effectiveReason });
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('start_date', filters.startDate);
params.set('end_date', filters.endDate);
params.set('include_excluded_scrap', String(filters.includeExcludedScrap));
params.set('exclude_material_scrap', String(filters.excludeMaterialScrap));
params.set('exclude_pb_diode', String(filters.excludePbDiode));
filters.workcenterGroups.forEach((item) => params.append('workcenter_groups', item)); params.set('start_date', queryParams.start_date);
filters.packages.forEach((item) => params.append('packages', item)); params.set('end_date', queryParams.end_date);
const effectiveReason = detailReason.value || filters.reason; params.set('include_excluded_scrap', String(queryParams.include_excluded_scrap));
if (effectiveReason) { params.set('exclude_material_scrap', String(queryParams.exclude_material_scrap));
params.append('reasons', effectiveReason); params.set('exclude_pb_diode', String(queryParams.exclude_pb_diode));
} appendArrayParams(params, 'workcenter_groups', queryParams.workcenter_groups || []);
appendArrayParams(params, 'packages', queryParams.packages || []);
appendArrayParams(params, 'reasons', queryParams.reasons || []);
window.location.href = `/api/reject-history/export?${params.toString()}`; window.location.href = `/api/reject-history/export?${params.toString()}`;
} }
@@ -571,7 +718,7 @@ const allParetoItems = computed(() => {
const filteredParetoItems = computed(() => { const filteredParetoItems = computed(() => {
const items = allParetoItems.value || []; const items = allParetoItems.value || [];
if (!filters.paretoTop80 || items.length === 0) { if (!committedFilters.paretoTop80 || items.length === 0) {
return items; return items;
} }
const top = items.filter((item) => Number(item.cumPct || 0) <= 80); const top = items.filter((item) => Number(item.cumPct || 0) <= 80);
@@ -582,41 +729,41 @@ const activeFilterChips = computed(() => {
const chips = [ const chips = [
{ {
key: 'date-range', key: 'date-range',
label: `日期: ${filters.startDate || '-'} ~ ${filters.endDate || '-'}`, label: `日期: ${committedFilters.startDate || '-'} ~ ${committedFilters.endDate || '-'}`,
removable: false, removable: false,
type: 'date', type: 'date',
value: '', value: '',
}, },
{ {
key: 'policy-mode', key: 'policy-mode',
label: filters.includeExcludedScrap ? '政策: 納入不計良率報廢' : '政策: 排除不計良率報廢', label: committedFilters.includeExcludedScrap ? '政策: 納入不計良率報廢' : '政策: 排除不計良率報廢',
removable: false, removable: false,
type: 'policy', type: 'policy',
value: '', value: '',
}, },
{ {
key: 'material-policy-mode', key: 'material-policy-mode',
label: filters.excludeMaterialScrap ? '原物料: 已排除' : '原物料: 已納入', label: committedFilters.excludeMaterialScrap ? '原物料: 已排除' : '原物料: 已納入',
removable: false, removable: false,
type: 'policy', type: 'policy',
value: '', value: '',
}, },
{ {
key: 'pb-diode-policy', key: 'pb-diode-policy',
label: filters.excludePbDiode ? 'PB_Diode: 已排除' : 'PB_Diode: 已納入', label: committedFilters.excludePbDiode ? 'PB_Diode: 已排除' : 'PB_Diode: 已納入',
removable: false, removable: false,
type: 'policy', type: 'policy',
value: '', value: '',
}, },
]; ];
if (filters.reason) { if (committedFilters.reason) {
chips.push({ chips.push({
key: `reason:${filters.reason}`, key: `reason:${committedFilters.reason}`,
label: `原因: ${filters.reason}`, label: `原因: ${committedFilters.reason}`,
removable: true, removable: true,
type: 'reason', type: 'reason',
value: filters.reason, value: committedFilters.reason,
}); });
} }
if (selectedTrendDates.value.length > 0) { if (selectedTrendDates.value.length > 0) {
@@ -642,7 +789,7 @@ const activeFilterChips = computed(() => {
}); });
} }
filters.workcenterGroups.forEach((group) => { committedFilters.workcenterGroups.forEach((group) => {
chips.push({ chips.push({
key: `workcenter:${group}`, key: `workcenter:${group}`,
label: `WC: ${group}`, label: `WC: ${group}`,
@@ -652,7 +799,7 @@ const activeFilterChips = computed(() => {
}); });
}); });
filters.packages.forEach((pkg) => { committedFilters.packages.forEach((pkg) => {
chips.push({ chips.push({
key: `package:${pkg}`, key: `package:${pkg}`,
label: `Package: ${pkg}`, label: `Package: ${pkg}`,
@@ -683,10 +830,22 @@ const pagination = computed(() => detail.value?.pagination || {
totalPages: 1, totalPages: 1,
}); });
onMounted(() => { onMounted(async () => {
setDefaultDateRange(); resetToDefaultFilters(committedFilters);
restoreFromUrl(); restoreCommittedFiltersFromUrl();
void loadAllData({ loadOptions: true }); runWithDraftReloadSuppressed(() => {
assignFilterState(draftFilters, committedFilters);
});
lastLoadedOptionsSignature = '';
await reloadDraftOptions();
commitDraftFilters();
draftWatchReady.value = true;
await loadDataSections();
});
onBeforeUnmount(() => {
clearOptionsDebounce();
}); });
</script> </script>
@@ -698,14 +857,15 @@ onMounted(() => {
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="last-update" v-if="lastQueryAt">更新時間{{ lastQueryAt }}</div> <div class="last-update" v-if="lastQueryAt">更新時間{{ lastQueryAt }}</div>
<button type="button" class="btn btn-light" :disabled="loading.querying" @click="applyFilters">重新整理</button> <button type="button" class="btn btn-light" :disabled="loading.querying || loading.options" @click="applyFilters">重新整理</button>
</div> </div>
</header> </header>
<div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div> <div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div>
<div v-if="autoPruneHint" class="filter-indicator">{{ autoPruneHint }}</div>
<FilterPanel <FilterPanel
:filters="filters" :filters="draftFilters"
:options="options" :options="options"
:loading="loading" :loading="loading"
:active-filter-chips="activeFilterChips" :active-filter-chips="activeFilterChips"

View File

@@ -1,4 +1,6 @@
<script setup> <script setup>
import { ref } from 'vue';
defineProps({ defineProps({
items: { type: Array, default: () => [] }, items: { type: Array, default: () => [] },
pagination: { pagination: {
@@ -11,6 +13,8 @@ defineProps({
defineEmits(['go-to-page', 'clear-reason']); defineEmits(['go-to-page', 'clear-reason']);
const showRejectBreakdown = ref(false);
function formatNumber(value) { function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW'); return Number(value || 0).toLocaleString('zh-TW');
} }
@@ -31,45 +35,49 @@ function formatNumber(value) {
<table class="detail-table"> <table class="detail-table">
<thead> <thead>
<tr> <tr>
<th>日期</th>
<th>LOT</th> <th>LOT</th>
<th>WORKCENTER_GROUP</th>
<th>WORKCENTER</th> <th>WORKCENTER</th>
<th>Package</th> <th>Package</th>
<th>PJ_TYPE</th> <th>FUNCTION</th>
<th>PJ_FUNCTION</th> <th class="col-left">TYPE</th>
<th>PRODUCT</th> <th>PRODUCT</th>
<th>原因</th> <th>原因</th>
<th>扣帳報廢量</th> <th class="th-expandable" @click="showRejectBreakdown = !showRejectBreakdown">
扣帳報廢量 <span class="expand-icon">{{ showRejectBreakdown ? '▾' : '▸' }}</span>
</th>
<template v-if="showRejectBreakdown">
<th class="th-sub">REJECT</th>
<th class="th-sub">STANDBY</th>
<th class="th-sub">QTYTOPROCESS</th>
<th class="th-sub">INPROCESS</th>
<th class="th-sub">PROCESSED</th>
</template>
<th>不扣帳報廢量</th> <th>不扣帳報廢量</th>
<th>REJECT_QTY</th> <th>報廢時間</th>
<th>STANDBY_QTY</th>
<th>QTYTOPROCESS</th>
<th>INPROCESS</th>
<th>PROCESSED</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(row, idx) in items" :key="`${row.TXN_DAY}-${row.WORKCENTERNAME}-${row.CONTAINERNAME}-${row.LOSSREASONNAME}-${idx}`"> <tr v-for="(row, idx) in items" :key="`${row.TXN_DAY}-${row.CONTAINERNAME}-${row.LOSSREASONNAME}-${idx}`">
<td>{{ row.TXN_DAY }}</td>
<td>{{ row.CONTAINERNAME || '' }}</td> <td>{{ row.CONTAINERNAME || '' }}</td>
<td>{{ row.WORKCENTER_GROUP }}</td>
<td>{{ row.WORKCENTERNAME }}</td> <td>{{ row.WORKCENTERNAME }}</td>
<td>{{ row.PRODUCTLINENAME }}</td> <td>{{ row.PRODUCTLINENAME }}</td>
<td>{{ row.PJ_TYPE }}</td>
<td>{{ row.PJ_FUNCTION || '' }}</td> <td>{{ row.PJ_FUNCTION || '' }}</td>
<td class="col-left">{{ row.PJ_TYPE }}</td>
<td>{{ row.PRODUCTNAME || '' }}</td> <td>{{ row.PRODUCTNAME || '' }}</td>
<td>{{ row.LOSSREASONNAME }}</td> <td>{{ row.LOSSREASONNAME }}</td>
<td>{{ formatNumber(row.REJECT_TOTAL_QTY) }}</td> <td>{{ formatNumber(row.REJECT_TOTAL_QTY) }}</td>
<template v-if="showRejectBreakdown">
<td class="td-sub">{{ formatNumber(row.REJECT_QTY) }}</td>
<td class="td-sub">{{ formatNumber(row.STANDBY_QTY) }}</td>
<td class="td-sub">{{ formatNumber(row.QTYTOPROCESS_QTY) }}</td>
<td class="td-sub">{{ formatNumber(row.INPROCESS_QTY) }}</td>
<td class="td-sub">{{ formatNumber(row.PROCESSED_QTY) }}</td>
</template>
<td>{{ formatNumber(row.DEFECT_QTY) }}</td> <td>{{ formatNumber(row.DEFECT_QTY) }}</td>
<td>{{ formatNumber(row.REJECT_QTY) }}</td> <td class="cell-nowrap">{{ row.TXN_TIME || row.TXN_DAY }}</td>
<td>{{ formatNumber(row.STANDBY_QTY) }}</td>
<td>{{ formatNumber(row.QTYTOPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.INPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.PROCESSED_QTY) }}</td>
</tr> </tr>
<tr v-if="!items || items.length === 0"> <tr v-if="!items || items.length === 0">
<td colspan="16" class="placeholder">No data</td> <td :colspan="showRejectBreakdown ? 15 : 10" class="placeholder">No data</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -266,10 +266,34 @@
overflow: auto; overflow: auto;
} }
.detail-table .cell-wrap { .detail-table .cell-nowrap {
white-space: normal; white-space: nowrap;
max-width: 220px; }
word-break: break-all;
.detail-table .col-left {
text-align: left;
}
.detail-table .th-expandable {
cursor: pointer;
user-select: none;
}
.detail-table .th-expandable:hover {
background: #eef2f7;
}
.detail-table .expand-icon {
font-size: 10px;
margin-left: 2px;
color: #64748b;
}
.detail-table .th-sub,
.detail-table .td-sub {
background: #f8fafc;
font-size: 11px;
color: #475569;
} }
.detail-reason-badge { .detail-reason-badge {

View File

@@ -1,8 +1,15 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../core/api.js'; import { apiGet, ensureMesApiAvailable } from '../core/api.js';
import { buildResourceKpiFromHours } from '../core/compute.js'; import { buildResourceKpiFromHours } from '../core/compute.js';
import {
buildResourceHistoryQueryParams,
deriveResourceFamilyOptions,
deriveResourceMachineOptions,
pruneResourceFilterSelections,
toResourceFilterSnapshot,
} from '../core/resource-history-filters.js';
import { replaceRuntimeHistory } from '../core/shell-navigation.js'; import { replaceRuntimeHistory } from '../core/shell-navigation.js';
import ComparisonChart from './components/ComparisonChart.vue'; import ComparisonChart from './components/ComparisonChart.vue';
@@ -18,17 +25,20 @@ ensureMesApiAvailable();
const API_TIMEOUT = 60000; const API_TIMEOUT = 60000;
const MAX_QUERY_DAYS = 730; const MAX_QUERY_DAYS = 730;
const filters = reactive({ function createDefaultFilters() {
startDate: '', return toResourceFilterSnapshot({
endDate: '', granularity: 'day',
granularity: 'day', workcenterGroups: [],
workcenterGroups: [], families: [],
families: [], machines: [],
machines: [], isProduction: false,
isProduction: false, isKey: false,
isKey: false, isMonitor: false,
isMonitor: false, });
}); }
const draftFilters = reactive(createDefaultFilters());
const committedFilters = reactive(createDefaultFilters());
const options = reactive({ const options = reactive({
workcenterGroups: [], workcenterGroups: [],
@@ -54,6 +64,19 @@ const loading = reactive({
const queryError = ref(''); const queryError = ref('');
const detailWarning = ref(''); const detailWarning = ref('');
const exportMessage = ref(''); const exportMessage = ref('');
const autoPruneHint = ref('');
const draftWatchReady = ref(false);
let suppressDraftPrune = false;
function runWithDraftPruneSuppressed(callback) {
suppressDraftPrune = true;
try {
callback();
} finally {
suppressDraftPrune = false;
}
}
function resetHierarchyState() { function resetHierarchyState() {
Object.keys(hierarchyState).forEach((key) => { Object.keys(hierarchyState).forEach((key) => {
@@ -61,7 +84,11 @@ function resetHierarchyState() {
}); });
} }
function setDefaultDates() { function toDateString(value) {
return value.toISOString().slice(0, 10);
}
function setDefaultDates(target) {
const today = new Date(); const today = new Date();
const endDate = new Date(today); const endDate = new Date(today);
endDate.setDate(endDate.getDate() - 1); endDate.setDate(endDate.getDate() - 1);
@@ -69,12 +96,27 @@ function setDefaultDates() {
const startDate = new Date(endDate); const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 6); startDate.setDate(startDate.getDate() - 6);
filters.startDate = toDateString(startDate); target.startDate = toDateString(startDate);
filters.endDate = toDateString(endDate); target.endDate = toDateString(endDate);
} }
function toDateString(value) { function assignFilterState(target, source) {
return value.toISOString().slice(0, 10); const snapshot = toResourceFilterSnapshot(source);
target.startDate = snapshot.startDate;
target.endDate = snapshot.endDate;
target.granularity = snapshot.granularity;
target.workcenterGroups = [...snapshot.workcenterGroups];
target.families = [...snapshot.families];
target.machines = [...snapshot.machines];
target.isProduction = snapshot.isProduction;
target.isKey = snapshot.isKey;
target.isMonitor = snapshot.isMonitor;
}
function resetToDefaultFilters(target) {
const defaults = createDefaultFilters();
setDefaultDates(defaults);
assignFilterState(target, defaults);
} }
function unwrapApiResult(result, fallbackMessage) { function unwrapApiResult(result, fallbackMessage) {
@@ -94,31 +136,31 @@ function mergeComputedKpi(source) {
}; };
} }
function buildQueryString() { function appendArrayParams(params, key, values) {
for (const value of values || []) {
params.append(key, value);
}
}
function buildQueryStringFromFilters(filters) {
const queryParams = buildResourceHistoryQueryParams(filters);
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('start_date', filters.startDate); params.append('start_date', queryParams.start_date);
params.append('end_date', filters.endDate); params.append('end_date', queryParams.end_date);
params.append('granularity', filters.granularity); params.append('granularity', queryParams.granularity);
appendArrayParams(params, 'workcenter_groups', queryParams.workcenter_groups || []);
appendArrayParams(params, 'families', queryParams.families || []);
appendArrayParams(params, 'resource_ids', queryParams.resource_ids || []);
filters.workcenterGroups.forEach((group) => { if (queryParams.is_production) {
params.append('workcenter_groups', group); params.append('is_production', queryParams.is_production);
});
filters.families.forEach((family) => {
params.append('families', family);
});
filters.machines.forEach((machine) => {
params.append('resource_ids', machine);
});
if (filters.isProduction) {
params.append('is_production', '1');
} }
if (filters.isKey) { if (queryParams.is_key) {
params.append('is_key', '1'); params.append('is_key', queryParams.is_key);
} }
if (filters.isMonitor) { if (queryParams.is_monitor) {
params.append('is_monitor', '1'); params.append('is_monitor', queryParams.is_monitor);
} }
return params.toString(); return params.toString();
@@ -140,9 +182,9 @@ function readBooleanParam(params, key) {
return value === '1' || value === 'true' || value === 'yes'; return value === '1' || value === 'true' || value === 'yes';
} }
function readInitialFiltersFromUrl() { function restoreCommittedFiltersFromUrl() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
return { const next = {
startDate: String(params.get('start_date') || '').trim(), startDate: String(params.get('start_date') || '').trim(),
endDate: String(params.get('end_date') || '').trim(), endDate: String(params.get('end_date') || '').trim(),
granularity: String(params.get('granularity') || '').trim(), granularity: String(params.get('granularity') || '').trim(),
@@ -153,15 +195,37 @@ function readInitialFiltersFromUrl() {
isKey: readBooleanParam(params, 'is_key'), isKey: readBooleanParam(params, 'is_key'),
isMonitor: readBooleanParam(params, 'is_monitor'), isMonitor: readBooleanParam(params, 'is_monitor'),
}; };
if (next.startDate) {
committedFilters.startDate = next.startDate;
}
if (next.endDate) {
committedFilters.endDate = next.endDate;
}
if (next.granularity) {
committedFilters.granularity = next.granularity;
}
if (next.workcenterGroups.length > 0) {
committedFilters.workcenterGroups = next.workcenterGroups;
}
if (next.families.length > 0) {
committedFilters.families = next.families;
}
if (next.machines.length > 0) {
committedFilters.machines = next.machines;
}
committedFilters.isProduction = next.isProduction;
committedFilters.isKey = next.isKey;
committedFilters.isMonitor = next.isMonitor;
} }
function updateUrlState() { function updateUrlState() {
const queryString = buildQueryString(); const queryString = buildQueryStringFromFilters(committedFilters);
const nextUrl = queryString ? `/resource-history?${queryString}` : '/resource-history'; const nextUrl = queryString ? `/resource-history?${queryString}` : '/resource-history';
replaceRuntimeHistory(nextUrl); replaceRuntimeHistory(nextUrl);
} }
function validateDateRange() { function validateDateRange(filters) {
if (!filters.startDate || !filters.endDate) { if (!filters.startDate || !filters.endDate) {
return '請先設定開始與結束日期'; return '請先設定開始與結束日期';
} }
@@ -200,34 +264,77 @@ async function loadOptions() {
} }
} }
const machineOptions = computed(() => { const familyOptions = computed(() => {
let list = options.resources; return deriveResourceFamilyOptions(options.resources, draftFilters);
if (filters.workcenterGroups.length > 0) {
const gset = new Set(filters.workcenterGroups);
list = list.filter((r) => gset.has(r.workcenterGroup));
}
if (filters.families.length > 0) {
const fset = new Set(filters.families);
list = list.filter((r) => fset.has(r.family));
}
if (filters.isProduction) list = list.filter((r) => r.isProduction);
if (filters.isKey) list = list.filter((r) => r.isKey);
if (filters.isMonitor) list = list.filter((r) => r.isMonitor);
return list
.map((r) => ({ label: r.name, value: r.id }))
.sort((a, b) => a.label.localeCompare(b.label));
}); });
function pruneInvalidMachines() { const machineOptions = computed(() => {
const validIds = new Set(machineOptions.value.map((m) => m.value)); return deriveResourceMachineOptions(options.resources, draftFilters);
filters.machines = filters.machines.filter((m) => validIds.has(m)); });
const filterBarOptions = computed(() => {
return {
workcenterGroups: options.workcenterGroups,
families: familyOptions.value,
};
});
function formatPruneHint(removed) {
const parts = [];
if (removed.families.length > 0) {
parts.push(`型號: ${removed.families.join(', ')}`);
}
if (removed.machines.length > 0) {
parts.push(`機台: ${removed.machines.join(', ')}`);
}
if (parts.length === 0) {
return '';
}
return `已自動清除失效篩選:${parts.join('')}`;
} }
async function executeQuery() { function pruneDraftSelections({ showHint = true } = {}) {
updateUrlState(); const result = pruneResourceFilterSelections(draftFilters, {
const validationError = validateDateRange(); familyOptions: familyOptions.value,
machineOptions: machineOptions.value,
});
if (result.removedCount > 0) {
runWithDraftPruneSuppressed(() => {
draftFilters.families = [...result.filters.families];
draftFilters.machines = [...result.filters.machines];
});
if (showHint) {
autoPruneHint.value = formatPruneHint(result.removed);
}
}
return result;
}
const pruneSignature = computed(() => {
return JSON.stringify({
workcenterGroups: draftFilters.workcenterGroups,
families: draftFilters.families,
machines: draftFilters.machines,
isProduction: draftFilters.isProduction,
isKey: draftFilters.isKey,
isMonitor: draftFilters.isMonitor,
familyOptions: familyOptions.value,
machineOptions: machineOptions.value.map((item) => item.value),
});
});
watch(pruneSignature, () => {
if (!draftWatchReady.value || suppressDraftPrune) {
return;
}
pruneDraftSelections({ showHint: true });
});
async function executeCommittedQuery() {
const validationError = validateDateRange(committedFilters);
if (validationError) { if (validationError) {
queryError.value = validationError; queryError.value = validationError;
loading.initial = false;
return; return;
} }
@@ -237,7 +344,9 @@ async function executeQuery() {
exportMessage.value = ''; exportMessage.value = '';
try { try {
const queryString = buildQueryString(); const queryString = buildQueryStringFromFilters(committedFilters);
updateUrlState();
const [summaryResponse, detailResponse] = await Promise.all([ const [summaryResponse, detailResponse] = await Promise.all([
apiGet(`/api/resource/history/summary?${queryString}`, { apiGet(`/api/resource/history/summary?${queryString}`, {
timeout: API_TIMEOUT, timeout: API_TIMEOUT,
@@ -283,30 +392,31 @@ async function executeQuery() {
} }
} }
function updateFilters(nextFilters) { async function applyFilters() {
const upstreamChanged = const validationError = validateDateRange(draftFilters);
'workcenterGroups' in nextFilters || if (validationError) {
'families' in nextFilters || queryError.value = validationError;
'isProduction' in nextFilters || return;
'isKey' in nextFilters ||
'isMonitor' in nextFilters;
filters.startDate = nextFilters.startDate || '';
filters.endDate = nextFilters.endDate || '';
filters.granularity = nextFilters.granularity || 'day';
filters.workcenterGroups = Array.isArray(nextFilters.workcenterGroups)
? nextFilters.workcenterGroups
: [];
filters.families = Array.isArray(nextFilters.families) ? nextFilters.families : [];
filters.machines = Array.isArray(nextFilters.machines) ? nextFilters.machines : [];
filters.isProduction = Boolean(nextFilters.isProduction);
filters.isKey = Boolean(nextFilters.isKey);
filters.isMonitor = Boolean(nextFilters.isMonitor);
if (upstreamChanged) {
pruneInvalidMachines();
} }
updateUrlState(); pruneDraftSelections({ showHint: true });
assignFilterState(committedFilters, draftFilters);
await executeCommittedQuery();
}
async function clearFilters() {
runWithDraftPruneSuppressed(() => {
resetToDefaultFilters(draftFilters);
});
autoPruneHint.value = '';
assignFilterState(committedFilters, draftFilters);
await executeCommittedQuery();
}
function updateFilters(nextFilters) {
runWithDraftPruneSuppressed(() => {
assignFilterState(draftFilters, nextFilters);
});
pruneDraftSelections({ showHint: true });
} }
function handleToggleRow(rowId) { function handleToggleRow(rowId) {
@@ -320,15 +430,15 @@ function handleToggleAllRows({ expand, rowIds }) {
} }
function exportCsv() { function exportCsv() {
if (!filters.startDate || !filters.endDate) { if (!committedFilters.startDate || !committedFilters.endDate) {
queryError.value = '請先設定查詢條件'; queryError.value = '請先設定查詢條件';
return; return;
} }
const queryString = buildQueryString(); const queryString = buildQueryStringFromFilters(committedFilters);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = `/api/resource/history/export?${queryString}`; link.href = `/api/resource/history/export?${queryString}`;
link.download = `resource_history_${filters.startDate}_to_${filters.endDate}.csv`; link.download = `resource_history_${committedFilters.startDate}_to_${committedFilters.endDate}.csv`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
@@ -337,38 +447,22 @@ function exportCsv() {
} }
async function initPage() { async function initPage() {
setDefaultDates(); resetToDefaultFilters(committedFilters);
const initial = readInitialFiltersFromUrl(); restoreCommittedFiltersFromUrl();
if (initial.startDate) { runWithDraftPruneSuppressed(() => {
filters.startDate = initial.startDate; assignFilterState(draftFilters, committedFilters);
} });
if (initial.endDate) {
filters.endDate = initial.endDate;
}
if (initial.granularity) {
filters.granularity = initial.granularity;
}
if (initial.workcenterGroups.length > 0) {
filters.workcenterGroups = initial.workcenterGroups;
}
if (initial.families.length > 0) {
filters.families = initial.families;
}
if (initial.machines.length > 0) {
filters.machines = initial.machines;
}
filters.isProduction = initial.isProduction;
filters.isKey = initial.isKey;
filters.isMonitor = initial.isMonitor;
try { try {
await loadOptions(); await loadOptions();
pruneInvalidMachines(); pruneDraftSelections({ showHint: true });
assignFilterState(committedFilters, draftFilters);
} catch (error) { } catch (error) {
queryError.value = error?.message || '載入篩選選項失敗'; queryError.value = error?.message || '載入篩選選項失敗';
} }
updateUrlState();
await executeQuery(); draftWatchReady.value = true;
await executeCommittedQuery();
} }
onMounted(() => { onMounted(() => {
@@ -384,15 +478,17 @@ onMounted(() => {
</header> </header>
<FilterBar <FilterBar
:filters="filters" :filters="draftFilters"
:options="options" :options="filterBarOptions"
:machine-options="machineOptions" :machine-options="machineOptions"
:loading="loading.options || loading.querying" :loading="loading.options || loading.querying"
@update-filters="updateFilters" @update-filters="updateFilters"
@query="executeQuery" @query="applyFilters"
@clear="clearFilters"
/> />
<p v-if="queryError" class="error-banner query-error">{{ queryError }}</p> <p v-if="queryError" class="error-banner query-error">{{ queryError }}</p>
<p v-if="autoPruneHint" class="filter-indicator">{{ autoPruneHint }}</p>
<p v-if="detailWarning" class="filter-indicator active">{{ detailWarning }}</p> <p v-if="detailWarning" class="filter-indicator active">{{ detailWarning }}</p>
<p v-if="exportMessage" class="filter-indicator active">{{ exportMessage }}</p> <p v-if="exportMessage" class="filter-indicator active">{{ exportMessage }}</p>
@@ -418,9 +514,5 @@ onMounted(() => {
@export-csv="exportCsv" @export-csv="exportCsv"
/> />
</div> </div>
<div class="loading-overlay" :class="{ hidden: !loading.initial && !loading.querying }">
<div class="loading-spinner"></div>
</div>
</div> </div>
</template> </template>

View File

@@ -30,7 +30,7 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['update-filters', 'query']); const emit = defineEmits(['update-filters', 'query', 'clear']);
function updateFilters(patch) { function updateFilters(patch) {
emit('update-filters', { emit('update-filters', {
@@ -148,6 +148,7 @@ function updateFilters(patch) {
</div> </div>
<button type="button" class="btn btn-primary" :disabled="loading" @click="$emit('query')">查詢</button> <button type="button" class="btn btn-primary" :disabled="loading" @click="$emit('query')">查詢</button>
<button type="button" class="btn btn-secondary" :disabled="loading" @click="$emit('clear')">清除條件</button>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,145 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildRejectOptionsRequestParams,
pruneRejectFilterSelections,
} from '../src/core/reject-history-filters.js';
import {
buildResourceHistoryQueryParams,
deriveResourceFamilyOptions,
deriveResourceMachineOptions,
pruneResourceFilterSelections,
} from '../src/core/resource-history-filters.js';
test('reject-history draft options params include full context', () => {
const params = buildRejectOptionsRequestParams({
startDate: '2026-02-01',
endDate: '2026-02-07',
workcenterGroups: ['WB'],
packages: ['PKG-A'],
reason: '001_A',
includeExcludedScrap: true,
excludeMaterialScrap: false,
excludePbDiode: true,
});
assert.deepEqual(params, {
start_date: '2026-02-01',
end_date: '2026-02-07',
workcenter_groups: ['WB'],
packages: ['PKG-A'],
reason: '001_A',
include_excluded_scrap: true,
exclude_material_scrap: false,
exclude_pb_diode: true,
});
});
test('reject-history prune removes invalid selected values', () => {
const pruned = pruneRejectFilterSelections(
{
startDate: '2026-02-01',
endDate: '2026-02-07',
workcenterGroups: ['WB', 'FA'],
packages: ['PKG-A', 'PKG-Z'],
reason: '999_X',
includeExcludedScrap: false,
excludeMaterialScrap: true,
excludePbDiode: true,
paretoTop80: true,
},
{
workcenterGroups: [{ name: 'WB', sequence: 1 }],
packages: ['PKG-A'],
reasons: ['001_A', '002_B'],
}
);
assert.deepEqual(pruned.filters.workcenterGroups, ['WB']);
assert.deepEqual(pruned.filters.packages, ['PKG-A']);
assert.equal(pruned.filters.reason, '');
assert.equal(pruned.removedCount, 3);
});
test('resource-history derives families from upstream group and flags', () => {
const resources = [
{ id: 'R1', name: 'MC-01', family: 'FAM-A', workcenterGroup: 'WB', isProduction: true, isKey: true, isMonitor: false },
{ id: 'R2', name: 'MC-02', family: 'FAM-B', workcenterGroup: 'WB', isProduction: true, isKey: false, isMonitor: true },
{ id: 'R3', name: 'MC-03', family: 'FAM-C', workcenterGroup: 'FA', isProduction: true, isKey: true, isMonitor: false },
];
const families = deriveResourceFamilyOptions(resources, {
workcenterGroups: ['WB'],
isProduction: true,
isKey: true,
isMonitor: false,
});
assert.deepEqual(families, ['FAM-A']);
});
test('resource-history machine derivation and prune keep valid selections only', () => {
const resources = [
{ id: 'R1', name: 'MC-01', family: 'FAM-A', workcenterGroup: 'WB', isProduction: true, isKey: false, isMonitor: false },
{ id: 'R2', name: 'MC-02', family: 'FAM-B', workcenterGroup: 'WB', isProduction: true, isKey: false, isMonitor: false },
{ id: 'R3', name: 'MC-03', family: 'FAM-C', workcenterGroup: 'FA', isProduction: true, isKey: true, isMonitor: false },
];
const machineOptions = deriveResourceMachineOptions(resources, {
workcenterGroups: ['WB'],
families: ['FAM-B'],
isProduction: true,
isKey: false,
isMonitor: false,
});
assert.deepEqual(machineOptions, [{ label: 'MC-02', value: 'R2' }]);
const pruned = pruneResourceFilterSelections(
{
startDate: '2026-02-01',
endDate: '2026-02-07',
granularity: 'day',
workcenterGroups: ['WB'],
families: ['FAM-A', 'FAM-Z'],
machines: ['R1', 'R9'],
isProduction: true,
isKey: false,
isMonitor: false,
},
{
familyOptions: ['FAM-A', 'FAM-B'],
machineOptions: [{ label: 'MC-01', value: 'R1' }],
}
);
assert.deepEqual(pruned.filters.families, ['FAM-A']);
assert.deepEqual(pruned.filters.machines, ['R1']);
assert.equal(pruned.removedCount, 2);
});
test('resource-history query params include selected arrays and enabled flags', () => {
const params = buildResourceHistoryQueryParams({
startDate: '2026-02-01',
endDate: '2026-02-07',
granularity: 'week',
workcenterGroups: ['WB'],
families: ['FAM-A'],
machines: ['R1', 'R2'],
isProduction: true,
isKey: false,
isMonitor: true,
});
assert.deepEqual(params, {
start_date: '2026-02-01',
end_date: '2026-02-07',
granularity: 'week',
workcenter_groups: ['WB'],
families: ['FAM-A'],
resource_ids: ['R1', 'R2'],
is_production: '1',
is_monitor: '1',
});
});

View File

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

View File

@@ -0,0 +1,106 @@
## Context
目前 released 報表頁面的篩選模型混用三種模式:
1. 後端動態 options 互篩(`wip-overview` / `wip-detail`
2. 前端用已載入 options 做部分收斂(`resource-history`
3. 查詢驅動或鑽取驅動(`reject-history``hold-*` 等)
這種混用在「探索型」報表(使用者要快速找到可分析的有效組合)會造成明顯成本:
- 下拉選單可選但查詢無資料
- 上游條件變更後,下游已選值失效但仍保留
- 使用者對不同頁面的篩選預期不一致
本提案聚焦補強兩個探索型頁面:
- `reject-history`
- `resource-history`
並建立跨報表可重用的篩選策略基線。
## Goals / Non-Goals
**Goals:**
- 定義「探索型 vs 監控/鑽取型」頁面的篩選策略分級與適用準則。
-`reject-history` 支援草稿條件驅動的 options 互相收斂,減少無效篩選組合。
-`resource-history` 從部分聯動提升為一致聯動(上游變更時,自動 prune 失效選取值)。
- 統一互篩技術細節debounce、請求去重/過期回應保護、無效值剔除、apply/clear 語意一致。
**Non-Goals:**
- 不在本次將所有 released 頁面全面改為互相篩選。
- 不改變報表核心計算邏輯KPI、統計口徑、圖表定義
- 不引入新基礎設施(例如 Redis 新部署)作為互篩前置條件。
## Decisions
### Decision 1: 採用「頁面類型分級」而非全站一刀切
- 決策:
- 探索型頁面要求完整互篩options 受目前草稿條件影響,且自動 prune 失效值)。
- 監控/鑽取型頁面:允許維持輕量篩選 + drilldown不強制完整互篩。
- 理由:
- 探索型頁面的主要任務是「找可分析組合」,互篩是核心可用性功能。
- 監控/鑽取型頁面追求即時性與低操作成本,完整互篩的收益較低。
- 替代方案:
- 全頁面統一完整互篩:一致性高,但開發與維護成本過高,且對監控頁面價值有限。
### Decision 2: `reject-history` 採 server-side options 互篩
- 決策:
- 以 options API 接收草稿條件時間、workcenter group、package、reason、政策旗標回傳收斂後候選值。
- 前端在草稿變更時 debounce 觸發 options reloadapply 時再執行主查詢。
- 理由:
- `reject-history` 篩選受政策旗標影響,僅靠前端靜態 options 無法正確反映後端過濾語意。
- 替代方案:
- 前端本地收斂:無法覆蓋政策條件與後端口徑,容易與實際查詢結果不一致。
### Decision 3: `resource-history` 維持前端收斂為主,但補齊一致 prune
- 決策:
- 仍使用已載入 options/resources 作前端收斂,避免每次草稿變更打後端。
- 補上 family/machine 與上游條件群組、flags一致收斂與失效值自動清理。
- 理由:
- `resource-history` 現有資料結構已適合前端計算選項,性能與實作成本較平衡。
- 替代方案:
- 改成全 server-side options一致性高但請求頻率與後端負載上升且未必有必要。
### Decision 4: 建立可重用互篩行為基線
- 決策:
- 統一以下行為:
- debounce options reload
- request token / stale response guard
- upstream 變更觸發 downstream prune
- clear 時重置至預設並同步 URL
- 理由:
- 降低跨頁面行為偏差,後續新頁面可直接套用。
- 替代方案:
- 每頁自行實作:短期快,但長期易分歧與回歸。
## Risks / Trade-offs
- [Risk] `reject-history` options API 參數變多,查詢複雜度上升
→ Mitigation: 使用現有快取索引與必要欄位投影,限制 options 查詢計算範圍。
- [Risk] 草稿變更觸發 options reload造成頻繁請求
→ Mitigation: debounce + 過期回應丟棄 + 僅在可影響 options 的欄位變更時重載。
- [Risk] prune 行為可能讓使用者感覺「選項被系統吃掉」
→ Mitigation: UI 顯示明確提示(例如選項已失效並自動清除),且 apply/clear 行為一致。
- [Risk] 互篩策略擴大後,頁面之間仍可能有例外需求
→ Mitigation: 先以「頁面分級」定義允許差異的範圍,避免假一致性。
## Migration Plan
1. 先提交 spec-level 規範(策略基線 + 兩頁行為變更)。
2. `reject-history`:擴充 options API 參數與服務層收斂邏輯,前端接入草稿互篩。
3. `resource-history`:補齊前端收斂與 prune 規則,對齊 apply/query 流程。
4. 補 route/service/frontend 測試,確認:
- options 會隨草稿條件收斂
- upstream 變更會清理失效下游值
- apply/clear 行為不回歸
5. 以 feature flag若需要做灰度確認查詢效能後再全面開啟。
Rollback:
- 後端保留原 options 路徑/參數相容;前端可切回「僅 apply 查詢,不做草稿互篩」模式。
## Open Questions
- `reject-history` 的 reason 下拉是否要改為 multi-select目前設計維持單選
- `resource-history` 是否需要將 family options 也改為完全 server-side 來源以統一模式?
- prune 提示文案是否需要全站共用元件(避免各頁文案不一致)?

View File

@@ -0,0 +1,65 @@
# Implementation Notes
## 1) Page classification (exploratory baseline)
- `reject-history`: exploratory
- Requires draft-driven options narrowing for `WORKCENTER_GROUP` / `Package` / `原因`
- Requires stale-response protection + invalid-value auto-prune before apply/export
- `resource-history`: exploratory
- Requires upstream-to-downstream narrowing (`群組/旗標 -> 型號 -> 機台`)
- Requires family/machine auto-prune and committed query execution model
## 2) Frontend responsibilities mapping
- `frontend/src/reject-history/App.vue`
- Holds **draft filters** (`draftFilters`) for options narrowing
- Holds **committed filters** (`committedFilters`) for summary/trend/pareto/list/export
- Performs debounced options reload and stale response discard
- Prunes invalid draft values and applies non-blocking prune hint
- `frontend/src/resource-history/App.vue`
- Holds draft/committed filter split and URL sync on committed only
- Derives family/machine options from loaded resource metadata before first query
- Prunes invalid family/machine values on upstream changes
- Uses apply/clear semantics with deterministic reset
- Shared helper modules for deterministic unit-test coverage:
- `frontend/src/core/reject-history-filters.js`
- `frontend/src/core/resource-history-filters.js`
## 3) Debounce & request-guard conventions
- `reject-history` draft options debounce: `300ms`
- Constant: `OPTIONS_DEBOUNCE_MS = 300`
- stale-response guard: monotonic request token (`activeOptionsRequestId`)
- `reject-history` data queries and list updates
- stale-response guard: monotonic request token (`activeDataRequestId`)
- `resource-history`
- Uses local computed narrowing/prune (no per-change options API call), so no options debounce required
- Query execution remains explicit on apply/clear
## 4) Manual verification checklist (2026-02-22)
- [x] Reject-history: changing draft `WORKCENTER_GROUP` narrows `Package/原因` options
- [x] Reject-history: changing policy toggles narrows options and keeps list/analytics policy alignment
- [x] Reject-history: invalid selected values auto-prune and show non-blocking hint
- [x] Reject-history: apply/export only use committed valid filters
- [x] Resource-history: first load provides usable family/machine candidates before first query
- [x] Resource-history: upstream (`群組/設備旗標`) changes prune invalid `型號/機台`
- [x] Resource-history: query + URL sync use committed filters only
- [x] Resource-history: clear resets deterministic defaults and reloads data
## 5) Monitoring/drilldown non-goal guard
- No filter strategy rollout changes were applied to monitoring/drilldown page code paths in this change.
- Scope verification: file modifications are limited to reject-history/resource-history flows, related SQL/routes/services, tests, and change artifacts.
## 6) Release note entry (draft)
- Title: `Exploratory filter strategy hardening for Reject History and Resource History`
- Scope:
- Added interdependent draft option narrowing and invalid-selection pruning on `reject-history`
- Strengthened upstream-driven family/machine narrowing and prune behavior on `resource-history`
- Unified apply/clear semantics and non-blocking prune feedback on both exploratory pages
- Non-goals:
- No global rollout to all released reports
- No KPI/business formula changes
- No monitoring/drilldown filtering model migration in this release

View File

@@ -0,0 +1,41 @@
## Why
目前已 release 的報表頁面中,篩選行為有明顯差異:有些頁面具備互相影響的動態選項,有些僅做查詢後過濾或單向收斂。這會造成使用者在跨報表分析時的操作落差與誤解,並增加「無結果查詢」與重複嘗試成本,因此需要補強並統一篩選策略。
## What Changes
- 建立跨報表的篩選策略分級與適用準則,明確區分:
- 探索型報表:需支援多欄位互相影響篩選
- 監控/鑽取型報表:保留輕量篩選與 drilldown 為主
- 針對探索型頁面補強互相篩選:
- `reject-history`:選項 API 支援依目前草稿條件回傳受限選項,降低無效組合
- `resource-history`:將現有 machine 單向收斂擴展為上游條件一致收斂並自動剔除失效選取值
- 統一前端互篩行為細節debounce、請求去重/過期保護、無效選取值 prune、apply/clear 一致語意
- 補上互篩策略的驗證與回歸測試(前端互動與後端 option API 行為)
## Capabilities
### New Capabilities
- `report-filter-strategy`: 定義報表篩選策略分級、互篩行為基線與一致性驗證規範
### Modified Capabilities
- `reject-history-page`: 篩選選項改為可依草稿條件動態收斂,並維持既有政策旗標語意
- `resource-history-page`: 篩選聯動由部分收斂提升為完整上游影響與失效值自動清理
## Impact
- **Frontend**
- `frontend/src/reject-history/App.vue`
- `frontend/src/reject-history/components/FilterPanel.vue`
- `frontend/src/resource-history/App.vue`
- `frontend/src/resource-history/components/FilterBar.vue`
- 視需要新增 shared composable互篩 option reload / prune
- **Backend**
- `src/mes_dashboard/routes/reject_history_routes.py`
- `src/mes_dashboard/services/reject_history_service.py`
- 視需要新增/擴充 `resource-history` option API 參數解析與服務層
- **Tests**
- `tests/` 中 reject-history / resource-history 相關 route & service 測試
- `frontend/tests/` 補互篩行為測試
- **Non-goals**
- 本次不要求所有 released 頁面全面改為完整互篩;僅先補強探索型頁面,監控/鑽取型頁面維持現有互動模型

View File

@@ -0,0 +1,36 @@
## MODIFIED Requirements
### Requirement: Reject History page SHALL provide filterable historical query controls
The page SHALL provide a filter area for date range and major production dimensions to drive all report sections, and SHALL provide context-aware option narrowing for exploratory filtering.
#### Scenario: Default filter values
- **WHEN** the page is first loaded
- **THEN** `start_date` and `end_date` SHALL default to a valid recent range
- **THEN** all other dimension filters SHALL default to empty (no restriction)
#### Scenario: Apply and clear filters
- **WHEN** user clicks "查詢"
- **THEN** summary, trend, pareto, and list sections SHALL reload with the same filter set
- **WHEN** user clicks "清除條件"
- **THEN** all filters SHALL reset to defaults and all sections SHALL reload
#### Scenario: Required core filters are present
- **WHEN** the filter panel is rendered
- **THEN** it SHALL include `start_date/end_date` time filter controls
- **THEN** it SHALL include reason filter control
- **THEN** it SHALL include `WORKCENTER_GROUP` filter control
#### Scenario: Draft filter options are interdependent
- **WHEN** user changes draft values for `WORKCENTER_GROUP`, `package`, `reason`, or policy toggles
- **THEN** option candidates for reason/workcenter-group/package SHALL reload under the current draft context
- **THEN** unavailable combinations SHALL NOT remain in selectable options
#### Scenario: Policy toggles affect option scope
- **WHEN** user changes policy toggles (including excluded-scrap and material-scrap switches)
- **THEN** options and query results SHALL use the same policy mode
- **THEN** option narrowing SHALL remain consistent with backend exclusion semantics
#### Scenario: Invalid selected values are pruned
- **WHEN** narrowed options no longer contain previously selected values
- **THEN** invalid selections SHALL be removed automatically before query commit
- **THEN** apply/query SHALL only send valid selected values

View File

@@ -0,0 +1,54 @@
## ADDED Requirements
### Requirement: Report pages SHALL declare a filter strategy class
Each released report page that exposes filter controls SHALL declare whether it is `exploratory` or `monitoring-drilldown` for filter behavior governance.
#### Scenario: Exploratory page classification
- **WHEN** a page is classified as `exploratory`
- **THEN** it SHALL implement interdependent filter options
- **THEN** it SHALL prevent invalid cross-dimension combinations from remaining selectable
#### Scenario: Monitoring/drilldown page classification
- **WHEN** a page is classified as `monitoring-drilldown`
- **THEN** it MAY keep lightweight filters or chart-driven drilldown
- **THEN** it SHALL document why full interdependent options are not required
### Requirement: Exploratory pages SHALL support draft-driven option narrowing
Exploratory pages SHALL update filter option lists according to current draft selections before main query execution.
#### Scenario: Draft change triggers option reload
- **WHEN** user changes any draft filter field that can affect other options
- **THEN** the page SHALL debounce option reload requests
- **THEN** options returned by the latest request SHALL replace prior candidates
#### Scenario: Stale option response protection
- **WHEN** multiple option reload requests are in-flight
- **THEN** only the newest request result SHALL be applied
- **THEN** stale responses SHALL be discarded without mutating UI state
### Requirement: Exploratory pages SHALL prune invalid selected values
Exploratory pages SHALL automatically remove selected values that are no longer valid under the latest upstream draft conditions.
#### Scenario: Upstream change invalidates downstream values
- **WHEN** upstream filters change and previously selected downstream values are absent from narrowed options
- **THEN** those invalid selected values SHALL be removed automatically
- **THEN** the page SHALL keep remaining valid selections unchanged
#### Scenario: Apply query uses pruned committed filters
- **WHEN** user clicks apply/query after pruning occurred
- **THEN** the request SHALL use the current valid filter set only
- **THEN** no removed invalid values SHALL be sent to API parameters
### Requirement: Exploratory pages SHALL keep apply and clear semantics consistent
Exploratory pages SHALL separate draft option narrowing from committed query execution and provide deterministic clear behavior.
#### Scenario: Apply commits current filter state
- **WHEN** user clicks apply/query
- **THEN** all data sections SHALL reload using the same committed filter set
- **THEN** URL state SHALL synchronize with committed filters only
#### Scenario: Clear resets to defaults
- **WHEN** user clicks clear
- **THEN** filters SHALL reset to documented defaults
- **THEN** option candidates SHALL reload for the default state
- **THEN** data sections SHALL reload from the default state

View File

@@ -0,0 +1,30 @@
## MODIFIED Requirements
### Requirement: Resource History page SHALL support multi-select filtering
The page SHALL provide multi-select dropdown filters for workcenter groups and families, and SHALL support interdependent narrowing with machine options and selected-value pruning.
#### Scenario: Multi-select dropdown
- **WHEN** user clicks a multi-select dropdown trigger
- **THEN** a dropdown SHALL display with checkboxes for each option
- **THEN** "Select All" and "Clear All" buttons SHALL be available
- **THEN** clicking outside the dropdown SHALL close it
#### Scenario: Filter options loading
- **WHEN** the page loads
- **THEN** workcenter groups and families SHALL load from `GET /api/resource/history/options`
- **THEN** machine candidates SHALL be derivable before first query from loaded option resources
#### Scenario: Upstream filters narrow downstream options
- **WHEN** user changes upstream filters (`workcenterGroups`, `families`, equipment-type flags)
- **THEN** machine options SHALL be recomputed to only include matching resources
- **THEN** narrowed options SHALL be reflected immediately in filter controls
#### Scenario: Invalid selected machines are pruned
- **WHEN** upstream filters change and selected machines are no longer valid
- **THEN** invalid selected machine values SHALL be removed automatically
- **THEN** remaining valid selected machine values SHALL be preserved
#### Scenario: Equipment type checkboxes
- **WHEN** user toggles a checkbox (生產設備, 重點設備, 監控設備)
- **THEN** the next query SHALL include the corresponding filter parameter
- **THEN** option narrowing SHALL also honor the same checkbox conditions

View File

@@ -0,0 +1,38 @@
## 1. Spec and strategy baseline alignment
- [x] 1.1 Confirm page classification for this change (`reject-history`=exploratory, `resource-history`=exploratory) and record in implementation notes
- [x] 1.2 Verify filter behavior baseline (debounce, stale-response guard, prune, apply/clear semantics) is mapped to concrete frontend responsibilities
## 2. Reject History interdependent options hardening
- [x] 2.1 Extend reject-history options route to accept full draft filter context (date range, workcenter_groups, packages, reason, policy toggles)
- [x] 2.2 Update reject-history service option query logic to return narrowed reason/workcenter/package candidates under the same policy semantics as list/analytics
- [x] 2.3 Update reject-history frontend filter flow to reload options on draft-relevant changes with debounce and stale-response protection
- [x] 2.4 Add invalid-selection pruning for reject-history filters after options reload (remove values no longer in candidate set)
- [x] 2.5 Ensure apply/query and export requests only use committed valid filters
## 3. Resource History interdependent filtering hardening
- [x] 3.1 Refine resource-history option-derivation logic so upstream changes consistently narrow downstream machine options
- [x] 3.2 Add/verify family and machine selection pruning when upstream filters or equipment-type flags invalidate selected values
- [x] 3.3 Ensure resource-history query execution always uses the pruned committed filter set and preserves URL synchronization behavior
- [x] 3.4 Verify first-load filter usability (options and machine candidates usable before first query)
## 4. Shared UX consistency and guardrails
- [x] 4.1 Normalize apply/clear semantics and naming within affected pages without altering existing report-specific business logic
- [x] 4.2 Add consistent user feedback for auto-pruned selections (non-blocking hint/banner or equivalent)
- [x] 4.3 Confirm debounce interval and request-token guard values are documented and consistent with project conventions
## 5. Tests and validation
- [x] 5.1 Add/adjust backend tests for reject-history options narrowing and policy-toggle consistency
- [x] 5.2 Add/adjust frontend tests for interdependent option reload and prune behavior on reject-history/resource-history
- [x] 5.3 Run targeted test set (backend route/service + frontend unit) and resolve regressions
- [x] 5.4 Run frontend build validation and verify no behavior regressions on released pages outside this scope
## 6. Rollout and regression checks
- [x] 6.1 Perform manual verification checklist for exploratory use cases (cross-dimension narrowing, clear/apply, URL restore)
- [x] 6.2 Verify monitoring/drilldown pages remain unchanged in behavior (no accidental full interdependent filtering rollout)
- [x] 6.3 Prepare release note entry describing filter strategy hardening scope and non-goals

View File

@@ -4,7 +4,7 @@
TBD - created by archiving change reject-history-query-page. Update Purpose after archive. TBD - created by archiving change reject-history-query-page. Update Purpose after archive.
## Requirements ## Requirements
### Requirement: Reject History page SHALL provide filterable historical query controls ### Requirement: Reject History page SHALL provide filterable historical query controls
The page SHALL provide a filter area for date range and major production dimensions to drive all report sections. The page SHALL provide a filter area for date range and major production dimensions to drive all report sections, and SHALL provide context-aware option narrowing for exploratory filtering.
#### Scenario: Default filter values #### Scenario: Default filter values
- **WHEN** the page is first loaded - **WHEN** the page is first loaded
@@ -23,6 +23,21 @@ The page SHALL provide a filter area for date range and major production dimensi
- **THEN** it SHALL include reason filter control - **THEN** it SHALL include reason filter control
- **THEN** it SHALL include `WORKCENTER_GROUP` filter control - **THEN** it SHALL include `WORKCENTER_GROUP` filter control
#### Scenario: Draft filter options are interdependent
- **WHEN** user changes draft values for `WORKCENTER_GROUP`, `package`, `reason`, or policy toggles
- **THEN** option candidates for reason/workcenter-group/package SHALL reload under the current draft context
- **THEN** unavailable combinations SHALL NOT remain in selectable options
#### Scenario: Policy toggles affect option scope
- **WHEN** user changes policy toggles (including excluded-scrap and material-scrap switches)
- **THEN** options and query results SHALL use the same policy mode
- **THEN** option narrowing SHALL remain consistent with backend exclusion semantics
#### Scenario: Invalid selected values are pruned
- **WHEN** narrowed options no longer contain previously selected values
- **THEN** invalid selections SHALL be removed automatically before query commit
- **THEN** apply/query SHALL only send valid selected values
### Requirement: Reject History page SHALL expose yield-exclusion toggle control ### Requirement: Reject History page SHALL expose yield-exclusion toggle control
The page SHALL let users decide whether to include policy-marked scrap in yield calculations. The page SHALL let users decide whether to include policy-marked scrap in yield calculations.
@@ -139,4 +154,3 @@ The page SHALL keep the same semantic grouping across desktop and mobile layouts
- **WHEN** viewport width is below responsive breakpoint - **WHEN** viewport width is below responsive breakpoint
- **THEN** cards and chart panels SHALL stack in a single column - **THEN** cards and chart panels SHALL stack in a single column
- **THEN** filter controls SHALL remain operable without horizontal overflow - **THEN** filter controls SHALL remain operable without horizontal overflow

View File

@@ -0,0 +1,58 @@
# report-filter-strategy Specification
## Purpose
Define cross-report filter strategy governance, including exploratory-page interdependent filtering baselines.
## Requirements
### Requirement: Report pages SHALL declare a filter strategy class
Each released report page that exposes filter controls SHALL declare whether it is `exploratory` or `monitoring-drilldown` for filter behavior governance.
#### Scenario: Exploratory page classification
- **WHEN** a page is classified as `exploratory`
- **THEN** it SHALL implement interdependent filter options
- **THEN** it SHALL prevent invalid cross-dimension combinations from remaining selectable
#### Scenario: Monitoring/drilldown page classification
- **WHEN** a page is classified as `monitoring-drilldown`
- **THEN** it MAY keep lightweight filters or chart-driven drilldown
- **THEN** it SHALL document why full interdependent options are not required
### Requirement: Exploratory pages SHALL support draft-driven option narrowing
Exploratory pages SHALL update filter option lists according to current draft selections before main query execution.
#### Scenario: Draft change triggers option reload
- **WHEN** user changes any draft filter field that can affect other options
- **THEN** the page SHALL debounce option reload requests
- **THEN** options returned by the latest request SHALL replace prior candidates
#### Scenario: Stale option response protection
- **WHEN** multiple option reload requests are in-flight
- **THEN** only the newest request result SHALL be applied
- **THEN** stale responses SHALL be discarded without mutating UI state
### Requirement: Exploratory pages SHALL prune invalid selected values
Exploratory pages SHALL automatically remove selected values that are no longer valid under the latest upstream draft conditions.
#### Scenario: Upstream change invalidates downstream values
- **WHEN** upstream filters change and previously selected downstream values are absent from narrowed options
- **THEN** those invalid selected values SHALL be removed automatically
- **THEN** the page SHALL keep remaining valid selections unchanged
#### Scenario: Apply query uses pruned committed filters
- **WHEN** user clicks apply/query after pruning occurred
- **THEN** the request SHALL use the current valid filter set only
- **THEN** no removed invalid values SHALL be sent to API parameters
### Requirement: Exploratory pages SHALL keep apply and clear semantics consistent
Exploratory pages SHALL separate draft option narrowing from committed query execution and provide deterministic clear behavior.
#### Scenario: Apply commits current filter state
- **WHEN** user clicks apply/query
- **THEN** all data sections SHALL reload using the same committed filter set
- **THEN** URL state SHALL synchronize with committed filters only
#### Scenario: Clear resets to defaults
- **WHEN** user clicks clear
- **THEN** filters SHALL reset to documented defaults
- **THEN** option candidates SHALL reload for the default state
- **THEN** data sections SHALL reload from the default state

View File

@@ -84,8 +84,8 @@ The page SHALL allow users to specify time range and aggregation granularity.
- **THEN** summary and detail APIs SHALL be called in parallel - **THEN** summary and detail APIs SHALL be called in parallel
- **THEN** all 4 charts, KPI cards, and detail table SHALL update with results - **THEN** all 4 charts, KPI cards, and detail table SHALL update with results
### Requirement: Resource History page SHALL support multi-select filtering ### Requirement: Resource History page SHALL support multi-select filtering
The page SHALL provide multi-select dropdown filters for workcenter groups and families. The page SHALL provide multi-select dropdown filters for workcenter groups and families, and SHALL support interdependent narrowing with machine options and selected-value pruning.
#### Scenario: Multi-select dropdown #### Scenario: Multi-select dropdown
- **WHEN** user clicks a multi-select dropdown trigger - **WHEN** user clicks a multi-select dropdown trigger
@@ -93,13 +93,25 @@ The page SHALL provide multi-select dropdown filters for workcenter groups and f
- **THEN** "Select All" and "Clear All" buttons SHALL be available - **THEN** "Select All" and "Clear All" buttons SHALL be available
- **THEN** clicking outside the dropdown SHALL close it - **THEN** clicking outside the dropdown SHALL close it
#### Scenario: Filter options loading #### Scenario: Filter options loading
- **WHEN** the page loads - **WHEN** the page loads
- **THEN** workcenter groups and families SHALL load from `GET /api/resource/history/options` - **THEN** workcenter groups and families SHALL load from `GET /api/resource/history/options`
- **THEN** machine candidates SHALL be derivable before first query from loaded option resources
#### Scenario: Equipment type checkboxes
- **WHEN** user toggles a checkbox (生產設備, 重點設備, 監控設備) #### Scenario: Upstream filters narrow downstream options
- **THEN** the next query SHALL include the corresponding filter parameter - **WHEN** user changes upstream filters (`workcenterGroups`, `families`, equipment-type flags)
- **THEN** machine options SHALL be recomputed to only include matching resources
- **THEN** narrowed options SHALL be reflected immediately in filter controls
#### Scenario: Invalid selected machines are pruned
- **WHEN** upstream filters change and selected machines are no longer valid
- **THEN** invalid selected machine values SHALL be removed automatically
- **THEN** remaining valid selected machine values SHALL be preserved
#### Scenario: Equipment type checkboxes
- **WHEN** user toggles a checkbox (生產設備, 重點設備, 監控設備)
- **THEN** the next query SHALL include the corresponding filter parameter
- **THEN** option narrowing SHALL also honor the same checkbox conditions
### Requirement: Resource History page SHALL support CSV export ### Requirement: Resource History page SHALL support CSV export
The page SHALL allow users to export the current query results as CSV. The page SHALL allow users to export the current query results as CSV.

View File

@@ -3,11 +3,13 @@
from __future__ import annotations from __future__ import annotations
import os
from datetime import date, timedelta from datetime import date, timedelta
from typing import Optional from typing import Optional
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key
from mes_dashboard.core.rate_limit import configured_rate_limit from mes_dashboard.core.rate_limit import configured_rate_limit
from mes_dashboard.services.reject_history_service import ( from mes_dashboard.services.reject_history_service import (
export_csv, export_csv,
@@ -20,6 +22,9 @@ from mes_dashboard.services.reject_history_service import (
) )
reject_history_bp = Blueprint("reject_history", __name__) reject_history_bp = Blueprint("reject_history", __name__)
_REJECT_HISTORY_OPTIONS_CACHE_TTL_SECONDS = int(
os.getenv("REJECT_HISTORY_OPTIONS_CACHE_TTL_SECONDS", "14400")
)
_REJECT_HISTORY_LIST_RATE_LIMIT = configured_rate_limit( _REJECT_HISTORY_LIST_RATE_LIMIT = configured_rate_limit(
bucket="reject-history-list", bucket="reject-history-list",
@@ -83,6 +88,16 @@ def _parse_multi_param(name: str) -> list[str]:
return deduped return deduped
def _normalized_list_for_cache(values: Optional[list[str]]) -> Optional[list[str]]:
if not values:
return None
return sorted({
str(value).strip()
for value in values
if str(value).strip()
})
def _extract_meta( def _extract_meta(
payload: dict, payload: dict,
include_excluded_scrap: bool, include_excluded_scrap: bool,
@@ -135,10 +150,41 @@ def api_reject_history_options():
if bool_error: if bool_error:
return jsonify(bool_error[0]), bool_error[1] return jsonify(bool_error[0]), bool_error[1]
workcenter_groups = _parse_multi_param("workcenter_groups") or None
packages = _parse_multi_param("packages") or None
categories = _parse_multi_param("categories") or None
reasons = _parse_multi_param("reasons")
single_reason = _parse_multi_param("reason")
for reason in single_reason:
if reason not in reasons:
reasons.append(reason)
reasons = reasons or None
cache_filters = {
"start_date": start_date,
"end_date": end_date,
"workcenter_groups": _normalized_list_for_cache(workcenter_groups),
"packages": _normalized_list_for_cache(packages),
"reasons": _normalized_list_for_cache(reasons),
"categories": _normalized_list_for_cache(categories),
"include_excluded_scrap": bool(include_excluded_scrap),
"exclude_material_scrap": bool(exclude_material_scrap),
"exclude_pb_diode": bool(exclude_pb_diode),
}
cache_key = make_cache_key("reject_history_options_v2", filters=cache_filters)
cached_payload = cache_get(cache_key)
if cached_payload is not None:
return jsonify(cached_payload)
try: try:
result = get_filter_options( result = get_filter_options(
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
workcenter_groups=workcenter_groups,
packages=packages,
reasons=reasons,
categories=categories,
include_excluded_scrap=include_excluded_scrap, include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap, exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode, exclude_pb_diode=exclude_pb_diode,
@@ -149,7 +195,13 @@ def api_reject_history_options():
exclude_material_scrap, exclude_material_scrap,
exclude_pb_diode, exclude_pb_diode,
) )
return jsonify({"success": True, "data": data, "meta": meta}) payload = {"success": True, "data": data, "meta": meta}
cache_set(
cache_key,
payload,
ttl=max(_REJECT_HISTORY_OPTIONS_CACHE_TTL_SECONDS, 1),
)
return jsonify(payload)
except ValueError as exc: except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400 return jsonify({"success": False, "error": str(exc)}), 400
except Exception: except Exception:

View File

@@ -12,7 +12,6 @@ from typing import Any, Dict, Generator, Iterable, Optional
import pandas as pd import pandas as pd
from mes_dashboard.core.database import read_sql_df from mes_dashboard.core.database import read_sql_df
from mes_dashboard.services.filter_cache import get_workcenter_groups
from mes_dashboard.services.scrap_reason_exclusion_cache import get_excluded_reasons from mes_dashboard.services.scrap_reason_exclusion_cache import get_excluded_reasons
from mes_dashboard.sql import QueryBuilder, SQLLoader from mes_dashboard.sql import QueryBuilder, SQLLoader
@@ -85,6 +84,22 @@ def _to_date_str(value: Any) -> str:
return text return text
def _to_datetime_str(value: Any) -> str:
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(value, pd.Timestamp):
return value.to_pydatetime().strftime("%Y-%m-%d %H:%M:%S")
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
text = _normalize_text(value)
if not text:
return ""
try:
return pd.to_datetime(text).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return text
def _date_bucket_expr(granularity: str) -> str: def _date_bucket_expr(granularity: str) -> str:
if granularity == "week": if granularity == "week":
return "TRUNC(b.TXN_DAY, 'IW')" return "TRUNC(b.TXN_DAY, 'IW')"
@@ -295,67 +310,85 @@ def _list_to_csv(
buffer.truncate(0) buffer.truncate(0)
def _extract_distinct_text_values(df: pd.DataFrame, column: str) -> list[str]:
if df is None or df.empty or column not in df.columns:
return []
return sorted({
_normalize_text(value)
for value in df[column].dropna()
if _normalize_text(value)
})
def _extract_workcenter_group_options(df: pd.DataFrame) -> list[dict[str, Any]]:
if df is None or df.empty or "WORKCENTER_GROUP" not in df.columns:
return []
sequence_by_name: dict[str, int] = {}
for _, row in df.iterrows():
name = _normalize_text(row.get("WORKCENTER_GROUP"))
if not name:
continue
sequence = _as_int(row.get("WORKCENTERSEQUENCE_GROUP"))
if name not in sequence_by_name:
sequence_by_name[name] = sequence
continue
sequence_by_name[name] = min(sequence_by_name[name], sequence)
ordered_names = sorted(
sequence_by_name.keys(),
key=lambda item: (sequence_by_name[item], item),
)
return [{"name": name, "sequence": sequence_by_name[name]} for name in ordered_names]
def _has_material_scrap(df: pd.DataFrame) -> bool:
if df is None or df.empty or "SCRAP_OBJECTTYPE" not in df.columns:
return False
return (
df["SCRAP_OBJECTTYPE"]
.map(lambda value: _normalize_text(value).upper())
.eq("MATERIAL")
.any()
)
def get_filter_options( def get_filter_options(
*, *,
start_date: str, start_date: str,
end_date: str, end_date: str,
workcenter_groups: Optional[list[str]] = None,
packages: Optional[list[str]] = None,
reasons: Optional[list[str]] = None,
categories: Optional[list[str]] = None,
include_excluded_scrap: bool = False, include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True, exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True, exclude_pb_diode: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return workcenter-group / package / reason options (single DB query).""" """Return options under current draft filter context with one SQL round-trip."""
_validate_range(start_date, end_date) _validate_range(start_date, end_date)
where_clause, params, meta = _build_where_clause( where_clause, params, meta = _build_where_clause(
workcenter_groups=workcenter_groups,
packages=packages,
reasons=reasons,
categories=categories,
include_excluded_scrap=include_excluded_scrap, include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap, exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode, exclude_pb_diode=exclude_pb_diode,
) )
sql = _prepare_sql("filter_options", where_clause=where_clause) sql = _prepare_sql("filter_options", where_clause=where_clause)
df = read_sql_df(sql, _common_params(start_date, end_date, params)) df = read_sql_df(sql, _common_params(start_date, end_date, params))
if df is None:
df = pd.DataFrame()
reasons: list[str] = [] reason_options = _extract_distinct_text_values(df, "REASON")
packages: list[str] = [] if _has_material_scrap(df) and MATERIAL_REASON_OPTION not in reason_options:
has_material_option = False
if df is not None and not df.empty:
reasons = sorted({
_normalize_text(v)
for v in df["REASON"].dropna()
if _normalize_text(v)
})
packages = sorted({
_normalize_text(v)
for v in df["PACKAGE"].dropna()
if _normalize_text(v)
})
if "SCRAP_OBJECTTYPE" in df.columns:
has_material_option = (
df["SCRAP_OBJECTTYPE"]
.apply(lambda v: str(v or "").strip().upper())
.eq("MATERIAL")
.any()
)
groups_raw = get_workcenter_groups() or []
workcenter_groups = []
for item in groups_raw:
name = _normalize_text(item.get("name"))
if not name:
continue
workcenter_groups.append(
{
"name": name,
"sequence": _as_int(item.get("sequence")),
}
)
reason_options = sorted(set(reasons))
if has_material_option and MATERIAL_REASON_OPTION not in reason_options:
reason_options.append(MATERIAL_REASON_OPTION) reason_options.append(MATERIAL_REASON_OPTION)
return { return {
"workcenter_groups": workcenter_groups, "workcenter_groups": _extract_workcenter_group_options(df),
"packages": sorted(set(packages)), "packages": _extract_distinct_text_values(df, "PACKAGE"),
"reasons": reason_options, "reasons": reason_options,
"meta": meta, "meta": meta,
} }
@@ -576,6 +609,7 @@ def query_list(
for _, row in df.iterrows(): for _, row in df.iterrows():
items.append( items.append(
{ {
"TXN_TIME": _to_datetime_str(row.get("TXN_TIME")),
"TXN_DAY": _to_date_str(row.get("TXN_DAY")), "TXN_DAY": _to_date_str(row.get("TXN_DAY")),
"TXN_MONTH": _normalize_text(row.get("TXN_MONTH")), "TXN_MONTH": _normalize_text(row.get("TXN_MONTH")),
"WORKCENTER_GROUP": _normalize_text(row.get("WORKCENTER_GROUP")), "WORKCENTER_GROUP": _normalize_text(row.get("WORKCENTER_GROUP")),

View File

@@ -5,13 +5,20 @@
-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b) -- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b)
{{ BASE_WITH_CTE }} {{ BASE_WITH_CTE }}
SELECT SELECT
b.LOSSREASONNAME AS REASON, b.WORKCENTER_GROUP,
b.PRODUCTLINENAME AS PACKAGE, b.WORKCENTERSEQUENCE_GROUP,
b.SCRAP_OBJECTTYPE, b.LOSSREASONNAME AS REASON,
SUM(b.REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY, b.PRODUCTLINENAME AS PACKAGE,
SUM(b.DEFECT_QTY) AS DEFECT_QTY b.SCRAP_OBJECTTYPE,
FROM base b SUM(b.REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY,
{{ WHERE_CLAUSE }} SUM(b.DEFECT_QTY) AS DEFECT_QTY
GROUP BY b.LOSSREASONNAME, b.PRODUCTLINENAME, b.SCRAP_OBJECTTYPE FROM base b
HAVING SUM(b.REJECT_TOTAL_QTY) > 0 OR SUM(b.DEFECT_QTY) > 0 {{ WHERE_CLAUSE }}
GROUP BY
b.WORKCENTER_GROUP,
b.WORKCENTERSEQUENCE_GROUP,
b.LOSSREASONNAME,
b.PRODUCTLINENAME,
b.SCRAP_OBJECTTYPE
HAVING SUM(b.REJECT_TOTAL_QTY) > 0 OR SUM(b.DEFECT_QTY) > 0

View File

@@ -23,6 +23,7 @@ paged AS (
OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY
) )
SELECT SELECT
p.TXN_TIME,
p.TXN_DAY, p.TXN_DAY,
p.TXN_MONTH, p.TXN_MONTH,
p.WORKCENTER_GROUP, p.WORKCENTER_GROUP,

View File

@@ -18,6 +18,7 @@ WITH workcenter_map AS (
), ),
reject_raw AS ( reject_raw AS (
SELECT SELECT
r.TXNDATE,
TRUNC(r.TXNDATE) AS TXN_DAY, TRUNC(r.TXNDATE) AS TXN_DAY,
TO_CHAR(TRUNC(r.TXNDATE), 'YYYY-MM') AS TXN_MONTH, TO_CHAR(TRUNC(r.TXNDATE), 'YYYY-MM') AS TXN_MONTH,
r.CONTAINERID, r.CONTAINERID,
@@ -90,6 +91,7 @@ daily_agg AS (
LOSSREASONNAME, LOSSREASONNAME,
LOSSREASON_CODE, LOSSREASON_CODE,
REJECTCATEGORYNAME, REJECTCATEGORYNAME,
MIN(TXNDATE) AS TXN_TIME,
COUNT(*) AS REJECT_EVENT_ROWS, COUNT(*) AS REJECT_EVENT_ROWS,
COUNT(DISTINCT PJ_WORKORDER) AS AFFECTED_WORKORDER_COUNT, COUNT(DISTINCT PJ_WORKORDER) AS AFFECTED_WORKORDER_COUNT,
SUM(CASE WHEN EVENT_RN = 1 THEN MOVEINQTY ELSE 0 END) AS MOVEIN_QTY, SUM(CASE WHEN EVENT_RN = 1 THEN MOVEINQTY ELSE 0 END) AS MOVEIN_QTY,
@@ -122,6 +124,7 @@ daily_agg AS (
REJECTCATEGORYNAME REJECTCATEGORYNAME
) )
SELECT SELECT
TXN_TIME,
TXN_DAY, TXN_DAY,
TXN_MONTH, TXN_MONTH,
CONTAINERID, CONTAINERID,

View File

@@ -40,6 +40,59 @@ class TestRejectHistoryPageRoute(unittest.TestCase):
class TestRejectHistoryApiRoutes(TestRejectHistoryRoutesBase): class TestRejectHistoryApiRoutes(TestRejectHistoryRoutesBase):
@patch('mes_dashboard.routes.reject_history_routes.get_filter_options')
@patch('mes_dashboard.routes.reject_history_routes.cache_get')
def test_options_uses_cache_hit_without_service_call(self, mock_cache_get, mock_options):
mock_cache_get.return_value = {
'success': True,
'data': {'workcenter_groups': [], 'packages': [], 'reasons': []},
'meta': {'include_excluded_scrap': False},
}
response = self.client.get(
'/api/reject-history/options?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.assertIn('data', payload)
mock_options.assert_not_called()
@patch('mes_dashboard.routes.reject_history_routes.get_filter_options')
def test_options_passes_full_draft_filters(self, mock_options):
mock_options.return_value = {
'workcenter_groups': [],
'packages': [],
'reasons': [],
'meta': {},
}
response = self.client.get(
'/api/reject-history/options'
'?start_date=2026-02-01'
'&end_date=2026-02-07'
'&workcenter_groups=WB'
'&workcenter_groups=TEST'
'&packages=PKG-A'
'&reasons=001_A'
'&reason=002_B'
'&include_excluded_scrap=true'
'&exclude_material_scrap=false'
'&exclude_pb_diode=true'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(payload['success'])
_, kwargs = mock_options.call_args
self.assertEqual(kwargs['workcenter_groups'], ['WB', 'TEST'])
self.assertEqual(kwargs['packages'], ['PKG-A'])
self.assertEqual(kwargs['reasons'], ['001_A', '002_B'])
self.assertIs(kwargs['include_excluded_scrap'], True)
self.assertIs(kwargs['exclude_material_scrap'], False)
self.assertIs(kwargs['exclude_pb_diode'], True)
def test_summary_missing_dates_returns_400(self): def test_summary_missing_dates_returns_400(self):
response = self.client.get('/api/reject-history/summary') response = self.client.get('/api/reject-history/summary')
payload = json.loads(response.data) payload = json.loads(response.data)

View File

@@ -115,47 +115,66 @@ def test_build_where_clause_include_override_skips_reason_prefix_policy(monkeypa
def test_get_filter_options_includes_packages(monkeypatch): def test_get_filter_options_includes_packages(monkeypatch):
monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set()) monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set())
monkeypatch.setattr( captured: dict = {}
svc,
"get_workcenter_groups",
lambda: [
{"name": "WB", "sequence": 1},
{"name": "FA", "sequence": 2},
],
)
def _fake_read_sql_df(sql, _params=None): def _fake_read_sql_df(_sql, params=None):
if "AS REASON" in sql: captured["params"] = dict(params or {})
return pd.DataFrame([{"REASON": "R1"}, {"REASON": "R2"}]) return pd.DataFrame(
if "AS PACKAGE" in sql: [
return pd.DataFrame([{"PACKAGE": "PKG-A"}, {"PACKAGE": "PKG-B"}]) {
return pd.DataFrame() "WORKCENTER_GROUP": "WB",
"WORKCENTERSEQUENCE_GROUP": 1,
"REASON": "R1",
"PACKAGE": "PKG-A",
"SCRAP_OBJECTTYPE": "LOT",
},
{
"WORKCENTER_GROUP": "FA",
"WORKCENTERSEQUENCE_GROUP": 5,
"REASON": "R2",
"PACKAGE": "PKG-B",
"SCRAP_OBJECTTYPE": "LOT",
},
]
)
monkeypatch.setattr(svc, "read_sql_df", _fake_read_sql_df) monkeypatch.setattr(svc, "read_sql_df", _fake_read_sql_df)
result = svc.get_filter_options( result = svc.get_filter_options(
start_date="2026-02-01", start_date="2026-02-01",
end_date="2026-02-07", end_date="2026-02-07",
workcenter_groups=["WB"],
packages=["PKG-A"],
reasons=["R1"],
include_excluded_scrap=False, include_excluded_scrap=False,
) )
assert result["reasons"] == ["R1", "R2"] assert result["reasons"] == ["R1", "R2"]
assert result["packages"] == ["PKG-A", "PKG-B"] assert result["packages"] == ["PKG-A", "PKG-B"]
assert result["workcenter_groups"][0]["name"] == "WB" assert result["workcenter_groups"][0]["name"] == "WB"
assert result["workcenter_groups"][1]["name"] == "FA"
assert captured["params"]["start_date"] == "2026-02-01"
assert captured["params"]["end_date"] == "2026-02-07"
assert "WB" in captured["params"].values()
assert "PKG-A" in captured["params"].values()
assert "R1" in captured["params"].values()
def test_get_filter_options_appends_material_reason_option(monkeypatch): def test_get_filter_options_appends_material_reason_option(monkeypatch):
monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set()) monkeypatch.setattr(svc, "get_excluded_reasons", lambda force_refresh=False: set())
monkeypatch.setattr(svc, "get_workcenter_groups", lambda: []) def _fake_read_sql_df(_sql, _params=None):
return pd.DataFrame(
def _fake_read_sql_df(sql, _params=None): [
if "AS REASON" in sql: {
return pd.DataFrame([{"REASON": "001_TEST"}]) "WORKCENTER_GROUP": "WB",
if "AS PACKAGE" in sql: "WORKCENTERSEQUENCE_GROUP": 1,
return pd.DataFrame([{"PACKAGE": "PKG-A"}]) "REASON": "001_TEST",
if "AS HAS_MATERIAL" in sql: "PACKAGE": "PKG-A",
return pd.DataFrame([{"HAS_MATERIAL": 1}]) "SCRAP_OBJECTTYPE": "MATERIAL",
return pd.DataFrame() }
]
)
monkeypatch.setattr(svc, "read_sql_df", _fake_read_sql_df) monkeypatch.setattr(svc, "read_sql_df", _fake_read_sql_df)