From 5e5cc487acd320479204cea076107aa760688d85 Mon Sep 17 00:00:00 2001 From: egg Date: Sun, 22 Feb 2026 12:50:05 +0800 Subject: [PATCH] feat: archive filter strategy change and optimize reject-history filters --- .env.example | 3 + frontend/src/core/reject-history-filters.js | 151 ++++++ frontend/src/core/resource-history-filters.js | 159 ++++++ frontend/src/reject-history/App.vue | 480 ++++++++++++------ .../reject-history/components/DetailTable.vue | 48 +- frontend/src/reject-history/style.css | 32 +- frontend/src/resource-history/App.vue | 338 +++++++----- .../resource-history/components/FilterBar.vue | 3 +- frontend/tests/report-filter-strategy.test.js | 145 ++++++ .../.openspec.yaml | 2 + .../design.md | 106 ++++ .../implementation-notes.md | 65 +++ .../proposal.md | 41 ++ .../specs/reject-history-page/spec.md | 36 ++ .../specs/report-filter-strategy/spec.md | 54 ++ .../specs/resource-history-page/spec.md | 30 ++ .../tasks.md | 38 ++ openspec/specs/reject-history-page/spec.md | 18 +- openspec/specs/report-filter-strategy/spec.md | 58 +++ openspec/specs/resource-history-page/spec.md | 30 +- .../routes/reject_history_routes.py | 54 +- .../services/reject_history_service.py | 118 +++-- .../sql/reject_history/filter_options.sql | 27 +- src/mes_dashboard/sql/reject_history/list.sql | 1 + .../reject_history/performance_daily_lot.sql | 3 + tests/test_reject_history_routes.py | 53 ++ tests/test_reject_history_service.py | 67 ++- 27 files changed, 1764 insertions(+), 396 deletions(-) create mode 100644 frontend/src/core/reject-history-filters.js create mode 100644 frontend/src/core/resource-history-filters.js create mode 100644 frontend/tests/report-filter-strategy.test.js create mode 100644 openspec/changes/archive/2026-02-22-report-filter-strategy-hardening/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-22-report-filter-strategy-hardening/design.md create mode 100644 openspec/changes/archive/2026-02-22-report-filter-strategy-hardening/implementation-notes.md create mode 100644 openspec/changes/archive/2026-02-22-report-filter-strategy-hardening/proposal.md create mode 100644 openspec/changes/archive/2026-02-22-report-filter-strategy-hardening/specs/reject-history-page/spec.md create mode 100644 openspec/changes/archive/2026-02-22-report-filter-strategy-hardening/specs/report-filter-strategy/spec.md create mode 100644 openspec/changes/archive/2026-02-22-report-filter-strategy-hardening/specs/resource-history-page/spec.md create mode 100644 openspec/changes/archive/2026-02-22-report-filter-strategy-hardening/tasks.md create mode 100644 openspec/specs/report-filter-strategy/spec.md diff --git a/.env.example b/.env.example index 028f494..38c31bc 100644 --- a/.env.example +++ b/.env.example @@ -226,6 +226,9 @@ RUNTIME_CONTRACT_ENFORCE=false # Health endpoint memo cache TTL in seconds 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 # ============================================================ diff --git a/frontend/src/core/reject-history-filters.js b/frontend/src/core/reject-history-filters.js new file mode 100644 index 0000000..fde98d2 --- /dev/null +++ b/frontend/src/core/reject-history-filters.js @@ -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; +} diff --git a/frontend/src/core/resource-history-filters.js b/frontend/src/core/resource-history-filters.js new file mode 100644 index 0000000..6e198b5 --- /dev/null +++ b/frontend/src/core/resource-history-filters.js @@ -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; +} diff --git a/frontend/src/reject-history/App.vue b/frontend/src/reject-history/App.vue index 705634b..1fea5d9 100644 --- a/frontend/src/reject-history/App.vue +++ b/frontend/src/reject-history/App.vue @@ -1,7 +1,13 @@ @@ -698,14 +857,15 @@ onMounted(() => {
更新時間:{{ lastQueryAt }}
- +
{{ errorMessage }}
+
{{ autoPruneHint }}
+import { ref } from 'vue'; + defineProps({ items: { type: Array, default: () => [] }, pagination: { @@ -11,6 +13,8 @@ defineProps({ defineEmits(['go-to-page', 'clear-reason']); +const showRejectBreakdown = ref(false); + function formatNumber(value) { return Number(value || 0).toLocaleString('zh-TW'); } @@ -31,45 +35,49 @@ function formatNumber(value) { - - - - + + - + + - - - - - + - - + - - + + - - - - - + - +
日期 LOTWORKCENTER_GROUP WORKCENTER PackagePJ_TYPEPJ_FUNCTIONFUNCTIONTYPE PRODUCT 原因扣帳報廢量 + 扣帳報廢量 {{ showRejectBreakdown ? '▾' : '▸' }} + 不扣帳報廢量REJECT_QTYSTANDBY_QTYQTYTOPROCESSINPROCESSPROCESSED報廢時間
{{ row.TXN_DAY }}
{{ row.CONTAINERNAME || '' }}{{ row.WORKCENTER_GROUP }} {{ row.WORKCENTERNAME }} {{ row.PRODUCTLINENAME }}{{ row.PJ_TYPE }} {{ row.PJ_FUNCTION || '' }}{{ row.PJ_TYPE }} {{ row.PRODUCTNAME || '' }} {{ row.LOSSREASONNAME }} {{ formatNumber(row.REJECT_TOTAL_QTY) }} {{ formatNumber(row.DEFECT_QTY) }}{{ formatNumber(row.REJECT_QTY) }}{{ formatNumber(row.STANDBY_QTY) }}{{ formatNumber(row.QTYTOPROCESS_QTY) }}{{ formatNumber(row.INPROCESS_QTY) }}{{ formatNumber(row.PROCESSED_QTY) }}{{ row.TXN_TIME || row.TXN_DAY }}
No dataNo data
diff --git a/frontend/src/reject-history/style.css b/frontend/src/reject-history/style.css index 9efcb7f..3751c0a 100644 --- a/frontend/src/reject-history/style.css +++ b/frontend/src/reject-history/style.css @@ -266,10 +266,34 @@ overflow: auto; } -.detail-table .cell-wrap { - white-space: normal; - max-width: 220px; - word-break: break-all; +.detail-table .cell-nowrap { + white-space: nowrap; +} + +.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 { diff --git a/frontend/src/resource-history/App.vue b/frontend/src/resource-history/App.vue index eabe87d..52f10da 100644 --- a/frontend/src/resource-history/App.vue +++ b/frontend/src/resource-history/App.vue @@ -1,8 +1,15 @@