feat: archive filter strategy change and optimize reject-history filters
This commit is contained in:
@@ -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
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
151
frontend/src/core/reject-history-filters.js
Normal file
151
frontend/src/core/reject-history-filters.js
Normal 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;
|
||||||
|
}
|
||||||
159
frontend/src/core/resource-history-filters.js
Normal file
159
frontend/src/core/resource-history-filters.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
145
frontend/tests/report-filter-strategy.test.js
Normal file
145
frontend/tests/report-filter-strategy.test.js
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-02-22
|
||||||
@@ -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 reload;apply 時再執行主查詢。
|
||||||
|
- 理由:
|
||||||
|
- `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 提示文案是否需要全站共用元件(避免各頁文案不一致)?
|
||||||
@@ -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
|
||||||
@@ -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 頁面全面改為完整互篩;僅先補強探索型頁面,監控/鑽取型頁面維持現有互動模型
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
58
openspec/specs/report-filter-strategy/spec.md
Normal file
58
openspec/specs/report-filter-strategy/spec.md
Normal 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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user