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(() => {
| 日期 | LOT | -WORKCENTER_GROUP | WORKCENTER | Package | -PJ_TYPE | -PJ_FUNCTION | +FUNCTION | +TYPE | PRODUCT | 原因 | -扣帳報廢量 | ++ 扣帳報廢量 + | + +REJECT | +STANDBY | +QTYTOPROCESS | +INPROCESS | +PROCESSED | +不扣帳報廢量 | -REJECT_QTY | -STANDBY_QTY | -QTYTOPROCESS | -INPROCESS | -PROCESSED | +報廢時間 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ 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.REJECT_QTY) }} | +{{ formatNumber(row.STANDBY_QTY) }} | +{{ formatNumber(row.QTYTOPROCESS_QTY) }} | +{{ formatNumber(row.INPROCESS_QTY) }} | +{{ formatNumber(row.PROCESSED_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 data | +No data | |||||||||||||||||||||||