feat: archive filter strategy change and optimize reject-history filters
This commit is contained in:
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>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
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 DetailTable from './components/DetailTable.vue';
|
||||
@@ -12,18 +18,19 @@ import TrendChart from './components/TrendChart.vue';
|
||||
|
||||
const API_TIMEOUT = 60000;
|
||||
const DEFAULT_PER_PAGE = 50;
|
||||
const OPTIONS_DEBOUNCE_MS = 300;
|
||||
|
||||
const filters = reactive({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
workcenterGroups: [],
|
||||
packages: [],
|
||||
reason: '',
|
||||
includeExcludedScrap: false,
|
||||
excludeMaterialScrap: true,
|
||||
excludePbDiode: true,
|
||||
paretoTop80: true,
|
||||
});
|
||||
function createDefaultFilters() {
|
||||
return toRejectFilterSnapshot({
|
||||
includeExcludedScrap: false,
|
||||
excludeMaterialScrap: true,
|
||||
excludePbDiode: true,
|
||||
paretoTop80: true,
|
||||
});
|
||||
}
|
||||
|
||||
const draftFilters = reactive(createDefaultFilters());
|
||||
const committedFilters = reactive(createDefaultFilters());
|
||||
|
||||
const page = ref(1);
|
||||
const detailReason = ref('');
|
||||
@@ -67,6 +74,7 @@ const loading = reactive({
|
||||
});
|
||||
|
||||
const errorMessage = ref('');
|
||||
const autoPruneHint = ref('');
|
||||
const lastQueryAt = ref('');
|
||||
const lastPolicyMeta = ref({
|
||||
include_excluded_scrap: false,
|
||||
@@ -74,15 +82,30 @@ const lastPolicyMeta = ref({
|
||||
excluded_reason_count: 0,
|
||||
});
|
||||
|
||||
let activeRequestId = 0;
|
||||
const draftWatchReady = ref(false);
|
||||
|
||||
function nextRequestId() {
|
||||
activeRequestId += 1;
|
||||
return activeRequestId;
|
||||
let activeDataRequestId = 0;
|
||||
let activeOptionsRequestId = 0;
|
||||
let optionsDebounceHandle = null;
|
||||
let suppressDraftOptionReload = false;
|
||||
let lastLoadedOptionsSignature = '';
|
||||
|
||||
function nextDataRequestId() {
|
||||
activeDataRequestId += 1;
|
||||
return activeDataRequestId;
|
||||
}
|
||||
|
||||
function isStaleRequest(requestId) {
|
||||
return requestId !== activeRequestId;
|
||||
function isStaleDataRequest(requestId) {
|
||||
return requestId !== activeDataRequestId;
|
||||
}
|
||||
|
||||
function nextOptionsRequestId() {
|
||||
activeOptionsRequestId += 1;
|
||||
return activeOptionsRequestId;
|
||||
}
|
||||
|
||||
function isStaleOptionsRequest(requestId) {
|
||||
return requestId !== activeOptionsRequestId;
|
||||
}
|
||||
|
||||
function toDateString(value) {
|
||||
@@ -92,14 +115,42 @@ function toDateString(value) {
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function setDefaultDateRange() {
|
||||
function setDefaultDateRange(target) {
|
||||
const today = new Date();
|
||||
const end = new Date(today);
|
||||
end.setDate(end.getDate() - 1);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 29);
|
||||
filters.startDate = toDateString(start);
|
||||
filters.endDate = toDateString(end);
|
||||
target.startDate = toDateString(start);
|
||||
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) {
|
||||
@@ -121,29 +172,29 @@ function readBooleanParam(params, key, defaultValue = false) {
|
||||
return ['1', 'true', 'yes', 'y', 'on'].includes(value);
|
||||
}
|
||||
|
||||
function restoreFromUrl() {
|
||||
function restoreCommittedFiltersFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const startDate = String(params.get('start_date') || '').trim();
|
||||
const endDate = String(params.get('end_date') || '').trim();
|
||||
|
||||
if (startDate && endDate) {
|
||||
filters.startDate = startDate;
|
||||
filters.endDate = endDate;
|
||||
committedFilters.startDate = startDate;
|
||||
committedFilters.endDate = endDate;
|
||||
}
|
||||
|
||||
const wcGroups = readArrayParam(params, 'workcenter_groups');
|
||||
if (wcGroups.length > 0) {
|
||||
filters.workcenterGroups = wcGroups;
|
||||
committedFilters.workcenterGroups = wcGroups;
|
||||
}
|
||||
|
||||
const packages = readArrayParam(params, 'packages');
|
||||
if (packages.length > 0) {
|
||||
filters.packages = packages;
|
||||
committedFilters.packages = packages;
|
||||
}
|
||||
|
||||
const reason = String(params.get('reason') || '').trim();
|
||||
if (reason) {
|
||||
filters.reason = reason;
|
||||
committedFilters.reason = reason;
|
||||
}
|
||||
const detailReasonFromUrl = String(params.get('detail_reason') || '').trim();
|
||||
if (detailReasonFromUrl) {
|
||||
@@ -154,38 +205,44 @@ function restoreFromUrl() {
|
||||
selectedTrendDates.value = trendDates;
|
||||
}
|
||||
|
||||
filters.includeExcludedScrap = readBooleanParam(params, 'include_excluded_scrap', false);
|
||||
filters.excludeMaterialScrap = readBooleanParam(params, 'exclude_material_scrap', true);
|
||||
filters.excludePbDiode = readBooleanParam(params, 'exclude_pb_diode', true);
|
||||
filters.paretoTop80 = !readBooleanParam(params, 'pareto_scope_all', false);
|
||||
committedFilters.includeExcludedScrap = readBooleanParam(params, 'include_excluded_scrap', false);
|
||||
committedFilters.excludeMaterialScrap = readBooleanParam(params, 'exclude_material_scrap', true);
|
||||
committedFilters.excludePbDiode = readBooleanParam(params, 'exclude_pb_diode', true);
|
||||
committedFilters.paretoTop80 = !readBooleanParam(params, 'pareto_scope_all', false);
|
||||
|
||||
const parsedPage = Number(params.get('page') || '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() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set('start_date', filters.startDate);
|
||||
params.set('end_date', filters.endDate);
|
||||
filters.workcenterGroups.forEach((item) => params.append('workcenter_groups', item));
|
||||
filters.packages.forEach((item) => params.append('packages', item));
|
||||
params.set('start_date', committedFilters.startDate);
|
||||
params.set('end_date', committedFilters.endDate);
|
||||
appendArrayParams(params, 'workcenter_groups', committedFilters.workcenterGroups);
|
||||
appendArrayParams(params, 'packages', committedFilters.packages);
|
||||
|
||||
if (filters.reason) {
|
||||
params.set('reason', filters.reason);
|
||||
if (committedFilters.reason) {
|
||||
params.set('reason', committedFilters.reason);
|
||||
}
|
||||
if (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('exclude_material_scrap', String(filters.excludeMaterialScrap));
|
||||
params.set('exclude_pb_diode', String(filters.excludePbDiode));
|
||||
params.set('exclude_material_scrap', String(committedFilters.excludeMaterialScrap));
|
||||
params.set('exclude_pb_diode', String(committedFilters.excludePbDiode));
|
||||
|
||||
if (!filters.paretoTop80) {
|
||||
if (!committedFilters.paretoTop80) {
|
||||
params.set('pareto_scope_all', 'true');
|
||||
}
|
||||
|
||||
@@ -206,26 +263,12 @@ function unwrapApiResult(result, fallbackMessage) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildCommonParams({ reason = filters.reason } = {}) {
|
||||
const params = {
|
||||
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 buildCommonParams({ reason = committedFilters.reason } = {}) {
|
||||
return buildRejectCommonQueryParams(committedFilters, { reason });
|
||||
}
|
||||
|
||||
function buildListParams() {
|
||||
const effectiveReason = detailReason.value || filters.reason;
|
||||
const effectiveReason = detailReason.value || committedFilters.reason;
|
||||
const params = {
|
||||
...buildCommonParams({ reason: effectiveReason }),
|
||||
page: page.value,
|
||||
@@ -239,15 +282,9 @@ function buildListParams() {
|
||||
return params;
|
||||
}
|
||||
|
||||
async function fetchOptions() {
|
||||
async function fetchDraftOptions() {
|
||||
const response = await apiGet('/api/reject-history/options', {
|
||||
params: {
|
||||
start_date: filters.startDate,
|
||||
end_date: filters.endDate,
|
||||
include_excluded_scrap: filters.includeExcludedScrap,
|
||||
exclude_material_scrap: filters.excludeMaterialScrap,
|
||||
exclude_pb_diode: filters.excludePbDiode,
|
||||
},
|
||||
params: buildRejectOptionsRequestParams(draftFilters),
|
||||
timeout: API_TIMEOUT,
|
||||
});
|
||||
const payload = unwrapApiResult(response, '載入篩選選項失敗');
|
||||
@@ -283,38 +320,145 @@ function mergePolicyMeta(meta) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFiltersByOptions() {
|
||||
if (filters.reason && !options.reasons.includes(filters.reason)) {
|
||||
filters.reason = '';
|
||||
}
|
||||
function applyOptionsPayload(payload) {
|
||||
options.workcenterGroups = Array.isArray(payload?.workcenter_groups)
|
||||
? payload.workcenter_groups
|
||||
: [];
|
||||
options.reasons = Array.isArray(payload?.reasons)
|
||||
? payload.reasons
|
||||
: [];
|
||||
options.packages = Array.isArray(payload?.packages)
|
||||
? payload.packages
|
||||
: [];
|
||||
}
|
||||
|
||||
if (filters.packages.length > 0) {
|
||||
const packageSet = new Set(options.packages);
|
||||
filters.packages = filters.packages.filter((pkg) => packageSet.has(pkg));
|
||||
function formatPruneHint(removed) {
|
||||
const parts = [];
|
||||
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 } = {}) {
|
||||
const requestId = nextRequestId();
|
||||
async function reloadDraftOptions() {
|
||||
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.list = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const tasks = [fetchAnalytics(), fetchList()];
|
||||
if (loadOptions) {
|
||||
loading.options = true;
|
||||
tasks.push(fetchOptions());
|
||||
}
|
||||
|
||||
const responses = await Promise.all(tasks);
|
||||
if (isStaleRequest(requestId)) {
|
||||
const [analyticsResp, listResp] = await Promise.all([fetchAnalytics(), fetchList()]);
|
||||
if (isStaleDataRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [analyticsResp, listResp, optionsResp] = responses;
|
||||
|
||||
const analyticsData = analyticsResp.data || {};
|
||||
summary.value = analyticsData.summary || summary.value;
|
||||
trend.value = analyticsData.trend || trend.value;
|
||||
@@ -327,83 +471,70 @@ async function loadAllData({ loadOptions = true } = {}) {
|
||||
};
|
||||
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');
|
||||
updateUrlState();
|
||||
} catch (error) {
|
||||
if (isStaleRequest(requestId)) {
|
||||
if (isStaleDataRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
errorMessage.value = error?.message || '載入資料失敗';
|
||||
} finally {
|
||||
if (isStaleRequest(requestId)) {
|
||||
if (isStaleDataRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
loading.initial = false;
|
||||
loading.querying = false;
|
||||
loading.options = false;
|
||||
loading.list = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadListOnly() {
|
||||
const requestId = nextRequestId();
|
||||
const requestId = nextDataRequestId();
|
||||
loading.list = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const listResp = await fetchList();
|
||||
if (isStaleRequest(requestId)) {
|
||||
if (isStaleDataRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
detail.value = listResp.data || detail.value;
|
||||
mergePolicyMeta(listResp.meta || {});
|
||||
updateUrlState();
|
||||
} catch (error) {
|
||||
if (isStaleRequest(requestId)) {
|
||||
if (isStaleDataRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
errorMessage.value = error?.message || '載入明細資料失敗';
|
||||
} finally {
|
||||
if (isStaleRequest(requestId)) {
|
||||
if (isStaleDataRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
loading.list = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
async function applyFilters() {
|
||||
page.value = 1;
|
||||
detailReason.value = '';
|
||||
selectedTrendDates.value = [];
|
||||
void loadAllData({ loadOptions: true });
|
||||
await ensureDraftOptionsFresh();
|
||||
commitDraftFilters();
|
||||
await loadDataSections();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setDefaultDateRange();
|
||||
filters.workcenterGroups = [];
|
||||
filters.packages = [];
|
||||
filters.reason = '';
|
||||
async function clearFilters() {
|
||||
runWithDraftReloadSuppressed(() => {
|
||||
resetToDefaultFilters(draftFilters);
|
||||
});
|
||||
autoPruneHint.value = '';
|
||||
detailReason.value = '';
|
||||
selectedTrendDates.value = [];
|
||||
filters.includeExcludedScrap = false;
|
||||
filters.excludeMaterialScrap = true;
|
||||
filters.excludePbDiode = true;
|
||||
filters.paretoTop80 = true;
|
||||
page.value = 1;
|
||||
void loadAllData({ loadOptions: true });
|
||||
lastLoadedOptionsSignature = '';
|
||||
await ensureDraftOptionsFresh();
|
||||
commitDraftFilters();
|
||||
await loadDataSections();
|
||||
}
|
||||
|
||||
function goToPage(nextPage) {
|
||||
@@ -443,54 +574,70 @@ function onParetoClick(reason) {
|
||||
}
|
||||
|
||||
function handleParetoScopeToggle(checked) {
|
||||
filters.paretoTop80 = Boolean(checked);
|
||||
const value = Boolean(checked);
|
||||
runWithDraftReloadSuppressed(() => {
|
||||
draftFilters.paretoTop80 = value;
|
||||
});
|
||||
committedFilters.paretoTop80 = value;
|
||||
updateUrlState();
|
||||
}
|
||||
|
||||
function removeFilterChip(chip) {
|
||||
async function removeFilterChip(chip) {
|
||||
if (!chip?.removable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chip.type === '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') {
|
||||
if (chip.type === 'detail-reason') {
|
||||
detailReason.value = '';
|
||||
page.value = 1;
|
||||
void loadListOnly();
|
||||
await loadListOnly();
|
||||
return;
|
||||
} else if (chip.type === 'trend-dates') {
|
||||
}
|
||||
|
||||
if (chip.type === 'trend-dates') {
|
||||
selectedTrendDates.value = [];
|
||||
page.value = 1;
|
||||
void loadListOnly();
|
||||
await loadListOnly();
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
|
||||
runWithDraftReloadSuppressed(() => {
|
||||
assignFilterState(draftFilters, nextFilters);
|
||||
});
|
||||
assignFilterState(committedFilters, nextFilters);
|
||||
|
||||
page.value = 1;
|
||||
void loadAllData({ loadOptions: false });
|
||||
lastLoadedOptionsSignature = '';
|
||||
await ensureDraftOptionsFresh();
|
||||
commitDraftFilters();
|
||||
await loadDataSections();
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const effectiveReason = detailReason.value || committedFilters.reason;
|
||||
const queryParams = buildRejectCommonQueryParams(committedFilters, { reason: effectiveReason });
|
||||
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));
|
||||
filters.packages.forEach((item) => params.append('packages', item));
|
||||
const effectiveReason = detailReason.value || filters.reason;
|
||||
if (effectiveReason) {
|
||||
params.append('reasons', effectiveReason);
|
||||
}
|
||||
params.set('start_date', queryParams.start_date);
|
||||
params.set('end_date', queryParams.end_date);
|
||||
params.set('include_excluded_scrap', String(queryParams.include_excluded_scrap));
|
||||
params.set('exclude_material_scrap', String(queryParams.exclude_material_scrap));
|
||||
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()}`;
|
||||
}
|
||||
@@ -571,7 +718,7 @@ const allParetoItems = computed(() => {
|
||||
|
||||
const filteredParetoItems = computed(() => {
|
||||
const items = allParetoItems.value || [];
|
||||
if (!filters.paretoTop80 || items.length === 0) {
|
||||
if (!committedFilters.paretoTop80 || items.length === 0) {
|
||||
return items;
|
||||
}
|
||||
const top = items.filter((item) => Number(item.cumPct || 0) <= 80);
|
||||
@@ -582,41 +729,41 @@ const activeFilterChips = computed(() => {
|
||||
const chips = [
|
||||
{
|
||||
key: 'date-range',
|
||||
label: `日期: ${filters.startDate || '-'} ~ ${filters.endDate || '-'}`,
|
||||
label: `日期: ${committedFilters.startDate || '-'} ~ ${committedFilters.endDate || '-'}`,
|
||||
removable: false,
|
||||
type: 'date',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
key: 'policy-mode',
|
||||
label: filters.includeExcludedScrap ? '政策: 納入不計良率報廢' : '政策: 排除不計良率報廢',
|
||||
label: committedFilters.includeExcludedScrap ? '政策: 納入不計良率報廢' : '政策: 排除不計良率報廢',
|
||||
removable: false,
|
||||
type: 'policy',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
key: 'material-policy-mode',
|
||||
label: filters.excludeMaterialScrap ? '原物料: 已排除' : '原物料: 已納入',
|
||||
label: committedFilters.excludeMaterialScrap ? '原物料: 已排除' : '原物料: 已納入',
|
||||
removable: false,
|
||||
type: 'policy',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
key: 'pb-diode-policy',
|
||||
label: filters.excludePbDiode ? 'PB_Diode: 已排除' : 'PB_Diode: 已納入',
|
||||
label: committedFilters.excludePbDiode ? 'PB_Diode: 已排除' : 'PB_Diode: 已納入',
|
||||
removable: false,
|
||||
type: 'policy',
|
||||
value: '',
|
||||
},
|
||||
];
|
||||
|
||||
if (filters.reason) {
|
||||
if (committedFilters.reason) {
|
||||
chips.push({
|
||||
key: `reason:${filters.reason}`,
|
||||
label: `原因: ${filters.reason}`,
|
||||
key: `reason:${committedFilters.reason}`,
|
||||
label: `原因: ${committedFilters.reason}`,
|
||||
removable: true,
|
||||
type: 'reason',
|
||||
value: filters.reason,
|
||||
value: committedFilters.reason,
|
||||
});
|
||||
}
|
||||
if (selectedTrendDates.value.length > 0) {
|
||||
@@ -642,7 +789,7 @@ const activeFilterChips = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
filters.workcenterGroups.forEach((group) => {
|
||||
committedFilters.workcenterGroups.forEach((group) => {
|
||||
chips.push({
|
||||
key: `workcenter:${group}`,
|
||||
label: `WC: ${group}`,
|
||||
@@ -652,7 +799,7 @@ const activeFilterChips = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
filters.packages.forEach((pkg) => {
|
||||
committedFilters.packages.forEach((pkg) => {
|
||||
chips.push({
|
||||
key: `package:${pkg}`,
|
||||
label: `Package: ${pkg}`,
|
||||
@@ -683,10 +830,22 @@ const pagination = computed(() => detail.value?.pagination || {
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setDefaultDateRange();
|
||||
restoreFromUrl();
|
||||
void loadAllData({ loadOptions: true });
|
||||
onMounted(async () => {
|
||||
resetToDefaultFilters(committedFilters);
|
||||
restoreCommittedFiltersFromUrl();
|
||||
runWithDraftReloadSuppressed(() => {
|
||||
assignFilterState(draftFilters, committedFilters);
|
||||
});
|
||||
|
||||
lastLoadedOptionsSignature = '';
|
||||
await reloadDraftOptions();
|
||||
commitDraftFilters();
|
||||
draftWatchReady.value = true;
|
||||
await loadDataSections();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearOptionsDebounce();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -698,14 +857,15 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div>
|
||||
<div v-if="autoPruneHint" class="filter-indicator">{{ autoPruneHint }}</div>
|
||||
|
||||
<FilterPanel
|
||||
:filters="filters"
|
||||
:filters="draftFilters"
|
||||
:options="options"
|
||||
:loading="loading"
|
||||
:active-filter-chips="activeFilterChips"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
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) {
|
||||
<table class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>LOT</th>
|
||||
<th>WORKCENTER_GROUP</th>
|
||||
<th>WORKCENTER</th>
|
||||
<th>Package</th>
|
||||
<th>PJ_TYPE</th>
|
||||
<th>PJ_FUNCTION</th>
|
||||
<th>FUNCTION</th>
|
||||
<th class="col-left">TYPE</th>
|
||||
<th>PRODUCT</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>REJECT_QTY</th>
|
||||
<th>STANDBY_QTY</th>
|
||||
<th>QTYTOPROCESS</th>
|
||||
<th>INPROCESS</th>
|
||||
<th>PROCESSED</th>
|
||||
<th>報廢時間</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in items" :key="`${row.TXN_DAY}-${row.WORKCENTERNAME}-${row.CONTAINERNAME}-${row.LOSSREASONNAME}-${idx}`">
|
||||
<td>{{ row.TXN_DAY }}</td>
|
||||
<tr v-for="(row, idx) in items" :key="`${row.TXN_DAY}-${row.CONTAINERNAME}-${row.LOSSREASONNAME}-${idx}`">
|
||||
<td>{{ row.CONTAINERNAME || '' }}</td>
|
||||
<td>{{ row.WORKCENTER_GROUP }}</td>
|
||||
<td>{{ row.WORKCENTERNAME }}</td>
|
||||
<td>{{ row.PRODUCTLINENAME }}</td>
|
||||
<td>{{ row.PJ_TYPE }}</td>
|
||||
<td>{{ row.PJ_FUNCTION || '' }}</td>
|
||||
<td class="col-left">{{ row.PJ_TYPE }}</td>
|
||||
<td>{{ row.PRODUCTNAME || '' }}</td>
|
||||
<td>{{ row.LOSSREASONNAME }}</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.REJECT_QTY) }}</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>
|
||||
<td class="cell-nowrap">{{ row.TXN_TIME || row.TXN_DAY }}</td>
|
||||
</tr>
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<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 { 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 ComparisonChart from './components/ComparisonChart.vue';
|
||||
@@ -18,17 +25,20 @@ ensureMesApiAvailable();
|
||||
const API_TIMEOUT = 60000;
|
||||
const MAX_QUERY_DAYS = 730;
|
||||
|
||||
const filters = reactive({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
granularity: 'day',
|
||||
workcenterGroups: [],
|
||||
families: [],
|
||||
machines: [],
|
||||
isProduction: false,
|
||||
isKey: false,
|
||||
isMonitor: false,
|
||||
});
|
||||
function createDefaultFilters() {
|
||||
return toResourceFilterSnapshot({
|
||||
granularity: 'day',
|
||||
workcenterGroups: [],
|
||||
families: [],
|
||||
machines: [],
|
||||
isProduction: false,
|
||||
isKey: false,
|
||||
isMonitor: false,
|
||||
});
|
||||
}
|
||||
|
||||
const draftFilters = reactive(createDefaultFilters());
|
||||
const committedFilters = reactive(createDefaultFilters());
|
||||
|
||||
const options = reactive({
|
||||
workcenterGroups: [],
|
||||
@@ -54,6 +64,19 @@ const loading = reactive({
|
||||
const queryError = ref('');
|
||||
const detailWarning = 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() {
|
||||
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 endDate = new Date(today);
|
||||
endDate.setDate(endDate.getDate() - 1);
|
||||
@@ -69,12 +96,27 @@ function setDefaultDates() {
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - 6);
|
||||
|
||||
filters.startDate = toDateString(startDate);
|
||||
filters.endDate = toDateString(endDate);
|
||||
target.startDate = toDateString(startDate);
|
||||
target.endDate = toDateString(endDate);
|
||||
}
|
||||
|
||||
function toDateString(value) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
function assignFilterState(target, source) {
|
||||
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) {
|
||||
@@ -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();
|
||||
|
||||
params.append('start_date', filters.startDate);
|
||||
params.append('end_date', filters.endDate);
|
||||
params.append('granularity', filters.granularity);
|
||||
params.append('start_date', queryParams.start_date);
|
||||
params.append('end_date', queryParams.end_date);
|
||||
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) => {
|
||||
params.append('workcenter_groups', group);
|
||||
});
|
||||
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 (queryParams.is_production) {
|
||||
params.append('is_production', queryParams.is_production);
|
||||
}
|
||||
if (filters.isKey) {
|
||||
params.append('is_key', '1');
|
||||
if (queryParams.is_key) {
|
||||
params.append('is_key', queryParams.is_key);
|
||||
}
|
||||
if (filters.isMonitor) {
|
||||
params.append('is_monitor', '1');
|
||||
if (queryParams.is_monitor) {
|
||||
params.append('is_monitor', queryParams.is_monitor);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
@@ -140,9 +182,9 @@ function readBooleanParam(params, key) {
|
||||
return value === '1' || value === 'true' || value === 'yes';
|
||||
}
|
||||
|
||||
function readInitialFiltersFromUrl() {
|
||||
function restoreCommittedFiltersFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
const next = {
|
||||
startDate: String(params.get('start_date') || '').trim(),
|
||||
endDate: String(params.get('end_date') || '').trim(),
|
||||
granularity: String(params.get('granularity') || '').trim(),
|
||||
@@ -153,15 +195,37 @@ function readInitialFiltersFromUrl() {
|
||||
isKey: readBooleanParam(params, 'is_key'),
|
||||
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() {
|
||||
const queryString = buildQueryString();
|
||||
const queryString = buildQueryStringFromFilters(committedFilters);
|
||||
const nextUrl = queryString ? `/resource-history?${queryString}` : '/resource-history';
|
||||
replaceRuntimeHistory(nextUrl);
|
||||
}
|
||||
|
||||
function validateDateRange() {
|
||||
function validateDateRange(filters) {
|
||||
if (!filters.startDate || !filters.endDate) {
|
||||
return '請先設定開始與結束日期';
|
||||
}
|
||||
@@ -200,34 +264,77 @@ async function loadOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
const machineOptions = computed(() => {
|
||||
let list = options.resources;
|
||||
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));
|
||||
const familyOptions = computed(() => {
|
||||
return deriveResourceFamilyOptions(options.resources, draftFilters);
|
||||
});
|
||||
|
||||
function pruneInvalidMachines() {
|
||||
const validIds = new Set(machineOptions.value.map((m) => m.value));
|
||||
filters.machines = filters.machines.filter((m) => validIds.has(m));
|
||||
const machineOptions = computed(() => {
|
||||
return deriveResourceMachineOptions(options.resources, draftFilters);
|
||||
});
|
||||
|
||||
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() {
|
||||
updateUrlState();
|
||||
const validationError = validateDateRange();
|
||||
function pruneDraftSelections({ showHint = true } = {}) {
|
||||
const result = pruneResourceFilterSelections(draftFilters, {
|
||||
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) {
|
||||
queryError.value = validationError;
|
||||
loading.initial = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -237,7 +344,9 @@ async function executeQuery() {
|
||||
exportMessage.value = '';
|
||||
|
||||
try {
|
||||
const queryString = buildQueryString();
|
||||
const queryString = buildQueryStringFromFilters(committedFilters);
|
||||
updateUrlState();
|
||||
|
||||
const [summaryResponse, detailResponse] = await Promise.all([
|
||||
apiGet(`/api/resource/history/summary?${queryString}`, {
|
||||
timeout: API_TIMEOUT,
|
||||
@@ -283,30 +392,31 @@ async function executeQuery() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilters(nextFilters) {
|
||||
const upstreamChanged =
|
||||
'workcenterGroups' in nextFilters ||
|
||||
'families' in nextFilters ||
|
||||
'isProduction' in nextFilters ||
|
||||
'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();
|
||||
async function applyFilters() {
|
||||
const validationError = validateDateRange(draftFilters);
|
||||
if (validationError) {
|
||||
queryError.value = validationError;
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
@@ -320,15 +430,15 @@ function handleToggleAllRows({ expand, rowIds }) {
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
if (!filters.startDate || !filters.endDate) {
|
||||
if (!committedFilters.startDate || !committedFilters.endDate) {
|
||||
queryError.value = '請先設定查詢條件';
|
||||
return;
|
||||
}
|
||||
|
||||
const queryString = buildQueryString();
|
||||
const queryString = buildQueryStringFromFilters(committedFilters);
|
||||
const link = document.createElement('a');
|
||||
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);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -337,38 +447,22 @@ function exportCsv() {
|
||||
}
|
||||
|
||||
async function initPage() {
|
||||
setDefaultDates();
|
||||
const initial = readInitialFiltersFromUrl();
|
||||
if (initial.startDate) {
|
||||
filters.startDate = initial.startDate;
|
||||
}
|
||||
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;
|
||||
resetToDefaultFilters(committedFilters);
|
||||
restoreCommittedFiltersFromUrl();
|
||||
runWithDraftPruneSuppressed(() => {
|
||||
assignFilterState(draftFilters, committedFilters);
|
||||
});
|
||||
|
||||
try {
|
||||
await loadOptions();
|
||||
pruneInvalidMachines();
|
||||
pruneDraftSelections({ showHint: true });
|
||||
assignFilterState(committedFilters, draftFilters);
|
||||
} catch (error) {
|
||||
queryError.value = error?.message || '載入篩選選項失敗';
|
||||
}
|
||||
updateUrlState();
|
||||
await executeQuery();
|
||||
|
||||
draftWatchReady.value = true;
|
||||
await executeCommittedQuery();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -384,15 +478,17 @@ onMounted(() => {
|
||||
</header>
|
||||
|
||||
<FilterBar
|
||||
:filters="filters"
|
||||
:options="options"
|
||||
:filters="draftFilters"
|
||||
:options="filterBarOptions"
|
||||
:machine-options="machineOptions"
|
||||
:loading="loading.options || loading.querying"
|
||||
@update-filters="updateFilters"
|
||||
@query="executeQuery"
|
||||
@query="applyFilters"
|
||||
@clear="clearFilters"
|
||||
/>
|
||||
|
||||
<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="exportMessage" class="filter-indicator active">{{ exportMessage }}</p>
|
||||
|
||||
@@ -418,9 +514,5 @@ onMounted(() => {
|
||||
@export-csv="exportCsv"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="loading-overlay" :class="{ hidden: !loading.initial && !loading.querying }">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,7 +30,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update-filters', 'query']);
|
||||
const emit = defineEmits(['update-filters', 'query', 'clear']);
|
||||
|
||||
function updateFilters(patch) {
|
||||
emit('update-filters', {
|
||||
@@ -148,6 +148,7 @@ function updateFilters(patch) {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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',
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user