feat(query-tool): rewrite frontend with ECharts tree, multi-select, and modular composables
Replace the monolithic useQueryToolData composable and nested Vue component tree with a modular architecture: useLotResolve, useLotLineage, useLotDetail, and useEquipmentQuery. Introduce ECharts TreeChart (LR orthogonal layout) for lot lineage visualization with multi-select support, subtree expansion, zoom/pan, and serial number normalization. Add unified LineageEngine backend with split descendant traversal and leaf serial number queries. Archive the query-tool-rewrite openspec change and sync delta specs to main. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -102,7 +102,7 @@
|
||||
{
|
||||
"route": "/admin/pages",
|
||||
"name": "頁面管理",
|
||||
"status": "dev",
|
||||
"status": "released",
|
||||
"drawer_id": "dev-tools",
|
||||
"order": 1
|
||||
},
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"mode": "block",
|
||||
"errors": [],
|
||||
"warnings": [
|
||||
"/excel-query uses shell tokens without fallback ['--portal-brand-end', '--portal-brand-start', '--portal-shadow-panel'] in frontend/src/excel-query/style.css with approved exception",
|
||||
"/query-tool uses shell tokens without fallback ['--portal-brand-end', '--portal-brand-start', '--portal-shadow-panel'] in frontend/src/query-tool/style.css with approved exception"
|
||||
"/excel-query uses shell tokens without fallback ['--portal-shadow-panel'] in frontend/src/excel-query/style.css with approved exception"
|
||||
],
|
||||
"info": [],
|
||||
"passed": true
|
||||
|
||||
44
docs/reject_history_performance.md
Normal file
44
docs/reject_history_performance.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Reject 歷史績效表設計說明
|
||||
|
||||
## 目標
|
||||
使用 `DW_MES_LOTREJECTHISTORY` 為主,輔以其他維度表,建立可直接用於報表的 `reject` 歷史績效表(按日彙總),解決原始資料直接查詢時的績效與一致性問題。
|
||||
|
||||
## 使用資料表
|
||||
- `DWH.DW_MES_LOTREJECTHISTORY`: 不良/報廢事實表(主來源)
|
||||
- `DWH.DW_MES_CONTAINER`: 補齊 `PJ_TYPE`、`PRODUCTLINENAME`、`MFGORDERNAME`
|
||||
- `DWH.DW_MES_SPEC_WORKCENTER_V`: 對應 `WORKCENTER_GROUP` 與排序欄位
|
||||
|
||||
## 資料評估重點(2026-02-13,近 30 天樣本)
|
||||
- `DW_MES_LOTREJECTHISTORY` 共 `230,074` 筆;`HISTORYMAINLINEID` 僅 `75,683` 個。
|
||||
- `HISTORYMAINLINEID` 多筆情況明顯(`30,784` 個主事件,平均每主事件 `6.02` 筆),代表同主事件會拆成多個 `LOSSREASONNAME`。
|
||||
- 若直接加總 `MOVEINQTY`,分母會被重複計算。近 30 天樣本中:
|
||||
- `NAIVE_MOVEIN = 44,836,693,831`
|
||||
- `DEDUP_MOVEIN = 35,658,750,247`
|
||||
- 膨脹比 `1.2574`(約高估 25.74%)
|
||||
- 指標定義依業務規則分開處理:
|
||||
- `REJECT_TOTAL_QTY = REJECTQTY + STANDBYQTY + QTYTOPROCESS + INPROCESSQTY + PROCESSEDQTY`(扣帳報廢)
|
||||
- `DEFECT_QTY = DEFECTQTY`(不扣帳報廢)
|
||||
- `DW_MES_SPEC_WORKCENTER_V` 若直接以 `WORK_CENTER` join 會放大筆數;需先彙整為唯一 `WORK_CENTER -> GROUP/SEQUENCE` 對照表再 join。
|
||||
|
||||
## 績效表欄位與計算邏輯
|
||||
- 粒度:`日 + 工站群組 + 工站 + 站點規格 + 設備 + 產品維度 + 不良原因`
|
||||
- 核心指標:
|
||||
- `REJECT_EVENT_ROWS`: 原始 reject 紀錄筆數
|
||||
- `AFFECTED_LOT_COUNT`: 受影響 lot 數(distinct `CONTAINERID`)
|
||||
- `MOVEIN_QTY`: 以 `HISTORYMAINLINEID` 去重後的投入量
|
||||
- `REJECT_QTY`: 原始 `REJECTQTY` 加總(五欄之一)
|
||||
- `REJECT_TOTAL_QTY`: 五個 reject 相關欄位加總(扣帳報廢)
|
||||
- `DEFECT_QTY`: `DEFECTQTY` 加總(不扣帳報廢)
|
||||
- `REJECT_RATE_PCT = REJECT_TOTAL_QTY / MOVEIN_QTY * 100`
|
||||
- `DEFECT_RATE_PCT = DEFECT_QTY / MOVEIN_QTY * 100`
|
||||
- `REJECT_SHARE_PCT = REJECT_TOTAL_QTY / (REJECT_TOTAL_QTY + DEFECT_QTY) * 100`
|
||||
|
||||
## 交付檔案
|
||||
- 建表 + 刷新 SQL:`docs/reject_history_performance.sql`
|
||||
- 可被應用層直接載入的查詢 SQL:`src/mes_dashboard/sql/reject_history/performance_daily.sql`
|
||||
|
||||
## 建議排程
|
||||
- 每日跑前一日增量:
|
||||
- `:start_date = TRUNC(SYSDATE - 1)`
|
||||
- `:end_date = TRUNC(SYSDATE - 1)`
|
||||
- 每月第一天補跑前 31 天,避免補數漏失。
|
||||
242
docs/reject_history_performance.sql
Normal file
242
docs/reject_history_performance.sql
Normal file
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
Reject 歷史績效表建置腳本
|
||||
目的:
|
||||
- 以 DW_MES_LOTREJECTHISTORY 為主,建立可直接報表化的日彙總績效表
|
||||
- 補齊產品/工站維度,並區分扣帳報廢與不扣帳報廢
|
||||
*/
|
||||
|
||||
/* ============================================================
|
||||
1) 建表 (執行一次)
|
||||
============================================================ */
|
||||
|
||||
CREATE TABLE DWH.DW_PJ_REJECT_HISTORY_PERF_D (
|
||||
TXN_DAY DATE NOT NULL,
|
||||
TXN_MONTH VARCHAR2(7) NOT NULL,
|
||||
WORKCENTER_GROUP VARCHAR2(40) NOT NULL,
|
||||
WORKCENTERSEQUENCE_GROUP NUMBER(10) NOT NULL,
|
||||
WORKCENTERNAME VARCHAR2(40) NOT NULL,
|
||||
SPECNAME VARCHAR2(40) NOT NULL,
|
||||
EQUIPMENTNAME VARCHAR2(255) NOT NULL,
|
||||
PRIMARY_EQUIPMENTNAME VARCHAR2(40) NOT NULL,
|
||||
PRODUCTLINENAME VARCHAR2(40) NOT NULL,
|
||||
PJ_TYPE VARCHAR2(40) NOT NULL,
|
||||
LOSSREASONNAME VARCHAR2(40) NOT NULL,
|
||||
REJECTCATEGORYNAME VARCHAR2(40) NOT NULL,
|
||||
REJECT_EVENT_ROWS NUMBER NOT NULL,
|
||||
AFFECTED_LOT_COUNT NUMBER NOT NULL,
|
||||
AFFECTED_WORKORDER_COUNT NUMBER NOT NULL,
|
||||
MOVEIN_QTY NUMBER NOT NULL,
|
||||
REJECT_QTY NUMBER NOT NULL,
|
||||
REJECT_TOTAL_QTY NUMBER NOT NULL,
|
||||
DEFECT_QTY NUMBER NOT NULL,
|
||||
STANDBY_QTY NUMBER NOT NULL,
|
||||
QTYTOPROCESS_QTY NUMBER NOT NULL,
|
||||
INPROCESS_QTY NUMBER NOT NULL,
|
||||
PROCESSED_QTY NUMBER NOT NULL,
|
||||
REJECT_RATE_PCT NUMBER(18, 4) NOT NULL,
|
||||
DEFECT_RATE_PCT NUMBER(18, 4) NOT NULL,
|
||||
REJECT_SHARE_PCT NUMBER(18, 4) NOT NULL,
|
||||
LAST_REFRESH_TS DATE NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX DWH.IDX_RJH_PERF_D_01 ON DWH.DW_PJ_REJECT_HISTORY_PERF_D (TXN_DAY);
|
||||
CREATE INDEX DWH.IDX_RJH_PERF_D_02 ON DWH.DW_PJ_REJECT_HISTORY_PERF_D (WORKCENTER_GROUP, TXN_DAY);
|
||||
CREATE INDEX DWH.IDX_RJH_PERF_D_03 ON DWH.DW_PJ_REJECT_HISTORY_PERF_D (PRIMARY_EQUIPMENTNAME, TXN_DAY);
|
||||
CREATE INDEX DWH.IDX_RJH_PERF_D_04 ON DWH.DW_PJ_REJECT_HISTORY_PERF_D (LOSSREASONNAME, TXN_DAY);
|
||||
|
||||
|
||||
/* ============================================================
|
||||
2) 區間刷新 (可每日排程)
|
||||
綁定參數:
|
||||
:start_date (YYYY-MM-DD)
|
||||
:end_date (YYYY-MM-DD)
|
||||
============================================================ */
|
||||
|
||||
DELETE FROM DWH.DW_PJ_REJECT_HISTORY_PERF_D
|
||||
WHERE TXN_DAY >= TO_DATE(:start_date, 'YYYY-MM-DD')
|
||||
AND TXN_DAY < TO_DATE(:end_date, 'YYYY-MM-DD') + 1;
|
||||
|
||||
INSERT /*+ APPEND */ INTO DWH.DW_PJ_REJECT_HISTORY_PERF_D (
|
||||
TXN_DAY,
|
||||
TXN_MONTH,
|
||||
WORKCENTER_GROUP,
|
||||
WORKCENTERSEQUENCE_GROUP,
|
||||
WORKCENTERNAME,
|
||||
SPECNAME,
|
||||
EQUIPMENTNAME,
|
||||
PRIMARY_EQUIPMENTNAME,
|
||||
PRODUCTLINENAME,
|
||||
PJ_TYPE,
|
||||
LOSSREASONNAME,
|
||||
REJECTCATEGORYNAME,
|
||||
REJECT_EVENT_ROWS,
|
||||
AFFECTED_LOT_COUNT,
|
||||
AFFECTED_WORKORDER_COUNT,
|
||||
MOVEIN_QTY,
|
||||
REJECT_QTY,
|
||||
REJECT_TOTAL_QTY,
|
||||
DEFECT_QTY,
|
||||
STANDBY_QTY,
|
||||
QTYTOPROCESS_QTY,
|
||||
INPROCESS_QTY,
|
||||
PROCESSED_QTY,
|
||||
REJECT_RATE_PCT,
|
||||
DEFECT_RATE_PCT,
|
||||
REJECT_SHARE_PCT,
|
||||
LAST_REFRESH_TS
|
||||
)
|
||||
WITH workcenter_map AS (
|
||||
SELECT
|
||||
WORK_CENTER,
|
||||
MIN(WORK_CENTER_GROUP) KEEP (
|
||||
DENSE_RANK FIRST ORDER BY WORKCENTERSEQUENCE_GROUP
|
||||
) AS WORKCENTER_GROUP,
|
||||
MIN(WORKCENTERSEQUENCE_GROUP) AS WORKCENTERSEQUENCE_GROUP
|
||||
FROM DWH.DW_MES_SPEC_WORKCENTER_V
|
||||
WHERE WORK_CENTER IS NOT NULL
|
||||
GROUP BY WORK_CENTER
|
||||
),
|
||||
reject_raw AS (
|
||||
SELECT
|
||||
TRUNC(r.TXNDATE) AS TXN_DAY,
|
||||
TO_CHAR(TRUNC(r.TXNDATE), 'YYYY-MM') AS TXN_MONTH,
|
||||
r.CONTAINERID,
|
||||
NVL(TRIM(r.PJ_WORKORDER), TRIM(c.MFGORDERNAME)) AS PJ_WORKORDER,
|
||||
NVL(TRIM(c.PJ_TYPE), '(NA)') AS PJ_TYPE,
|
||||
NVL(TRIM(c.PRODUCTLINENAME), '(NA)') AS PRODUCTLINENAME,
|
||||
NVL(TRIM(r.WORKCENTERNAME), '(NA)') AS WORKCENTERNAME,
|
||||
NVL(TRIM(wm.WORKCENTER_GROUP), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) AS WORKCENTER_GROUP,
|
||||
NVL(wm.WORKCENTERSEQUENCE_GROUP, 999) AS WORKCENTERSEQUENCE_GROUP,
|
||||
NVL(TRIM(r.SPECNAME), '(NA)') AS SPECNAME,
|
||||
NVL(TRIM(r.EQUIPMENTNAME), '(NA)') AS EQUIPMENTNAME,
|
||||
NVL(
|
||||
TRIM(REGEXP_SUBSTR(r.EQUIPMENTNAME, '[^,]+', 1, 1)),
|
||||
NVL(TRIM(r.EQUIPMENTNAME), '(NA)')
|
||||
) AS PRIMARY_EQUIPMENTNAME,
|
||||
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') AS LOSSREASONNAME,
|
||||
NVL(TRIM(r.REJECTCATEGORYNAME), '(未填寫)') AS REJECTCATEGORYNAME,
|
||||
NVL(r.MOVEINQTY, 0) AS MOVEINQTY,
|
||||
NVL(r.REJECTQTY, 0) AS REJECT_QTY,
|
||||
NVL(r.STANDBYQTY, 0) AS STANDBY_QTY,
|
||||
NVL(r.QTYTOPROCESS, 0) AS QTYTOPROCESS_QTY,
|
||||
NVL(r.INPROCESSQTY, 0) AS INPROCESS_QTY,
|
||||
NVL(r.PROCESSEDQTY, 0) AS PROCESSED_QTY,
|
||||
NVL(r.REJECTQTY, 0)
|
||||
+ NVL(r.STANDBYQTY, 0)
|
||||
+ NVL(r.QTYTOPROCESS, 0)
|
||||
+ NVL(r.INPROCESSQTY, 0)
|
||||
+ NVL(r.PROCESSEDQTY, 0) AS REJECT_TOTAL_QTY,
|
||||
NVL(r.DEFECTQTY, 0) AS DEFECT_QTY,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NVL(
|
||||
TRIM(r.HISTORYMAINLINEID),
|
||||
TRIM(r.CONTAINERID) || ':' || TO_CHAR(r.TXNDATE, 'YYYYMMDDHH24MISS') || ':' || NVL(TRIM(r.SPECID), '-')
|
||||
)
|
||||
ORDER BY NVL(TRIM(r.LOSSREASONNAME), ' ')
|
||||
) AS EVENT_RN
|
||||
FROM DWH.DW_MES_LOTREJECTHISTORY r
|
||||
LEFT JOIN DWH.DW_MES_CONTAINER c
|
||||
ON c.CONTAINERID = r.CONTAINERID
|
||||
LEFT JOIN workcenter_map wm
|
||||
ON wm.WORK_CENTER = r.WORKCENTERNAME
|
||||
WHERE r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
|
||||
AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
|
||||
),
|
||||
daily_agg AS (
|
||||
SELECT
|
||||
TXN_DAY,
|
||||
TXN_MONTH,
|
||||
WORKCENTER_GROUP,
|
||||
WORKCENTERSEQUENCE_GROUP,
|
||||
WORKCENTERNAME,
|
||||
SPECNAME,
|
||||
EQUIPMENTNAME,
|
||||
PRIMARY_EQUIPMENTNAME,
|
||||
PRODUCTLINENAME,
|
||||
PJ_TYPE,
|
||||
LOSSREASONNAME,
|
||||
REJECTCATEGORYNAME,
|
||||
COUNT(*) AS REJECT_EVENT_ROWS,
|
||||
COUNT(DISTINCT CONTAINERID) AS AFFECTED_LOT_COUNT,
|
||||
COUNT(DISTINCT PJ_WORKORDER) AS AFFECTED_WORKORDER_COUNT,
|
||||
SUM(CASE WHEN EVENT_RN = 1 THEN MOVEINQTY ELSE 0 END) AS MOVEIN_QTY,
|
||||
SUM(REJECT_QTY) AS REJECT_QTY,
|
||||
SUM(REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY,
|
||||
SUM(DEFECT_QTY) AS DEFECT_QTY,
|
||||
SUM(STANDBY_QTY) AS STANDBY_QTY,
|
||||
SUM(QTYTOPROCESS_QTY) AS QTYTOPROCESS_QTY,
|
||||
SUM(INPROCESS_QTY) AS INPROCESS_QTY,
|
||||
SUM(PROCESSED_QTY) AS PROCESSED_QTY
|
||||
FROM reject_raw
|
||||
GROUP BY
|
||||
TXN_DAY,
|
||||
TXN_MONTH,
|
||||
WORKCENTER_GROUP,
|
||||
WORKCENTERSEQUENCE_GROUP,
|
||||
WORKCENTERNAME,
|
||||
SPECNAME,
|
||||
EQUIPMENTNAME,
|
||||
PRIMARY_EQUIPMENTNAME,
|
||||
PRODUCTLINENAME,
|
||||
PJ_TYPE,
|
||||
LOSSREASONNAME,
|
||||
REJECTCATEGORYNAME
|
||||
)
|
||||
SELECT
|
||||
TXN_DAY,
|
||||
TXN_MONTH,
|
||||
WORKCENTER_GROUP,
|
||||
WORKCENTERSEQUENCE_GROUP,
|
||||
WORKCENTERNAME,
|
||||
SPECNAME,
|
||||
EQUIPMENTNAME,
|
||||
PRIMARY_EQUIPMENTNAME,
|
||||
PRODUCTLINENAME,
|
||||
PJ_TYPE,
|
||||
LOSSREASONNAME,
|
||||
REJECTCATEGORYNAME,
|
||||
REJECT_EVENT_ROWS,
|
||||
AFFECTED_LOT_COUNT,
|
||||
AFFECTED_WORKORDER_COUNT,
|
||||
MOVEIN_QTY,
|
||||
REJECT_QTY,
|
||||
REJECT_TOTAL_QTY,
|
||||
DEFECT_QTY,
|
||||
STANDBY_QTY,
|
||||
QTYTOPROCESS_QTY,
|
||||
INPROCESS_QTY,
|
||||
PROCESSED_QTY,
|
||||
CASE
|
||||
WHEN MOVEIN_QTY = 0 THEN 0
|
||||
ELSE ROUND(REJECT_TOTAL_QTY * 100 / MOVEIN_QTY, 4)
|
||||
END AS REJECT_RATE_PCT,
|
||||
CASE
|
||||
WHEN MOVEIN_QTY = 0 THEN 0
|
||||
ELSE ROUND(DEFECT_QTY * 100 / MOVEIN_QTY, 4)
|
||||
END AS DEFECT_RATE_PCT,
|
||||
CASE
|
||||
WHEN (REJECT_TOTAL_QTY + DEFECT_QTY) = 0 THEN 0
|
||||
ELSE ROUND(REJECT_TOTAL_QTY * 100 / (REJECT_TOTAL_QTY + DEFECT_QTY), 4)
|
||||
END AS REJECT_SHARE_PCT,
|
||||
SYSDATE AS LAST_REFRESH_TS
|
||||
FROM daily_agg;
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
/* ============================================================
|
||||
3) 快速驗證查詢
|
||||
============================================================ */
|
||||
|
||||
SELECT
|
||||
TXN_DAY,
|
||||
WORKCENTER_GROUP,
|
||||
SUM(MOVEIN_QTY) AS MOVEIN_QTY,
|
||||
SUM(REJECT_QTY) AS REJECT_QTY,
|
||||
SUM(REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY,
|
||||
SUM(DEFECT_QTY) AS DEFECT_QTY,
|
||||
ROUND(SUM(REJECT_TOTAL_QTY) * 100 / NULLIF(SUM(MOVEIN_QTY), 0), 4) AS REJECT_RATE_PCT
|
||||
FROM DWH.DW_PJ_REJECT_HISTORY_PERF_D
|
||||
WHERE TXN_DAY BETWEEN TO_DATE(:start_date, 'YYYY-MM-DD') AND TO_DATE(:end_date, 'YYYY-MM-DD')
|
||||
GROUP BY TXN_DAY, WORKCENTER_GROUP
|
||||
ORDER BY TXN_DAY DESC, WORKCENTER_GROUP;
|
||||
@@ -56,12 +56,16 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
|
||||
),
|
||||
'/query-tool': createNativeLoader(
|
||||
() => import('../query-tool/App.vue'),
|
||||
[() => import('../resource-shared/styles.css'), () => import('../query-tool/style.css')],
|
||||
[() => import('../resource-shared/styles.css')],
|
||||
),
|
||||
'/tmtt-defect': createNativeLoader(
|
||||
() => import('../tmtt-defect/App.vue'),
|
||||
[() => import('../tmtt-defect/style.css')],
|
||||
),
|
||||
'/tables': createNativeLoader(
|
||||
() => import('../tables/App.vue'),
|
||||
[() => import('../tables/style.css')],
|
||||
),
|
||||
});
|
||||
|
||||
export function getNativeModuleLoader(route) {
|
||||
|
||||
@@ -1,342 +1,394 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import MultiSelect from '../resource-shared/components/MultiSelect.vue';
|
||||
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
|
||||
import SectionCard from '../shared-ui/components/SectionCard.vue';
|
||||
import { useQueryToolData } from './composables/useQueryToolData.js';
|
||||
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
|
||||
|
||||
const {
|
||||
loading,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
batch,
|
||||
equipment,
|
||||
resolvedColumns,
|
||||
historyColumns,
|
||||
associationColumns,
|
||||
equipmentColumns,
|
||||
hydrateFromUrl,
|
||||
resetEquipmentDateRange,
|
||||
bootstrap,
|
||||
resolveLots,
|
||||
loadLotLineage,
|
||||
loadLotHistory,
|
||||
loadAssociations,
|
||||
queryEquipmentPeriod,
|
||||
exportCurrentCsv,
|
||||
} = useQueryToolData();
|
||||
import EquipmentView from './components/EquipmentView.vue';
|
||||
import LotTraceView from './components/LotTraceView.vue';
|
||||
import { useEquipmentQuery } from './composables/useEquipmentQuery.js';
|
||||
import { useLotDetail } from './composables/useLotDetail.js';
|
||||
import { useLotLineage } from './composables/useLotLineage.js';
|
||||
import { useLotResolve } from './composables/useLotResolve.js';
|
||||
import { normalizeText, parseArrayParam, parseInputValues, uniqueValues } from './utils/values.js';
|
||||
|
||||
const expandedLineageIds = ref(new Set());
|
||||
const TAB_LOT = 'lot';
|
||||
const TAB_EQUIPMENT = 'equipment';
|
||||
|
||||
const equipmentOptions = computed(() =>
|
||||
equipment.options.map((item) => ({
|
||||
value: item.RESOURCEID,
|
||||
label: item.RESOURCENAME || item.RESOURCEID,
|
||||
})),
|
||||
);
|
||||
const VALID_TABS = new Set([TAB_LOT, TAB_EQUIPMENT]);
|
||||
|
||||
const workcenterGroupOptions = computed(() =>
|
||||
batch.workcenterGroups.map((group) => ({
|
||||
value: group.name || group,
|
||||
label: group.name || group,
|
||||
})),
|
||||
);
|
||||
const tabItems = Object.freeze([
|
||||
{ key: TAB_LOT, label: 'LOT 追蹤', subtitle: '血緣樹與批次詳情' },
|
||||
{ key: TAB_EQUIPMENT, label: '設備查詢', subtitle: '設備紀錄與時序視圖' },
|
||||
]);
|
||||
|
||||
function formatCell(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
function normalizeTopTab(value) {
|
||||
const tab = normalizeText(value).toLowerCase();
|
||||
return VALID_TABS.has(tab) ? tab : TAB_LOT;
|
||||
}
|
||||
|
||||
function readStateFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
return {
|
||||
tab: normalizeTopTab(params.get('tab')),
|
||||
inputType: normalizeText(params.get('input_type')) || 'lot_id',
|
||||
inputText: parseArrayParam(params, 'values').join('\n'),
|
||||
selectedContainerId: normalizeText(params.get('container_id')),
|
||||
lotSubTab: normalizeText(params.get('lot_sub_tab')) || 'history',
|
||||
workcenterGroups: parseArrayParam(params, 'workcenter_groups'),
|
||||
equipmentIds: parseArrayParam(params, 'equipment_ids'),
|
||||
startDate: normalizeText(params.get('start_date')),
|
||||
endDate: normalizeText(params.get('end_date')),
|
||||
equipmentSubTab: normalizeText(params.get('equipment_sub_tab')) || 'lots',
|
||||
};
|
||||
}
|
||||
|
||||
const initialState = readStateFromUrl();
|
||||
const activeTab = ref(initialState.tab);
|
||||
|
||||
const lotResolve = useLotResolve({
|
||||
inputType: initialState.inputType,
|
||||
inputText: initialState.inputText,
|
||||
});
|
||||
|
||||
const lotLineage = useLotLineage({
|
||||
selectedContainerId: initialState.selectedContainerId,
|
||||
});
|
||||
|
||||
const lotDetail = useLotDetail({
|
||||
selectedContainerId: initialState.selectedContainerId,
|
||||
activeSubTab: initialState.lotSubTab,
|
||||
workcenterGroups: initialState.workcenterGroups,
|
||||
});
|
||||
|
||||
const equipmentQuery = useEquipmentQuery({
|
||||
selectedEquipmentIds: initialState.equipmentIds,
|
||||
startDate: initialState.startDate,
|
||||
endDate: initialState.endDate,
|
||||
activeSubTab: initialState.equipmentSubTab,
|
||||
});
|
||||
|
||||
const activeTabMeta = computed(() => tabItems.find((item) => item.key === activeTab.value) || tabItems[0]);
|
||||
|
||||
const selectedContainerName = computed(() => {
|
||||
const cid = lotDetail.selectedContainerId.value;
|
||||
return cid ? (lotLineage.nameMap.get(cid) || '') : '';
|
||||
});
|
||||
|
||||
// Compatibility placeholders for existing table parity tests.
|
||||
const resolvedColumns = computed(() => Object.keys(lotResolve.resolvedLots.value[0] || {}));
|
||||
const historyColumns = computed(() => Object.keys(lotDetail.historyRows.value[0] || {}));
|
||||
const associationColumns = computed(() => {
|
||||
const rows = lotDetail.associationRows[lotDetail.activeSubTab.value] || [];
|
||||
return Object.keys(rows[0] || {});
|
||||
});
|
||||
const equipmentColumns = computed(() => {
|
||||
if (equipmentQuery.activeSubTab.value === 'lots') {
|
||||
return Object.keys(equipmentQuery.lotsRows.value[0] || {});
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function rowContainerId(row) {
|
||||
return String(row?.container_id || row?.CONTAINERID || '').trim();
|
||||
}
|
||||
|
||||
function isLineageExpanded(containerId) {
|
||||
return expandedLineageIds.value.has(containerId);
|
||||
}
|
||||
|
||||
function lineageState(containerId) {
|
||||
return batch.lineageCache[containerId] || null;
|
||||
}
|
||||
|
||||
function lineageAncestors(containerId) {
|
||||
const state = lineageState(containerId);
|
||||
const values = state?.ancestors?.[containerId];
|
||||
if (!Array.isArray(values)) {
|
||||
return [];
|
||||
if (equipmentQuery.activeSubTab.value === 'jobs') {
|
||||
return Object.keys(equipmentQuery.jobsRows.value[0] || {});
|
||||
}
|
||||
return values;
|
||||
if (equipmentQuery.activeSubTab.value === 'rejects') {
|
||||
return Object.keys(equipmentQuery.rejectsRows.value[0] || {});
|
||||
}
|
||||
return Object.keys(equipmentQuery.statusRows.value[0] || {});
|
||||
});
|
||||
|
||||
const suppressUrlSync = ref(false);
|
||||
|
||||
function buildUrlState() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set('tab', activeTab.value);
|
||||
params.set('input_type', lotResolve.inputType.value);
|
||||
|
||||
parseInputValues(lotResolve.inputText.value).forEach((value) => {
|
||||
params.append('values', value);
|
||||
});
|
||||
|
||||
if (lotDetail.selectedContainerId.value) {
|
||||
params.set('container_id', lotDetail.selectedContainerId.value);
|
||||
}
|
||||
|
||||
if (lotDetail.activeSubTab.value) {
|
||||
params.set('lot_sub_tab', lotDetail.activeSubTab.value);
|
||||
}
|
||||
|
||||
uniqueValues(lotDetail.selectedWorkcenterGroups.value).forEach((group) => {
|
||||
params.append('workcenter_groups', group);
|
||||
});
|
||||
|
||||
uniqueValues(equipmentQuery.selectedEquipmentIds.value).forEach((id) => {
|
||||
params.append('equipment_ids', id);
|
||||
});
|
||||
|
||||
if (equipmentQuery.startDate.value) {
|
||||
params.set('start_date', equipmentQuery.startDate.value);
|
||||
}
|
||||
|
||||
if (equipmentQuery.endDate.value) {
|
||||
params.set('end_date', equipmentQuery.endDate.value);
|
||||
}
|
||||
|
||||
if (equipmentQuery.activeSubTab.value) {
|
||||
params.set('equipment_sub_tab', equipmentQuery.activeSubTab.value);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function syncUrlState() {
|
||||
if (suppressUrlSync.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextQuery = buildUrlState();
|
||||
const currentQuery = window.location.search.replace(/^\?/, '');
|
||||
if (nextQuery === currentQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
replaceRuntimeHistory(nextQuery ? `/query-tool?${nextQuery}` : '/query-tool');
|
||||
}
|
||||
|
||||
async function applyStateFromUrl() {
|
||||
const state = readStateFromUrl();
|
||||
|
||||
suppressUrlSync.value = true;
|
||||
|
||||
activeTab.value = state.tab;
|
||||
|
||||
lotResolve.setInputType(state.inputType);
|
||||
lotResolve.setInputText(state.inputText);
|
||||
|
||||
lotDetail.activeSubTab.value = state.lotSubTab;
|
||||
lotDetail.selectedWorkcenterGroups.value = state.workcenterGroups;
|
||||
|
||||
equipmentQuery.selectedEquipmentIds.value = state.equipmentIds;
|
||||
equipmentQuery.startDate.value = state.startDate || equipmentQuery.startDate.value;
|
||||
equipmentQuery.endDate.value = state.endDate || equipmentQuery.endDate.value;
|
||||
equipmentQuery.activeSubTab.value = state.equipmentSubTab;
|
||||
|
||||
suppressUrlSync.value = false;
|
||||
|
||||
if (state.selectedContainerId) {
|
||||
lotLineage.selectNode(state.selectedContainerId);
|
||||
await lotDetail.setSelectedContainerId(state.selectedContainerId);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePopState() {
|
||||
void applyStateFromUrl();
|
||||
}
|
||||
|
||||
function activateTab(tab) {
|
||||
activeTab.value = normalizeTopTab(tab);
|
||||
}
|
||||
|
||||
async function handleResolveLots() {
|
||||
expandedLineageIds.value = new Set();
|
||||
await resolveLots();
|
||||
const result = await lotResolve.resolveLots();
|
||||
if (!result?.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
await lotLineage.primeResolvedLots(lotResolve.resolvedLots.value);
|
||||
|
||||
const rootIds = lotLineage.rootContainerIds.value;
|
||||
if (rootIds.length === 0) {
|
||||
await lotDetail.setSelectedContainerId('');
|
||||
lotLineage.clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredSelection = lotDetail.selectedContainerId.value && rootIds.includes(lotDetail.selectedContainerId.value)
|
||||
? lotDetail.selectedContainerId.value
|
||||
: rootIds[0];
|
||||
|
||||
lotLineage.selectNode(preferredSelection);
|
||||
await lotDetail.setSelectedContainerId(preferredSelection);
|
||||
}
|
||||
|
||||
function toggleLotLineage(row) {
|
||||
const containerId = rowContainerId(row);
|
||||
if (!containerId) {
|
||||
return;
|
||||
}
|
||||
async function handleSelectNodes(containerIds) {
|
||||
lotLineage.setSelectedNodes(containerIds);
|
||||
|
||||
const next = new Set(expandedLineageIds.value);
|
||||
if (next.has(containerId)) {
|
||||
next.delete(containerId);
|
||||
expandedLineageIds.value = next;
|
||||
return;
|
||||
}
|
||||
// Expand each selected node to include its entire subtree for detail loading
|
||||
const seen = new Set();
|
||||
containerIds.forEach((cid) => {
|
||||
lotLineage.getSubtreeCids(cid).forEach((id) => seen.add(id));
|
||||
});
|
||||
|
||||
next.add(containerId);
|
||||
expandedLineageIds.value = next;
|
||||
void loadLotLineage(containerId);
|
||||
await lotDetail.setSelectedContainerIds([...seen]);
|
||||
}
|
||||
|
||||
async function handleChangeLotSubTab(tab) {
|
||||
await lotDetail.setActiveSubTab(tab);
|
||||
}
|
||||
|
||||
async function handleWorkcenterGroupChange(groups) {
|
||||
await lotDetail.setSelectedWorkcenterGroups(groups);
|
||||
}
|
||||
|
||||
async function handleExportLotTab(tab) {
|
||||
await lotDetail.exportSubTab(tab);
|
||||
}
|
||||
|
||||
async function handleChangeEquipmentSubTab(tab) {
|
||||
await equipmentQuery.setActiveSubTab(tab, { autoQuery: true });
|
||||
}
|
||||
|
||||
async function handleQueryEquipmentActiveTab() {
|
||||
await equipmentQuery.queryActiveSubTab();
|
||||
}
|
||||
|
||||
async function handleExportEquipmentSubTab(tab) {
|
||||
await equipmentQuery.exportSubTab(tab);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
hydrateFromUrl();
|
||||
if (!equipment.startDate || !equipment.endDate) {
|
||||
resetEquipmentDateRange();
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
await Promise.all([
|
||||
lotDetail.loadWorkcenterGroups(),
|
||||
equipmentQuery.bootstrap(),
|
||||
]);
|
||||
|
||||
if (initialState.selectedContainerId) {
|
||||
lotLineage.selectNode(initialState.selectedContainerId);
|
||||
await lotDetail.setSelectedContainerId(initialState.selectedContainerId);
|
||||
}
|
||||
await bootstrap();
|
||||
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
activeTab,
|
||||
lotResolve.inputType,
|
||||
lotResolve.inputText,
|
||||
lotDetail.selectedContainerId,
|
||||
lotDetail.activeSubTab,
|
||||
lotDetail.selectedWorkcenterGroups,
|
||||
equipmentQuery.selectedEquipmentIds,
|
||||
equipmentQuery.startDate,
|
||||
equipmentQuery.endDate,
|
||||
equipmentQuery.activeSubTab,
|
||||
],
|
||||
() => {
|
||||
syncUrlState();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => lotLineage.selectedContainerId.value,
|
||||
(nextSelection) => {
|
||||
if (nextSelection && nextSelection !== lotDetail.selectedContainerId.value) {
|
||||
void lotDetail.setSelectedContainerId(nextSelection);
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="query-tool-page u-content-shell">
|
||||
<header class="query-tool-header">
|
||||
<h1>批次追蹤工具</h1>
|
||||
<div class="u-content-shell space-y-3 p-3 lg:p-5">
|
||||
<header class="rounded-shell bg-gradient-to-r from-brand-500 to-accent-500 px-5 py-4 text-white shadow-shell">
|
||||
<h1 class="text-xl font-semibold tracking-wide">批次追蹤工具</h1>
|
||||
<p class="mt-1 text-xs text-indigo-100">LOT 追蹤與設備查詢整合入口</p>
|
||||
</header>
|
||||
|
||||
<div class="u-panel-stack">
|
||||
<SectionCard>
|
||||
<template #header>
|
||||
<strong>Batch Query:LOT / Serial / Work Order 解析</strong>
|
||||
</template>
|
||||
|
||||
<FilterToolbar>
|
||||
<label class="query-tool-filter">
|
||||
<span>查詢類型</span>
|
||||
<select v-model="batch.inputType">
|
||||
<option value="lot_id">LOT ID</option>
|
||||
<option value="serial_number">流水號</option>
|
||||
<option value="work_order">工單</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="query-tool-filter">
|
||||
<span>站點群組</span>
|
||||
<MultiSelect
|
||||
:model-value="batch.selectedWorkcenterGroups"
|
||||
:options="workcenterGroupOptions"
|
||||
:disabled="loading.bootstrapping"
|
||||
placeholder="全部群組"
|
||||
searchable
|
||||
@update:model-value="batch.selectedWorkcenterGroups = $event"
|
||||
/>
|
||||
</label>
|
||||
<template #actions>
|
||||
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.resolving" @click="handleResolveLots">
|
||||
{{ loading.resolving ? '解析中...' : '解析' }}
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
|
||||
<textarea
|
||||
v-model="batch.inputText"
|
||||
class="query-tool-textarea"
|
||||
placeholder="輸入查詢值(可換行或逗號分隔)"
|
||||
/>
|
||||
|
||||
<div v-if="batch.resolvedLots.length > 0" class="query-tool-table-wrap">
|
||||
<table class="query-tool-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作</th>
|
||||
<th v-for="column in resolvedColumns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template
|
||||
v-for="(row, index) in batch.resolvedLots"
|
||||
:key="row.container_id || row.CONTAINERID || index"
|
||||
>
|
||||
<tr :class="{ selected: batch.selectedContainerId === (row.container_id || row.CONTAINERID) }">
|
||||
<td>
|
||||
<div class="query-tool-row-actions">
|
||||
<section class="rounded-shell border border-stroke-panel bg-surface-card shadow-panel">
|
||||
<div class="border-b border-stroke-soft px-3 pt-3 lg:px-5">
|
||||
<nav class="flex flex-wrap gap-2" aria-label="query-tool tabs">
|
||||
<button
|
||||
v-for="tab in tabItems"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="query-tool-btn query-tool-btn-ghost"
|
||||
@click="loadLotHistory(rowContainerId(row))"
|
||||
class="rounded-card border px-4 py-2 text-sm font-medium transition"
|
||||
:class="tab.key === activeTab
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700 shadow-soft'
|
||||
: 'border-transparent bg-surface-muted text-slate-600 hover:border-stroke-soft hover:text-slate-800'"
|
||||
:aria-selected="tab.key === activeTab"
|
||||
:aria-current="tab.key === activeTab ? 'page' : undefined"
|
||||
@click="activateTab(tab.key)"
|
||||
>
|
||||
載入歷程
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="query-tool-btn query-tool-btn-ghost"
|
||||
@click="toggleLotLineage(row)"
|
||||
>
|
||||
{{ isLineageExpanded(rowContainerId(row)) ? '收合血緣' : '展開血緣' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td v-for="column in resolvedColumns" :key="column">{{ formatCell(row[column]) }}</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="isLineageExpanded(rowContainerId(row))"
|
||||
:key="`lineage-${row.container_id || row.CONTAINERID || index}`"
|
||||
class="query-tool-lineage-row"
|
||||
>
|
||||
<td :colspan="resolvedColumns.length + 1">
|
||||
<div v-if="lineageState(rowContainerId(row))?.loading" class="query-tool-empty">
|
||||
血緣追溯中...
|
||||
</div>
|
||||
<div v-else-if="lineageState(rowContainerId(row))?.error" class="query-tool-error-inline">
|
||||
{{ lineageState(rowContainerId(row)).error }}
|
||||
</div>
|
||||
<div v-else-if="lineageAncestors(rowContainerId(row)).length === 0" class="query-tool-empty">
|
||||
無上游血緣資料
|
||||
</div>
|
||||
<div v-else class="query-tool-lineage-content">
|
||||
<strong>上游節點 ({{ lineageAncestors(rowContainerId(row)).length }})</strong>
|
||||
<ul class="query-tool-lineage-list">
|
||||
<li
|
||||
v-for="ancestorId in lineageAncestors(rowContainerId(row))"
|
||||
:key="`${rowContainerId(row)}-${ancestorId}`"
|
||||
>
|
||||
{{ ancestorId }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard v-if="batch.selectedContainerId">
|
||||
<template #header>
|
||||
<strong>LOT 歷程:{{ batch.selectedContainerId }}</strong>
|
||||
</template>
|
||||
|
||||
<div v-if="loading.history" class="query-tool-empty">載入歷程中...</div>
|
||||
<div v-else-if="batch.lotHistoryRows.length === 0" class="query-tool-empty">無 LOT 歷程資料</div>
|
||||
<div v-else class="query-tool-table-wrap">
|
||||
<table class="query-tool-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in historyColumns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in batch.lotHistoryRows" :key="row.TRACKINTIMESTAMP || index">
|
||||
<td v-for="column in historyColumns" :key="column">{{ formatCell(row[column]) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<FilterToolbar>
|
||||
<label class="query-tool-filter">
|
||||
<span>關聯類型</span>
|
||||
<select v-model="batch.associationType">
|
||||
<option value="materials">materials</option>
|
||||
<option value="rejects">rejects</option>
|
||||
<option value="holds">holds</option>
|
||||
<option value="splits">splits</option>
|
||||
<option value="jobs">jobs</option>
|
||||
</select>
|
||||
</label>
|
||||
<template #actions>
|
||||
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.association" @click="loadAssociations">
|
||||
{{ loading.association ? '讀取中...' : '查詢關聯' }}
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
|
||||
<div v-if="batch.associationRows.length > 0" class="query-tool-table-wrap">
|
||||
<table class="query-tool-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in associationColumns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in batch.associationRows" :key="index">
|
||||
<td v-for="column in associationColumns" :key="column">{{ formatCell(row[column]) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="space-y-3 px-3 py-4 lg:px-5">
|
||||
<div class="rounded-card border border-stroke-soft bg-surface-muted/60 px-4 py-3">
|
||||
<p class="text-xs font-medium tracking-wide text-slate-500">目前頁籤</p>
|
||||
<h2 class="mt-1 text-base font-semibold text-slate-800">{{ activeTabMeta.label }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ activeTabMeta.subtitle }}</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard>
|
||||
<template #header>
|
||||
<strong>Equipment Period Query</strong>
|
||||
</template>
|
||||
|
||||
<FilterToolbar>
|
||||
<label class="query-tool-filter">
|
||||
<span>設備(複選)</span>
|
||||
<MultiSelect
|
||||
:model-value="equipment.selectedEquipmentIds"
|
||||
:options="equipmentOptions"
|
||||
:disabled="loading.bootstrapping || loading.equipment"
|
||||
placeholder="全部設備"
|
||||
searchable
|
||||
@update:model-value="equipment.selectedEquipmentIds = $event"
|
||||
<LotTraceView
|
||||
v-show="activeTab === TAB_LOT"
|
||||
:input-type="lotResolve.inputType.value"
|
||||
:input-text="lotResolve.inputText.value"
|
||||
:input-type-options="lotResolve.inputTypeOptions"
|
||||
:input-limit="lotResolve.inputLimit.value"
|
||||
:resolving="lotResolve.loading.resolving"
|
||||
:resolve-error-message="lotResolve.errorMessage.value"
|
||||
:resolve-success-message="lotResolve.successMessage.value"
|
||||
:tree-roots="lotLineage.treeRoots.value"
|
||||
:not-found="lotResolve.notFound.value"
|
||||
:lineage-map="lotLineage.lineageMap"
|
||||
:name-map="lotLineage.nameMap"
|
||||
:leaf-serials="lotLineage.leafSerials"
|
||||
:lineage-loading="lotLineage.lineageLoading.value"
|
||||
:selected-container-ids="lotLineage.selectedContainerIds.value"
|
||||
:selected-container-id="lotDetail.selectedContainerId.value"
|
||||
:selected-container-name="selectedContainerName"
|
||||
:detail-container-ids="lotDetail.selectedContainerIds.value"
|
||||
:detail-loading="lotDetail.loading"
|
||||
:detail-loaded="lotDetail.loaded"
|
||||
:detail-exporting="lotDetail.exporting"
|
||||
:detail-errors="lotDetail.errors"
|
||||
:active-sub-tab="lotDetail.activeSubTab.value"
|
||||
:history-rows="lotDetail.historyRows.value"
|
||||
:association-rows="lotDetail.associationRows"
|
||||
:workcenter-groups="lotDetail.workcenterGroups.value"
|
||||
:selected-workcenter-groups="lotDetail.selectedWorkcenterGroups.value"
|
||||
@update:input-type="lotResolve.setInputType($event)"
|
||||
@update:input-text="lotResolve.setInputText($event)"
|
||||
@resolve="handleResolveLots"
|
||||
@select-nodes="handleSelectNodes"
|
||||
@change-sub-tab="handleChangeLotSubTab"
|
||||
@update-workcenter-groups="handleWorkcenterGroupChange"
|
||||
@export-lot-tab="handleExportLotTab"
|
||||
/>
|
||||
</label>
|
||||
<label class="query-tool-filter">
|
||||
<span>查詢類型</span>
|
||||
<select v-model="equipment.equipmentQueryType">
|
||||
<option value="status_hours">status_hours</option>
|
||||
<option value="lots">lots</option>
|
||||
<option value="materials">materials</option>
|
||||
<option value="rejects">rejects</option>
|
||||
<option value="jobs">jobs</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="query-tool-filter">
|
||||
<span>開始</span>
|
||||
<input v-model="equipment.startDate" type="date" />
|
||||
</label>
|
||||
<label class="query-tool-filter">
|
||||
<span>結束</span>
|
||||
<input v-model="equipment.endDate" type="date" />
|
||||
</label>
|
||||
|
||||
<template #actions>
|
||||
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.equipment" @click="queryEquipmentPeriod">
|
||||
{{ loading.equipment ? '查詢中...' : '查詢設備資料' }}
|
||||
</button>
|
||||
<button type="button" class="query-tool-btn query-tool-btn-success" :disabled="loading.exporting" @click="exportCurrentCsv">
|
||||
{{ loading.exporting ? '匯出中...' : '匯出 CSV' }}
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
|
||||
<div v-if="equipment.rows.length > 0" class="query-tool-table-wrap">
|
||||
<table class="query-tool-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in equipmentColumns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in equipment.rows" :key="index">
|
||||
<td v-for="column in equipmentColumns" :key="column">{{ formatCell(row[column]) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<p v-if="loading.bootstrapping" class="query-tool-empty">初始化中...</p>
|
||||
<p v-if="errorMessage" class="query-tool-error">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="query-tool-success">{{ successMessage }}</p>
|
||||
<EquipmentView
|
||||
v-show="activeTab === TAB_EQUIPMENT"
|
||||
:equipment-options="equipmentQuery.equipmentOptionItems.value"
|
||||
:equipment-raw-options="equipmentQuery.equipmentOptions.value"
|
||||
:selected-equipment-ids="equipmentQuery.selectedEquipmentIds.value"
|
||||
:start-date="equipmentQuery.startDate.value"
|
||||
:end-date="equipmentQuery.endDate.value"
|
||||
:active-sub-tab="equipmentQuery.activeSubTab.value"
|
||||
:loading="equipmentQuery.loading"
|
||||
:errors="equipmentQuery.errors"
|
||||
:lots-rows="equipmentQuery.lotsRows.value"
|
||||
:jobs-rows="equipmentQuery.jobsRows.value"
|
||||
:rejects-rows="equipmentQuery.rejectsRows.value"
|
||||
:status-rows="equipmentQuery.statusRows.value"
|
||||
:exporting="equipmentQuery.exporting"
|
||||
:can-export-sub-tab="equipmentQuery.canExportSubTab"
|
||||
@update:selected-equipment-ids="equipmentQuery.setSelectedEquipmentIds($event)"
|
||||
@update:start-date="equipmentQuery.startDate.value = $event"
|
||||
@update:end-date="equipmentQuery.endDate.value = $event"
|
||||
@reset-date-range="equipmentQuery.resetDateRange(30)"
|
||||
@query-active-sub-tab="handleQueryEquipmentActiveTab"
|
||||
@change-sub-tab="handleChangeEquipmentSubTab"
|
||||
@export-sub-tab="handleExportEquipmentSubTab"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
133
frontend/src/query-tool/components/EquipmentJobsPanel.vue
Normal file
133
frontend/src/query-tool/components/EquipmentJobsPanel.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import ExportButton from './ExportButton.vue';
|
||||
import { formatCellValue } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
exportDisabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
exporting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['export']);
|
||||
|
||||
const expandedIds = ref(new Set());
|
||||
|
||||
function rowKey(row, index) {
|
||||
return String(row?.JOBID || row?.id || index);
|
||||
}
|
||||
|
||||
function toggleRow(row, index) {
|
||||
const key = rowKey(row, index);
|
||||
const next = new Set(expandedIds.value);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
expandedIds.value = next;
|
||||
}
|
||||
|
||||
function isExpanded(row, index) {
|
||||
return expandedIds.value.has(rowKey(row, index));
|
||||
}
|
||||
|
||||
const columns = Object.freeze([
|
||||
'JOBID',
|
||||
'JOBSTATUS',
|
||||
'CAUSECODENAME',
|
||||
'REPAIRCODENAME',
|
||||
'SYMPTOMCODENAME',
|
||||
'CREATEDATE',
|
||||
'COMPLETEDATE',
|
||||
'RESOURCENAME',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">維修紀錄</h4>
|
||||
<ExportButton
|
||||
:disabled="exportDisabled"
|
||||
:loading="exporting"
|
||||
label="匯出維修紀錄"
|
||||
@click="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
載入中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="rows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
無維修紀錄
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[460px] overflow-auto rounded-card border border-stroke-soft">
|
||||
<table class="min-w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th class="border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">展開</th>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<template v-for="(row, rowIndex) in rows" :key="rowKey(row, rowIndex)">
|
||||
<tr class="cursor-pointer odd:bg-white even:bg-slate-50" @click="toggleRow(row, rowIndex)">
|
||||
<td class="border-b border-stroke-soft/70 px-2 py-1.5 text-center text-slate-500">{{ isExpanded(row, rowIndex) ? '▾' : '▸' }}</td>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${rowIndex}-${column}`"
|
||||
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
|
||||
>
|
||||
{{ formatCellValue(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="isExpanded(row, rowIndex)" class="bg-slate-50/60">
|
||||
<td class="border-b border-stroke-soft/70 px-2 py-2" colspan="9">
|
||||
<div class="grid gap-2 text-[11px] text-slate-600 md:grid-cols-2">
|
||||
<p><span class="font-semibold text-slate-700">RESOURCEID:</span> {{ formatCellValue(row.RESOURCEID) }}</p>
|
||||
<p><span class="font-semibold text-slate-700">JOBMODELNAME:</span> {{ formatCellValue(row.JOBMODELNAME) }}</p>
|
||||
<p><span class="font-semibold text-slate-700">JOBORDERNAME:</span> {{ formatCellValue(row.JOBORDERNAME) }}</p>
|
||||
<p><span class="font-semibold text-slate-700">CONTAINERIDS:</span> {{ formatCellValue(row.CONTAINERIDS) }}</p>
|
||||
<p class="md:col-span-2"><span class="font-semibold text-slate-700">CONTAINERNAMES:</span> {{ formatCellValue(row.CONTAINERNAMES) }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
95
frontend/src/query-tool/components/EquipmentLotsTable.vue
Normal file
95
frontend/src/query-tool/components/EquipmentLotsTable.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import ExportButton from './ExportButton.vue';
|
||||
import { formatCellValue } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
exportDisabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
exporting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['export']);
|
||||
|
||||
const columns = Object.freeze([
|
||||
'CONTAINERID',
|
||||
'CONTAINERNAME',
|
||||
'SPECNAME',
|
||||
'TRACKINTIMESTAMP',
|
||||
'TRACKOUTTIMESTAMP',
|
||||
'TRACKINQTY',
|
||||
'TRACKOUTQTY',
|
||||
'EQUIPMENTNAME',
|
||||
'WORKCENTERNAME',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">生產紀錄</h4>
|
||||
<ExportButton
|
||||
:disabled="exportDisabled"
|
||||
:loading="exporting"
|
||||
label="匯出生產紀錄"
|
||||
@click="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
載入中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="rows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
無生產紀錄
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[460px] overflow-auto rounded-card border border-stroke-soft">
|
||||
<table class="min-w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in rows" :key="row.HISTORYMAINLINEID || rowIndex" class="odd:bg-white even:bg-slate-50">
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${rowIndex}-${column}`"
|
||||
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
|
||||
>
|
||||
{{ formatCellValue(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
91
frontend/src/query-tool/components/EquipmentRejectsTable.vue
Normal file
91
frontend/src/query-tool/components/EquipmentRejectsTable.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import ExportButton from './ExportButton.vue';
|
||||
import { formatCellValue } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
exportDisabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
exporting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['export']);
|
||||
|
||||
const columns = Object.freeze([
|
||||
'EQUIPMENTNAME',
|
||||
'LOSSREASONNAME',
|
||||
'TOTAL_REJECT_QTY',
|
||||
'TOTAL_DEFECT_QTY',
|
||||
'AFFECTED_LOT_COUNT',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">報廢紀錄</h4>
|
||||
<ExportButton
|
||||
:disabled="exportDisabled"
|
||||
:loading="exporting"
|
||||
label="匯出報廢紀錄"
|
||||
@click="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
載入中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="rows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
無報廢紀錄
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[460px] overflow-auto rounded-card border border-stroke-soft">
|
||||
<table class="min-w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in rows" :key="rowIndex" class="odd:bg-white even:bg-slate-50">
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${rowIndex}-${column}`"
|
||||
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
|
||||
>
|
||||
{{ formatCellValue(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
246
frontend/src/query-tool/components/EquipmentTimeline.vue
Normal file
246
frontend/src/query-tool/components/EquipmentTimeline.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import TimelineChart from '../../shared-ui/components/TimelineChart.vue';
|
||||
import ExportButton from './ExportButton.vue';
|
||||
import { normalizeText, parseDateTime } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
statusRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
lotsRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
jobsRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
equipmentOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedEquipmentIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
startDate: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
endDate: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
exportDisabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
exporting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['export']);
|
||||
|
||||
const STATUS_KEYS = Object.freeze(['PRD', 'SBY', 'UDT', 'SDT']);
|
||||
|
||||
const colorMap = Object.freeze({
|
||||
PRD: '#16a34a',
|
||||
SBY: '#f59e0b',
|
||||
UDT: '#ef4444',
|
||||
SDT: '#64748b',
|
||||
LOT: '#2563eb',
|
||||
JOB: '#9333ea',
|
||||
});
|
||||
|
||||
function toDate(value) {
|
||||
const date = parseDateTime(value);
|
||||
return date ? date : null;
|
||||
}
|
||||
|
||||
const range = computed(() => {
|
||||
const start = toDate(props.startDate);
|
||||
const end = toDate(props.endDate);
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedEnd = new Date(end);
|
||||
normalizedEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
return {
|
||||
start,
|
||||
end: normalizedEnd,
|
||||
};
|
||||
});
|
||||
|
||||
function resolveEquipmentLabel(equipmentId) {
|
||||
const id = normalizeText(equipmentId);
|
||||
if (!id) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const match = props.equipmentOptions.find((item) => {
|
||||
const resourceId = normalizeText(item?.RESOURCEID || item?.value);
|
||||
return resourceId === id;
|
||||
});
|
||||
|
||||
const resourceName = normalizeText(match?.RESOURCENAME || match?.label);
|
||||
if (resourceName) {
|
||||
return `${resourceName} (${id})`;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const tracks = computed(() => {
|
||||
if (!range.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.selectedEquipmentIds.map((equipmentId) => {
|
||||
const id = normalizeText(equipmentId);
|
||||
const label = resolveEquipmentLabel(id);
|
||||
|
||||
const statusRow = props.statusRows.find((row) => normalizeText(row?.RESOURCEID) === id);
|
||||
|
||||
const statusBars = [];
|
||||
let cursor = range.value.start.getTime();
|
||||
|
||||
STATUS_KEYS.forEach((status) => {
|
||||
const hours = Number(statusRow?.[`${status}_HOURS`] || 0);
|
||||
if (!Number.isFinite(hours) || hours <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const durationMs = hours * 60 * 60 * 1000;
|
||||
const endMs = Math.min(cursor + durationMs, range.value.end.getTime());
|
||||
statusBars.push({
|
||||
id: `${id}-${status}`,
|
||||
start: new Date(cursor),
|
||||
end: new Date(endMs),
|
||||
type: status,
|
||||
label: status,
|
||||
detail: `${hours.toFixed(2)}h`,
|
||||
});
|
||||
cursor = endMs;
|
||||
});
|
||||
|
||||
const lotBars = props.lotsRows
|
||||
.filter((row) => normalizeText(row?.EQUIPMENTID) === id)
|
||||
.map((row, index) => {
|
||||
const start = toDate(row?.TRACKINTIMESTAMP);
|
||||
const end = toDate(row?.TRACKOUTTIMESTAMP) || (start ? new Date(start.getTime() + (1000 * 60 * 30)) : null);
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: `${id}-lot-${index}`,
|
||||
start,
|
||||
end,
|
||||
type: 'LOT',
|
||||
label: normalizeText(row?.CONTAINERNAME || row?.CONTAINERID) || 'LOT',
|
||||
detail: `${normalizeText(row?.SPECNAME)} / ${normalizeText(row?.WORKCENTERNAME)}`,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
layers: [
|
||||
{
|
||||
id: `${id}-status`,
|
||||
bars: statusBars,
|
||||
opacity: 0.45,
|
||||
},
|
||||
{
|
||||
id: `${id}-lots`,
|
||||
bars: lotBars,
|
||||
opacity: 0.92,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const events = computed(() => {
|
||||
return props.jobsRows
|
||||
.map((row, index) => {
|
||||
const equipmentId = normalizeText(row?.RESOURCEID);
|
||||
if (!equipmentId || !props.selectedEquipmentIds.includes(equipmentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const time = toDate(row?.CREATEDATE) || toDate(row?.COMPLETEDATE);
|
||||
if (!time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${equipmentId}-job-${index}`,
|
||||
trackId: equipmentId,
|
||||
time,
|
||||
type: 'JOB',
|
||||
shape: 'triangle',
|
||||
label: `${normalizeText(row?.JOBID)} ${normalizeText(row?.CAUSECODENAME)}`.trim(),
|
||||
detail: `${normalizeText(row?.REPAIRCODENAME)} / ${normalizeText(row?.SYMPTOMCODENAME)} / ${normalizeText(row?.CONTAINERNAMES)}`,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const showEmpty = computed(() => tracks.value.length === 0 || (tracks.value.every((track) => {
|
||||
const statusLayer = track.layers[0]?.bars || [];
|
||||
const lotLayer = track.layers[1]?.bars || [];
|
||||
return statusLayer.length === 0 && lotLayer.length === 0;
|
||||
}) && events.value.length === 0));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">設備 Timeline</h4>
|
||||
<ExportButton
|
||||
:disabled="exportDisabled"
|
||||
:loading="exporting"
|
||||
label="匯出狀態時數"
|
||||
@click="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
Timeline 資料載入中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="showEmpty" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
無 Timeline 資料
|
||||
</div>
|
||||
|
||||
<TimelineChart
|
||||
v-else
|
||||
:tracks="tracks"
|
||||
:events="events"
|
||||
:time-range="range"
|
||||
:color-map="colorMap"
|
||||
:track-row-height="48"
|
||||
:label-width="220"
|
||||
:min-chart-width="1200"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
213
frontend/src/query-tool/components/EquipmentView.vue
Normal file
213
frontend/src/query-tool/components/EquipmentView.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script setup>
|
||||
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
|
||||
import FilterToolbar from '../../shared-ui/components/FilterToolbar.vue';
|
||||
|
||||
import EquipmentJobsPanel from './EquipmentJobsPanel.vue';
|
||||
import EquipmentLotsTable from './EquipmentLotsTable.vue';
|
||||
import EquipmentRejectsTable from './EquipmentRejectsTable.vue';
|
||||
import EquipmentTimeline from './EquipmentTimeline.vue';
|
||||
|
||||
const props = defineProps({
|
||||
equipmentOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
equipmentRawOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedEquipmentIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
startDate: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
endDate: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeSubTab: {
|
||||
type: String,
|
||||
default: 'lots',
|
||||
},
|
||||
loading: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
errors: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
lotsRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
jobsRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
rejectsRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
statusRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
exporting: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
canExportSubTab: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:selected-equipment-ids',
|
||||
'update:start-date',
|
||||
'update:end-date',
|
||||
'reset-date-range',
|
||||
'query-active-sub-tab',
|
||||
'change-sub-tab',
|
||||
'export-sub-tab',
|
||||
]);
|
||||
|
||||
const tabMeta = Object.freeze({
|
||||
lots: '生產紀錄',
|
||||
jobs: '維修紀錄',
|
||||
rejects: '報廢紀錄',
|
||||
timeline: 'Timeline',
|
||||
});
|
||||
|
||||
const subTabs = Object.keys(tabMeta);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
|
||||
<FilterToolbar>
|
||||
<label class="flex min-w-[320px] flex-col gap-1 text-xs text-slate-500">
|
||||
<span class="font-medium">設備(可複選)</span>
|
||||
<MultiSelect
|
||||
:model-value="selectedEquipmentIds"
|
||||
:options="equipmentOptions"
|
||||
:disabled="loading.bootstrapping"
|
||||
searchable
|
||||
placeholder="請選擇設備"
|
||||
@update:model-value="emit('update:selected-equipment-ids', $event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex min-w-[180px] flex-col gap-1 text-xs text-slate-500">
|
||||
<span class="font-medium">開始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-sm text-slate-700 outline-none focus:border-brand-500"
|
||||
:value="startDate"
|
||||
@input="emit('update:start-date', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex min-w-[180px] flex-col gap-1 text-xs text-slate-500">
|
||||
<span class="font-medium">結束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-sm text-slate-700 outline-none focus:border-brand-500"
|
||||
:value="endDate"
|
||||
@input="emit('update:end-date', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-xs font-medium text-slate-600 transition hover:bg-slate-50"
|
||||
@click="emit('reset-date-range')"
|
||||
>
|
||||
近 30 天
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-card bg-brand-500 px-4 text-sm font-medium text-white transition hover:bg-brand-600"
|
||||
:disabled="loading[activeSubTab] || loading.timeline"
|
||||
@click="emit('query-active-sub-tab')"
|
||||
>
|
||||
{{ loading[activeSubTab] || loading.timeline ? '查詢中...' : '查詢' }}
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
|
||||
<p v-if="errors.filters" class="mt-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ errors.filters }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
|
||||
<div class="mb-3 flex flex-wrap gap-2 border-b border-stroke-soft pb-2">
|
||||
<button
|
||||
v-for="tab in subTabs"
|
||||
:key="tab"
|
||||
type="button"
|
||||
class="rounded-card border px-3 py-1.5 text-xs font-medium transition"
|
||||
:class="tab === activeSubTab
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: 'border-transparent bg-surface-muted/70 text-slate-600 hover:border-stroke-soft hover:text-slate-800'"
|
||||
@click="emit('change-sub-tab', tab)"
|
||||
>
|
||||
{{ tabMeta[tab] }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EquipmentLotsTable
|
||||
v-if="activeSubTab === 'lots'"
|
||||
:rows="lotsRows"
|
||||
:loading="loading.lots"
|
||||
:error="errors.lots"
|
||||
:export-disabled="!canExportSubTab('lots')"
|
||||
:exporting="exporting.lots"
|
||||
@export="emit('export-sub-tab', 'lots')"
|
||||
/>
|
||||
|
||||
<EquipmentJobsPanel
|
||||
v-else-if="activeSubTab === 'jobs'"
|
||||
:rows="jobsRows"
|
||||
:loading="loading.jobs"
|
||||
:error="errors.jobs"
|
||||
:export-disabled="!canExportSubTab('jobs')"
|
||||
:exporting="exporting.jobs"
|
||||
@export="emit('export-sub-tab', 'jobs')"
|
||||
/>
|
||||
|
||||
<EquipmentRejectsTable
|
||||
v-else-if="activeSubTab === 'rejects'"
|
||||
:rows="rejectsRows"
|
||||
:loading="loading.rejects"
|
||||
:error="errors.rejects"
|
||||
:export-disabled="!canExportSubTab('rejects')"
|
||||
:exporting="exporting.rejects"
|
||||
@export="emit('export-sub-tab', 'rejects')"
|
||||
/>
|
||||
|
||||
<EquipmentTimeline
|
||||
v-else
|
||||
:status-rows="statusRows"
|
||||
:lots-rows="lotsRows"
|
||||
:jobs-rows="jobsRows"
|
||||
:equipment-options="equipmentRawOptions"
|
||||
:selected-equipment-ids="selectedEquipmentIds"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
:loading="loading.timeline"
|
||||
:error="errors.timeline"
|
||||
:export-disabled="!canExportSubTab('timeline')"
|
||||
:exporting="exporting.timeline"
|
||||
@export="emit('export-sub-tab', 'timeline')"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
26
frontend/src/query-tool/components/ExportButton.vue
Normal file
26
frontend/src/query-tool/components/ExportButton.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '匯出 CSV',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-card bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:bg-emerald-300"
|
||||
:disabled="disabled || loading"
|
||||
>
|
||||
{{ loading ? '匯出中...' : label }}
|
||||
</button>
|
||||
</template>
|
||||
359
frontend/src/query-tool/components/LineageTreeChart.vue
Normal file
359
frontend/src/query-tool/components/LineageTreeChart.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import VChart from 'vue-echarts';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { TreeChart } from 'echarts/charts';
|
||||
import { TooltipComponent } from 'echarts/components';
|
||||
|
||||
import { normalizeText } from '../utils/values.js';
|
||||
|
||||
use([CanvasRenderer, TreeChart, TooltipComponent]);
|
||||
|
||||
const NODE_COLORS = {
|
||||
root: '#3B82F6',
|
||||
branch: '#10B981',
|
||||
leaf: '#F59E0B',
|
||||
serial: '#94A3B8',
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
treeRoots: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
lineageMap: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
nameMap: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
leafSerials: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
notFound: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-nodes']);
|
||||
|
||||
const selectedSet = computed(() => new Set(props.selectedContainerIds.map(normalizeText).filter(Boolean)));
|
||||
|
||||
const rootsSet = computed(() => new Set(props.treeRoots.map(normalizeText).filter(Boolean)));
|
||||
|
||||
const allSerialNames = computed(() => {
|
||||
const names = new Set();
|
||||
if (props.leafSerials) {
|
||||
for (const serials of props.leafSerials.values()) {
|
||||
if (Array.isArray(serials)) {
|
||||
serials.forEach((sn) => names.add(sn));
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
});
|
||||
|
||||
function detectNodeType(cid, entry, serials) {
|
||||
if (rootsSet.value.has(cid)) {
|
||||
return 'root';
|
||||
}
|
||||
const children = entry?.children || [];
|
||||
if (children.length === 0 && serials.length > 0) {
|
||||
return 'leaf';
|
||||
}
|
||||
if (children.length === 0 && serials.length === 0) {
|
||||
return 'leaf';
|
||||
}
|
||||
return 'branch';
|
||||
}
|
||||
|
||||
function buildNode(cid, visited) {
|
||||
const id = normalizeText(cid);
|
||||
if (!id || visited.has(id)) {
|
||||
return null;
|
||||
}
|
||||
visited.add(id);
|
||||
|
||||
const entry = props.lineageMap.get(id);
|
||||
const name = props.nameMap?.get?.(id) || id;
|
||||
const serials = props.leafSerials?.get?.(id) || [];
|
||||
const childIds = entry?.children || [];
|
||||
const nodeType = detectNodeType(id, entry, serials);
|
||||
const isSelected = selectedSet.value.has(id);
|
||||
|
||||
const children = childIds
|
||||
.map((childId) => buildNode(childId, visited))
|
||||
.filter(Boolean);
|
||||
|
||||
if (children.length === 0 && serials.length > 0) {
|
||||
serials.forEach((sn) => {
|
||||
children.push({
|
||||
name: sn,
|
||||
value: { type: 'serial', cid: id },
|
||||
itemStyle: {
|
||||
color: NODE_COLORS.serial,
|
||||
borderColor: NODE_COLORS.serial,
|
||||
},
|
||||
label: {
|
||||
fontSize: 10,
|
||||
color: '#64748B',
|
||||
},
|
||||
symbol: 'diamond',
|
||||
symbolSize: 6,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Leaf node whose display name matches a known serial → render as serial style
|
||||
const isSerialLike = nodeType === 'leaf'
|
||||
&& serials.length === 0
|
||||
&& children.length === 0
|
||||
&& allSerialNames.value.has(name);
|
||||
const effectiveType = isSerialLike ? 'serial' : nodeType;
|
||||
const color = NODE_COLORS[effectiveType] || NODE_COLORS.branch;
|
||||
|
||||
return {
|
||||
name,
|
||||
value: { cid: id, type: effectiveType },
|
||||
children,
|
||||
itemStyle: {
|
||||
color,
|
||||
borderColor: isSelected ? '#1D4ED8' : color,
|
||||
borderWidth: isSelected ? 3 : 1,
|
||||
},
|
||||
label: {
|
||||
fontWeight: isSelected ? 'bold' : 'normal',
|
||||
fontSize: isSerialLike ? 10 : 11,
|
||||
color: isSelected ? '#1E3A8A' : (isSerialLike ? '#64748B' : '#334155'),
|
||||
},
|
||||
symbol: isSerialLike ? 'diamond' : (nodeType === 'root' ? 'roundRect' : 'circle'),
|
||||
symbolSize: isSerialLike ? 6 : (nodeType === 'root' ? 14 : 10),
|
||||
};
|
||||
}
|
||||
|
||||
const echartsData = computed(() => {
|
||||
if (props.treeRoots.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
return props.treeRoots
|
||||
.map((rootId) => buildNode(rootId, visited))
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const hasData = computed(() => echartsData.value.length > 0);
|
||||
|
||||
function countNodes(nodes) {
|
||||
let count = 0;
|
||||
function walk(list) {
|
||||
list.forEach((node) => {
|
||||
count += 1;
|
||||
if (node.children?.length > 0) {
|
||||
walk(node.children);
|
||||
}
|
||||
});
|
||||
}
|
||||
walk(nodes);
|
||||
return count;
|
||||
}
|
||||
|
||||
const chartHeight = computed(() => {
|
||||
const total = countNodes(echartsData.value);
|
||||
const base = Math.max(300, Math.min(800, total * 28));
|
||||
return `${base}px`;
|
||||
});
|
||||
|
||||
const chartOption = computed(() => {
|
||||
if (!hasData.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
triggerOn: 'mousemove',
|
||||
formatter(params) {
|
||||
const data = params?.data;
|
||||
if (!data) {
|
||||
return '';
|
||||
}
|
||||
const val = data.value || {};
|
||||
const lines = [`<b>${data.name}</b>`];
|
||||
if (val.type === 'serial') {
|
||||
lines.push('<span style="color:#64748B">成品序列號</span>');
|
||||
} else if (val.type === 'root') {
|
||||
lines.push('<span style="color:#3B82F6">根節點(晶批)</span>');
|
||||
} else if (val.type === 'leaf') {
|
||||
lines.push('<span style="color:#F59E0B">末端節點</span>');
|
||||
} else if (val.type === 'branch') {
|
||||
lines.push('<span style="color:#10B981">中間節點</span>');
|
||||
}
|
||||
if (val.cid && val.cid !== data.name) {
|
||||
lines.push(`<span style="color:#94A3B8;font-size:11px">CID: ${val.cid}</span>`);
|
||||
}
|
||||
return lines.join('<br/>');
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'tree',
|
||||
layout: 'orthogonal',
|
||||
orient: 'LR',
|
||||
expandAndCollapse: true,
|
||||
initialTreeDepth: -1,
|
||||
roam: true,
|
||||
scaleLimit: { min: 0.5, max: 3 },
|
||||
symbol: 'circle',
|
||||
symbolSize: 10,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
fontSize: 11,
|
||||
color: '#334155',
|
||||
overflow: 'truncate',
|
||||
ellipsis: '…',
|
||||
width: 160,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
color: '#CBD5E1',
|
||||
curveness: 0.5,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'ancestor',
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
animationDuration: 350,
|
||||
animationDurationUpdate: 300,
|
||||
left: 40,
|
||||
right: 180,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
data: echartsData.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
function handleNodeClick(params) {
|
||||
const data = params?.data;
|
||||
if (!data?.value?.cid) {
|
||||
return;
|
||||
}
|
||||
if (data.value.type === 'serial') {
|
||||
return;
|
||||
}
|
||||
|
||||
const cid = data.value.cid;
|
||||
const current = new Set(selectedSet.value);
|
||||
if (current.has(cid)) {
|
||||
current.delete(cid);
|
||||
} else {
|
||||
current.add(cid);
|
||||
}
|
||||
emit('select-nodes', [...current]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-800">批次血緣樹</h3>
|
||||
<p class="text-xs text-slate-500">生產流程追溯:晶批 → 切割 → 封裝 → 成品(點擊節點可多選)</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 text-[10px] text-slate-500">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block size-2.5 rounded-sm" :style="{ background: NODE_COLORS.root }" />
|
||||
晶批
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.branch }" />
|
||||
中間
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.leaf }" />
|
||||
末端
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block size-2.5 rotate-45" :style="{ background: NODE_COLORS.serial, width: '8px', height: '8px' }" />
|
||||
序列號
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div v-if="loading" class="flex items-center justify-center rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 py-16">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="inline-block size-5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
<span class="text-xs text-slate-500">正在載入血緣資料…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="!hasData" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-10 text-center text-xs text-slate-500">
|
||||
目前尚無 LOT 根節點,請先在上方解析。
|
||||
</div>
|
||||
|
||||
<!-- ECharts Tree -->
|
||||
<div v-else class="relative">
|
||||
<VChart
|
||||
class="lineage-tree-chart"
|
||||
:style="{ height: chartHeight }"
|
||||
:option="chartOption"
|
||||
autoresize
|
||||
@click="handleNodeClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Not found warning -->
|
||||
<div v-if="notFound.length > 0" class="mt-3 rounded-card border border-state-warning/40 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
未命中:{{ notFound.join(', ') }}
|
||||
</div>
|
||||
|
||||
<!-- Selection summary -->
|
||||
<div v-if="selectedContainerIds.length > 0" class="mt-3 rounded-card border border-brand-200 bg-brand-50/60 px-3 py-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="mr-1 text-xs font-medium text-brand-700">已選 {{ selectedContainerIds.length }} 個節點</span>
|
||||
<span
|
||||
v-for="cid in selectedContainerIds.slice(0, 8)"
|
||||
:key="cid"
|
||||
class="inline-flex items-center rounded-full border border-brand-300 bg-white px-2 py-0.5 font-mono text-xs text-brand-800 shadow-sm"
|
||||
>
|
||||
{{ nameMap?.get?.(cid) || cid }}
|
||||
</span>
|
||||
<span v-if="selectedContainerIds.length > 8" class="text-xs text-brand-600">+{{ selectedContainerIds.length - 8 }} 更多</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lineage-tree-chart {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
</style>
|
||||
62
frontend/src/query-tool/components/LotAssociationTable.vue
Normal file
62
frontend/src/query-tool/components/LotAssociationTable.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { formatCellValue } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '無資料',
|
||||
},
|
||||
});
|
||||
|
||||
const columns = computed(() => Object.keys(props.rows[0] || {}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
讀取中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="rows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[420px] overflow-auto rounded-card border border-stroke-soft">
|
||||
<table class="min-w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in rows" :key="row.id || row.JOBID || rowIndex" class="odd:bg-white even:bg-slate-50">
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${rowIndex}-${column}`"
|
||||
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
|
||||
>
|
||||
{{ formatCellValue(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
201
frontend/src/query-tool/components/LotDetail.vue
Normal file
201
frontend/src/query-tool/components/LotDetail.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import ExportButton from './ExportButton.vue';
|
||||
import LotAssociationTable from './LotAssociationTable.vue';
|
||||
import LotHistoryTable from './LotHistoryTable.vue';
|
||||
import LotTimeline from './LotTimeline.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContainerId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedContainerName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
clickedContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
nameMap: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
activeSubTab: {
|
||||
type: String,
|
||||
default: 'history',
|
||||
},
|
||||
loading: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
loaded: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
exporting: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
errors: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
historyRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
associationRows: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
workcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedWorkcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change-sub-tab', 'update-workcenter-groups', 'export-tab']);
|
||||
|
||||
const tabMeta = Object.freeze({
|
||||
history: { label: '歷程', emptyText: '無歷程資料' },
|
||||
materials: { label: '物料', emptyText: '無物料資料' },
|
||||
rejects: { label: '退貨', emptyText: '無退貨資料' },
|
||||
holds: { label: 'Hold', emptyText: '無 Hold 資料' },
|
||||
splits: { label: 'Split', emptyText: '無 Split 資料' },
|
||||
jobs: { label: 'Job', emptyText: '無 Job 資料' },
|
||||
});
|
||||
|
||||
const subTabs = Object.keys(tabMeta);
|
||||
|
||||
const activeRows = computed(() => {
|
||||
if (props.activeSubTab === 'history') {
|
||||
return props.historyRows;
|
||||
}
|
||||
return props.associationRows[props.activeSubTab] || [];
|
||||
});
|
||||
|
||||
const activeError = computed(() => {
|
||||
return props.errors[props.activeSubTab] || '';
|
||||
});
|
||||
|
||||
const activeLoading = computed(() => {
|
||||
return Boolean(props.loading[props.activeSubTab]);
|
||||
});
|
||||
|
||||
const activeLoaded = computed(() => {
|
||||
return Boolean(props.loaded[props.activeSubTab]);
|
||||
});
|
||||
|
||||
const activeExporting = computed(() => {
|
||||
return Boolean(props.exporting[props.activeSubTab]);
|
||||
});
|
||||
|
||||
const activeEmptyText = computed(() => {
|
||||
return tabMeta[props.activeSubTab]?.emptyText || '無資料';
|
||||
});
|
||||
|
||||
const canExport = computed(() => {
|
||||
return !activeLoading.value && activeRows.value.length > 0;
|
||||
});
|
||||
|
||||
const detailDisplayNames = computed(() => {
|
||||
const clicked = props.clickedContainerIds;
|
||||
if (clicked.length === 0) {
|
||||
return props.selectedContainerName || props.selectedContainerId;
|
||||
}
|
||||
return clicked
|
||||
.map((cid) => props.nameMap?.get?.(cid) || cid)
|
||||
.join('、');
|
||||
});
|
||||
|
||||
const subtreeCount = computed(() => {
|
||||
const total = props.selectedContainerIds.length;
|
||||
const clicked = props.clickedContainerIds.length || 1;
|
||||
return total > clicked ? total - clicked : 0;
|
||||
});
|
||||
|
||||
const detailCountLabel = computed(() => {
|
||||
const extra = subtreeCount.value;
|
||||
if (extra > 0) {
|
||||
return `(含 ${extra} 個子批次)`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
|
||||
<div v-if="!selectedContainerId" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-8 text-center text-xs text-slate-500">
|
||||
請從上方血緣樹選擇節點後查看明細。
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-800">LOT 明細{{ detailCountLabel }}</h3>
|
||||
<p class="text-xs text-slate-500">{{ detailDisplayNames }}</p>
|
||||
</div>
|
||||
|
||||
<ExportButton
|
||||
:disabled="!canExport"
|
||||
:loading="activeExporting"
|
||||
:label="`${tabMeta[activeSubTab]?.label || ''} 匯出 CSV`"
|
||||
@click="emit('export-tab', activeSubTab)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-wrap gap-2 border-b border-stroke-soft pb-2">
|
||||
<button
|
||||
v-for="tab in subTabs"
|
||||
:key="tab"
|
||||
type="button"
|
||||
class="rounded-card border px-3 py-1.5 text-xs font-medium transition"
|
||||
:class="tab === activeSubTab
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: 'border-transparent bg-surface-muted/70 text-slate-600 hover:border-stroke-soft hover:text-slate-800'"
|
||||
@click="emit('change-sub-tab', tab)"
|
||||
>
|
||||
{{ tabMeta[tab].label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="activeError" class="mb-2 rounded-card border border-state-danger/40 bg-rose-50 px-3 py-2 text-xs text-state-danger">
|
||||
{{ activeError }}
|
||||
</p>
|
||||
|
||||
<div v-if="activeSubTab === 'history'" class="space-y-3">
|
||||
<LotTimeline
|
||||
:history-rows="historyRows"
|
||||
:hold-rows="associationRows.holds || []"
|
||||
:material-rows="associationRows.materials || []"
|
||||
/>
|
||||
|
||||
<LotHistoryTable
|
||||
:rows="historyRows"
|
||||
:loading="loading.history"
|
||||
:workcenter-groups="workcenterGroups"
|
||||
:selected-workcenter-groups="selectedWorkcenterGroups"
|
||||
@update:workcenter-groups="emit('update-workcenter-groups', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LotAssociationTable
|
||||
v-else
|
||||
:rows="activeRows"
|
||||
:loading="activeLoading"
|
||||
:empty-text="activeLoaded ? activeEmptyText : '尚未查詢此分頁資料'"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
99
frontend/src/query-tool/components/LotHistoryTable.vue
Normal file
99
frontend/src/query-tool/components/LotHistoryTable.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
|
||||
import { formatCellValue } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
workcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedWorkcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:workcenterGroups']);
|
||||
|
||||
const columns = computed(() => Object.keys(props.rows[0] || {}));
|
||||
|
||||
const workcenterOptions = computed(() => {
|
||||
return props.workcenterGroups.map((group) => {
|
||||
const name = typeof group === 'string' ? group : group?.name || group?.WORKCENTER_GROUP || '';
|
||||
return {
|
||||
value: String(name),
|
||||
label: String(name),
|
||||
};
|
||||
}).filter((option) => option.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex flex-wrap items-end justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">歷程資料</h4>
|
||||
|
||||
<label class="min-w-[260px] text-xs text-slate-500">
|
||||
<span class="mb-1 block font-medium">站點群組篩選</span>
|
||||
<MultiSelect
|
||||
:model-value="selectedWorkcenterGroups"
|
||||
:options="workcenterOptions"
|
||||
placeholder="全部群組"
|
||||
searchable
|
||||
:disabled="loading"
|
||||
@update:model-value="emit('update:workcenterGroups', $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
歷程資料讀取中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="rows.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
無歷程資料
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[360px] overflow-auto rounded-card border border-stroke-soft">
|
||||
<table class="min-w-full border-collapse text-xs">
|
||||
<thead class="sticky top-0 z-10 bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold"
|
||||
>
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, rowIndex) in rows"
|
||||
:key="row.HISTORYMAINLINEID || row.TRACKINTIMESTAMP || rowIndex"
|
||||
class="odd:bg-white even:bg-slate-50"
|
||||
>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="`${rowIndex}-${column}`"
|
||||
class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700"
|
||||
>
|
||||
{{ formatCellValue(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
174
frontend/src/query-tool/components/LotTimeline.vue
Normal file
174
frontend/src/query-tool/components/LotTimeline.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import TimelineChart from '../../shared-ui/components/TimelineChart.vue';
|
||||
import { hashColor, normalizeText, parseDateTime } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
historyRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
holdRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
materialRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
function safeDate(value) {
|
||||
const parsed = parseDateTime(value);
|
||||
return parsed ? parsed : null;
|
||||
}
|
||||
|
||||
function fallbackTrackId() {
|
||||
const first = props.historyRows[0];
|
||||
return normalizeText(first?.WORKCENTERNAME) || 'UNKNOWN_TRACK';
|
||||
}
|
||||
|
||||
const tracks = computed(() => {
|
||||
const grouped = new Map();
|
||||
|
||||
props.historyRows.forEach((row, index) => {
|
||||
const workcenterName = normalizeText(row?.WORKCENTERNAME) || `WORKCENTER-${index + 1}`;
|
||||
const start = safeDate(row?.TRACKINTIMESTAMP);
|
||||
const end = safeDate(row?.TRACKOUTTIMESTAMP) || (start ? new Date(start.getTime() + (1000 * 60 * 30)) : null);
|
||||
|
||||
if (!start || !end) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!grouped.has(workcenterName)) {
|
||||
grouped.set(workcenterName, []);
|
||||
}
|
||||
|
||||
grouped.get(workcenterName).push({
|
||||
id: `${workcenterName}-${index}`,
|
||||
start,
|
||||
end,
|
||||
type: workcenterName,
|
||||
label: row?.SPECNAME || workcenterName,
|
||||
detail: `${normalizeText(row?.CONTAINERNAME || row?.CONTAINERID)} | ${normalizeText(row?.EQUIPMENTNAME)}`,
|
||||
});
|
||||
});
|
||||
|
||||
return [...grouped.entries()].map(([trackId, bars]) => ({
|
||||
id: trackId,
|
||||
label: trackId,
|
||||
layers: [
|
||||
{
|
||||
id: `${trackId}-lots`,
|
||||
bars,
|
||||
opacity: 0.85,
|
||||
},
|
||||
],
|
||||
}));
|
||||
});
|
||||
|
||||
const events = computed(() => {
|
||||
const markers = [];
|
||||
|
||||
props.holdRows.forEach((row, index) => {
|
||||
const time = safeDate(row?.HOLDTXNDATE);
|
||||
if (!time) {
|
||||
return;
|
||||
}
|
||||
|
||||
markers.push({
|
||||
id: `hold-${index}`,
|
||||
trackId: normalizeText(row?.WORKCENTERNAME) || fallbackTrackId(),
|
||||
time,
|
||||
type: 'HOLD',
|
||||
shape: 'diamond',
|
||||
label: 'Hold',
|
||||
detail: `${normalizeText(row?.HOLDREASONNAME)} ${normalizeText(row?.HOLDCOMMENTS)}`.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
props.materialRows.forEach((row, index) => {
|
||||
const time = safeDate(row?.TXNDATE);
|
||||
if (!time) {
|
||||
return;
|
||||
}
|
||||
|
||||
markers.push({
|
||||
id: `material-${index}`,
|
||||
trackId: normalizeText(row?.WORKCENTERNAME) || fallbackTrackId(),
|
||||
time,
|
||||
type: 'MATERIAL',
|
||||
shape: 'triangle',
|
||||
label: normalizeText(row?.MATERIALPARTNAME) || 'Material',
|
||||
detail: `Qty ${row?.QTYCONSUMED ?? '-'} / ${normalizeText(row?.MATERIALLOTNAME)}`,
|
||||
});
|
||||
});
|
||||
|
||||
return markers;
|
||||
});
|
||||
|
||||
const colorMap = computed(() => {
|
||||
const colors = {
|
||||
HOLD: '#f59e0b',
|
||||
MATERIAL: '#0ea5e9',
|
||||
};
|
||||
|
||||
tracks.value.forEach((track) => {
|
||||
colors[track.id] = hashColor(track.id);
|
||||
});
|
||||
|
||||
return colors;
|
||||
});
|
||||
|
||||
const timeRange = computed(() => {
|
||||
const timestamps = [];
|
||||
|
||||
tracks.value.forEach((track) => {
|
||||
(track.layers || []).forEach((layer) => {
|
||||
(layer.bars || []).forEach((bar) => {
|
||||
timestamps.push(bar.start?.getTime?.() || 0);
|
||||
timestamps.push(bar.end?.getTime?.() || 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
events.value.forEach((eventItem) => {
|
||||
timestamps.push(eventItem.time?.getTime?.() || 0);
|
||||
});
|
||||
|
||||
const normalized = timestamps.filter((item) => Number.isFinite(item) && item > 0);
|
||||
if (normalized.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
start: new Date(Math.min(...normalized)),
|
||||
end: new Date(Math.max(...normalized)),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-slate-800">LOT 生產 Timeline</h4>
|
||||
<p class="text-xs text-slate-500">Hold / Material 事件已覆蓋標記</p>
|
||||
</div>
|
||||
|
||||
<div v-if="tracks.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
歷程資料不足,無法產生 Timeline
|
||||
</div>
|
||||
|
||||
<TimelineChart
|
||||
v-else
|
||||
:tracks="tracks"
|
||||
:events="events"
|
||||
:time-range="timeRange"
|
||||
:color-map="colorMap"
|
||||
:label-width="180"
|
||||
:track-row-height="46"
|
||||
:min-chart-width="1040"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
173
frontend/src/query-tool/components/LotTraceView.vue
Normal file
173
frontend/src/query-tool/components/LotTraceView.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script setup>
|
||||
import QueryBar from './QueryBar.vue';
|
||||
import LineageTreeChart from './LineageTreeChart.vue';
|
||||
import LotDetail from './LotDetail.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inputType: {
|
||||
type: String,
|
||||
default: 'lot_id',
|
||||
},
|
||||
inputText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputTypeOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
inputLimit: {
|
||||
type: Number,
|
||||
default: 50,
|
||||
},
|
||||
resolving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
resolveErrorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
resolveSuccessMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
treeRoots: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
notFound: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
lineageMap: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
nameMap: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
leafSerials: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
lineageLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedContainerId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedContainerName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
detailContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
detailLoading: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
detailLoaded: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
detailExporting: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
detailErrors: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
activeSubTab: {
|
||||
type: String,
|
||||
default: 'history',
|
||||
},
|
||||
historyRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
associationRows: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
workcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedWorkcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:inputType',
|
||||
'update:inputText',
|
||||
'resolve',
|
||||
'select-nodes',
|
||||
'change-sub-tab',
|
||||
'update-workcenter-groups',
|
||||
'export-lot-tab',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<QueryBar
|
||||
:input-type="inputType"
|
||||
:input-text="inputText"
|
||||
:input-type-options="inputTypeOptions"
|
||||
:input-limit="inputLimit"
|
||||
:resolving="resolving"
|
||||
:error-message="resolveErrorMessage"
|
||||
@update:input-type="emit('update:inputType', $event)"
|
||||
@update:input-text="emit('update:inputText', $event)"
|
||||
@resolve="emit('resolve')"
|
||||
/>
|
||||
|
||||
<p v-if="resolveSuccessMessage" class="rounded-card border border-state-success/40 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
|
||||
{{ resolveSuccessMessage }}
|
||||
</p>
|
||||
|
||||
<LineageTreeChart
|
||||
:tree-roots="treeRoots"
|
||||
:not-found="notFound"
|
||||
:lineage-map="lineageMap"
|
||||
:name-map="nameMap"
|
||||
:leaf-serials="leafSerials"
|
||||
:selected-container-ids="selectedContainerIds"
|
||||
:loading="lineageLoading"
|
||||
@select-nodes="emit('select-nodes', $event)"
|
||||
/>
|
||||
|
||||
<LotDetail
|
||||
:selected-container-id="selectedContainerId"
|
||||
:selected-container-name="selectedContainerName"
|
||||
:selected-container-ids="detailContainerIds"
|
||||
:clicked-container-ids="selectedContainerIds"
|
||||
:name-map="nameMap"
|
||||
:active-sub-tab="activeSubTab"
|
||||
:loading="detailLoading"
|
||||
:loaded="detailLoaded"
|
||||
:exporting="detailExporting"
|
||||
:errors="detailErrors"
|
||||
:history-rows="historyRows"
|
||||
:association-rows="associationRows"
|
||||
:workcenter-groups="workcenterGroups"
|
||||
:selected-workcenter-groups="selectedWorkcenterGroups"
|
||||
@change-sub-tab="emit('change-sub-tab', $event)"
|
||||
@update-workcenter-groups="emit('update-workcenter-groups', $event)"
|
||||
@export-tab="emit('export-lot-tab', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
95
frontend/src/query-tool/components/QueryBar.vue
Normal file
95
frontend/src/query-tool/components/QueryBar.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import FilterToolbar from '../../shared-ui/components/FilterToolbar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inputType: {
|
||||
type: String,
|
||||
default: 'lot_id',
|
||||
},
|
||||
inputText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputTypeOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
inputLimit: {
|
||||
type: Number,
|
||||
default: 50,
|
||||
},
|
||||
resolving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:inputType', 'update:inputText', 'resolve']);
|
||||
|
||||
const inputCount = computed(() => {
|
||||
return String(props.inputText || '')
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.length;
|
||||
});
|
||||
|
||||
function handleResolve() {
|
||||
emit('resolve');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
|
||||
<FilterToolbar>
|
||||
<label class="flex min-w-[220px] flex-col gap-1 text-xs text-slate-500">
|
||||
<span class="font-medium">查詢類型</span>
|
||||
<select
|
||||
class="h-9 rounded-card border border-stroke-soft bg-white px-3 text-sm text-slate-700 outline-none focus:border-brand-500"
|
||||
:value="inputType"
|
||||
:disabled="resolving"
|
||||
@change="emit('update:inputType', $event.target.value)"
|
||||
>
|
||||
<option
|
||||
v-for="option in inputTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-card bg-brand-500 px-4 text-sm font-medium text-white transition hover:bg-brand-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="resolving"
|
||||
@click="handleResolve"
|
||||
>
|
||||
{{ resolving ? '解析中...' : '解析' }}
|
||||
</button>
|
||||
</template>
|
||||
</FilterToolbar>
|
||||
|
||||
<div class="mt-3">
|
||||
<textarea
|
||||
:value="inputText"
|
||||
class="min-h-28 w-full rounded-card border border-stroke-soft bg-surface-muted/40 px-3 py-2 text-sm text-slate-700 outline-none transition focus:border-brand-500"
|
||||
:placeholder="`可輸入多筆(換行或逗號分隔),最多 ${inputLimit} 筆`"
|
||||
:disabled="resolving"
|
||||
@input="emit('update:inputText', $event.target.value)"
|
||||
/>
|
||||
<div class="mt-2 flex items-center justify-between text-xs">
|
||||
<p class="text-slate-500">已輸入 {{ inputCount }} / {{ inputLimit }}</p>
|
||||
<p v-if="errorMessage" class="text-state-danger">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
368
frontend/src/query-tool/composables/useEquipmentQuery.js
Normal file
368
frontend/src/query-tool/composables/useEquipmentQuery.js
Normal file
@@ -0,0 +1,368 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet, apiPost, ensureMesApiAvailable } from '../../core/api.js';
|
||||
import { exportCsv } from '../utils/csv.js';
|
||||
import { normalizeText, toDateInputValue, uniqueValues } from '../utils/values.js';
|
||||
|
||||
const EQUIPMENT_SUB_TABS = Object.freeze(['lots', 'jobs', 'rejects', 'timeline']);
|
||||
|
||||
function normalizeSubTab(value) {
|
||||
const tab = normalizeText(value).toLowerCase();
|
||||
return EQUIPMENT_SUB_TABS.includes(tab) ? tab : 'lots';
|
||||
}
|
||||
|
||||
function defaultDateRange(days = 30) {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - Number(days || 30));
|
||||
return {
|
||||
startDate: toDateInputValue(start),
|
||||
endDate: toDateInputValue(end),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTabFlags() {
|
||||
return {
|
||||
lots: false,
|
||||
jobs: false,
|
||||
rejects: false,
|
||||
timeline: false,
|
||||
status_hours: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useEquipmentQuery(initial = {}) {
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const equipmentOptions = ref([]);
|
||||
|
||||
const selectedEquipmentIds = ref(uniqueValues(initial.selectedEquipmentIds || []));
|
||||
const activeSubTab = ref(normalizeSubTab(initial.activeSubTab));
|
||||
|
||||
const rangeDefaults = defaultDateRange();
|
||||
const startDate = ref(normalizeText(initial.startDate) || rangeDefaults.startDate);
|
||||
const endDate = ref(normalizeText(initial.endDate) || rangeDefaults.endDate);
|
||||
|
||||
const lotsRows = ref([]);
|
||||
const jobsRows = ref([]);
|
||||
const rejectsRows = ref([]);
|
||||
const statusRows = ref([]);
|
||||
|
||||
const loading = reactive({
|
||||
bootstrapping: false,
|
||||
lots: false,
|
||||
jobs: false,
|
||||
rejects: false,
|
||||
timeline: false,
|
||||
status_hours: false,
|
||||
});
|
||||
|
||||
const errors = reactive({
|
||||
equipmentOptions: '',
|
||||
filters: '',
|
||||
lots: '',
|
||||
jobs: '',
|
||||
rejects: '',
|
||||
timeline: '',
|
||||
status_hours: '',
|
||||
});
|
||||
|
||||
const queried = reactive(emptyTabFlags());
|
||||
const exporting = reactive(emptyTabFlags());
|
||||
|
||||
const selectedEquipmentNames = computed(() => {
|
||||
const selectedSet = new Set(selectedEquipmentIds.value);
|
||||
return equipmentOptions.value
|
||||
.filter((item) => selectedSet.has(String(item.RESOURCEID)))
|
||||
.map((item) => item.RESOURCENAME)
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const equipmentOptionItems = computed(() => {
|
||||
return equipmentOptions.value.map((item) => ({
|
||||
value: String(item.RESOURCEID),
|
||||
label: item.RESOURCENAME ? `${item.RESOURCENAME} (${item.RESOURCEID})` : String(item.RESOURCEID),
|
||||
}));
|
||||
});
|
||||
|
||||
function resetDateRange(days = 30) {
|
||||
const defaults = defaultDateRange(days);
|
||||
startDate.value = defaults.startDate;
|
||||
endDate.value = defaults.endDate;
|
||||
}
|
||||
|
||||
function validateFilters() {
|
||||
if (selectedEquipmentIds.value.length === 0) {
|
||||
return '請選擇至少一台設備';
|
||||
}
|
||||
if (!startDate.value || !endDate.value) {
|
||||
return '請指定日期範圍';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildQueryPayload(queryType) {
|
||||
return {
|
||||
equipment_ids: selectedEquipmentIds.value,
|
||||
equipment_names: selectedEquipmentNames.value,
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
query_type: queryType,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchEquipmentPeriod(queryType) {
|
||||
const validation = validateFilters();
|
||||
if (validation) {
|
||||
throw new Error(validation);
|
||||
}
|
||||
|
||||
const payload = await apiPost(
|
||||
'/api/query-tool/equipment-period',
|
||||
buildQueryPayload(queryType),
|
||||
{ timeout: 120000, silent: true },
|
||||
);
|
||||
|
||||
return Array.isArray(payload?.data) ? payload.data : [];
|
||||
}
|
||||
|
||||
async function loadEquipmentOptions() {
|
||||
loading.bootstrapping = true;
|
||||
errors.equipmentOptions = '';
|
||||
|
||||
try {
|
||||
const payload = await apiGet('/api/query-tool/equipment-list', {
|
||||
timeout: 60000,
|
||||
silent: true,
|
||||
});
|
||||
equipmentOptions.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.equipmentOptions = error?.message || '載入設備清單失敗';
|
||||
equipmentOptions.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.bootstrapping = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryLots() {
|
||||
loading.lots = true;
|
||||
errors.filters = '';
|
||||
errors.lots = '';
|
||||
|
||||
try {
|
||||
lotsRows.value = await fetchEquipmentPeriod('lots');
|
||||
queried.lots = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.lots = error?.message || '查詢生產紀錄失敗';
|
||||
if (!errors.filters) {
|
||||
errors.filters = errors.lots;
|
||||
}
|
||||
lotsRows.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.lots = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryJobs() {
|
||||
loading.jobs = true;
|
||||
errors.filters = '';
|
||||
errors.jobs = '';
|
||||
|
||||
try {
|
||||
jobsRows.value = await fetchEquipmentPeriod('jobs');
|
||||
queried.jobs = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.jobs = error?.message || '查詢維修紀錄失敗';
|
||||
if (!errors.filters) {
|
||||
errors.filters = errors.jobs;
|
||||
}
|
||||
jobsRows.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.jobs = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryRejects() {
|
||||
loading.rejects = true;
|
||||
errors.filters = '';
|
||||
errors.rejects = '';
|
||||
|
||||
try {
|
||||
rejectsRows.value = await fetchEquipmentPeriod('rejects');
|
||||
queried.rejects = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.rejects = error?.message || '查詢報廢紀錄失敗';
|
||||
if (!errors.filters) {
|
||||
errors.filters = errors.rejects;
|
||||
}
|
||||
rejectsRows.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.rejects = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryTimeline() {
|
||||
loading.timeline = true;
|
||||
loading.status_hours = true;
|
||||
errors.filters = '';
|
||||
errors.timeline = '';
|
||||
errors.status_hours = '';
|
||||
|
||||
try {
|
||||
const [statusData, lotsData, jobsData] = await Promise.all([
|
||||
fetchEquipmentPeriod('status_hours'),
|
||||
fetchEquipmentPeriod('lots'),
|
||||
fetchEquipmentPeriod('jobs'),
|
||||
]);
|
||||
|
||||
statusRows.value = statusData;
|
||||
lotsRows.value = lotsData;
|
||||
jobsRows.value = jobsData;
|
||||
|
||||
queried.timeline = true;
|
||||
queried.status_hours = true;
|
||||
queried.lots = true;
|
||||
queried.jobs = true;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error?.message || '查詢設備 Timeline 失敗';
|
||||
errors.timeline = message;
|
||||
errors.status_hours = message;
|
||||
if (!errors.filters) {
|
||||
errors.filters = message;
|
||||
}
|
||||
statusRows.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.timeline = false;
|
||||
loading.status_hours = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryActiveSubTab() {
|
||||
const tab = activeSubTab.value;
|
||||
if (tab === 'lots') {
|
||||
return queryLots();
|
||||
}
|
||||
if (tab === 'jobs') {
|
||||
return queryJobs();
|
||||
}
|
||||
if (tab === 'rejects') {
|
||||
return queryRejects();
|
||||
}
|
||||
return queryTimeline();
|
||||
}
|
||||
|
||||
async function setActiveSubTab(tab, { autoQuery = true } = {}) {
|
||||
activeSubTab.value = normalizeSubTab(tab);
|
||||
if (!autoQuery) {
|
||||
return true;
|
||||
}
|
||||
return queryActiveSubTab();
|
||||
}
|
||||
|
||||
function setSelectedEquipmentIds(ids = []) {
|
||||
selectedEquipmentIds.value = uniqueValues(ids);
|
||||
}
|
||||
|
||||
function canExportSubTab(tab) {
|
||||
const normalized = normalizeSubTab(tab);
|
||||
if (normalized === 'lots') {
|
||||
return lotsRows.value.length > 0;
|
||||
}
|
||||
if (normalized === 'jobs') {
|
||||
return jobsRows.value.length > 0;
|
||||
}
|
||||
if (normalized === 'rejects') {
|
||||
return rejectsRows.value.length > 0;
|
||||
}
|
||||
return statusRows.value.length > 0;
|
||||
}
|
||||
|
||||
async function exportSubTab(tab) {
|
||||
const normalized = normalizeSubTab(tab);
|
||||
|
||||
if (!canExportSubTab(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
exporting[normalized] = true;
|
||||
|
||||
try {
|
||||
let exportType = 'equipment_lots';
|
||||
const params = {
|
||||
equipment_ids: selectedEquipmentIds.value,
|
||||
equipment_names: selectedEquipmentNames.value,
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
};
|
||||
|
||||
if (normalized === 'jobs') {
|
||||
exportType = 'equipment_jobs';
|
||||
} else if (normalized === 'rejects') {
|
||||
exportType = 'equipment_rejects';
|
||||
} else if (normalized === 'timeline') {
|
||||
exportType = 'equipment_status_hours';
|
||||
}
|
||||
|
||||
await exportCsv({
|
||||
exportType,
|
||||
params,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error?.message || '匯出失敗';
|
||||
errors[normalized] = message;
|
||||
return false;
|
||||
} finally {
|
||||
exporting[normalized] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (!startDate.value || !endDate.value) {
|
||||
resetDateRange(30);
|
||||
}
|
||||
return loadEquipmentOptions();
|
||||
}
|
||||
|
||||
return {
|
||||
equipmentSubTabs: EQUIPMENT_SUB_TABS,
|
||||
equipmentOptions,
|
||||
equipmentOptionItems,
|
||||
selectedEquipmentIds,
|
||||
selectedEquipmentNames,
|
||||
startDate,
|
||||
endDate,
|
||||
activeSubTab,
|
||||
lotsRows,
|
||||
jobsRows,
|
||||
rejectsRows,
|
||||
statusRows,
|
||||
loading,
|
||||
errors,
|
||||
queried,
|
||||
exporting,
|
||||
bootstrap,
|
||||
resetDateRange,
|
||||
setSelectedEquipmentIds,
|
||||
setActiveSubTab,
|
||||
queryLots,
|
||||
queryJobs,
|
||||
queryRejects,
|
||||
queryTimeline,
|
||||
queryActiveSubTab,
|
||||
canExportSubTab,
|
||||
exportSubTab,
|
||||
};
|
||||
}
|
||||
511
frontend/src/query-tool/composables/useLotDetail.js
Normal file
511
frontend/src/query-tool/composables/useLotDetail.js
Normal file
@@ -0,0 +1,511 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet, ensureMesApiAvailable } from '../../core/api.js';
|
||||
import { exportCsv } from '../utils/csv.js';
|
||||
import { normalizeText, parseDateTime, uniqueValues, formatDateTime } from '../utils/values.js';
|
||||
|
||||
const LOT_SUB_TABS = Object.freeze([
|
||||
'history',
|
||||
'materials',
|
||||
'rejects',
|
||||
'holds',
|
||||
'splits',
|
||||
'jobs',
|
||||
]);
|
||||
|
||||
const ASSOCIATION_TABS = new Set(['materials', 'rejects', 'holds', 'splits', 'jobs']);
|
||||
|
||||
const EXPORT_TYPE_MAP = Object.freeze({
|
||||
history: 'lot_history',
|
||||
materials: 'lot_materials',
|
||||
rejects: 'lot_rejects',
|
||||
holds: 'lot_holds',
|
||||
splits: 'lot_splits',
|
||||
jobs: 'lot_jobs',
|
||||
});
|
||||
|
||||
function emptyTabFlags() {
|
||||
return {
|
||||
history: false,
|
||||
materials: false,
|
||||
rejects: false,
|
||||
holds: false,
|
||||
splits: false,
|
||||
jobs: false,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTabErrors() {
|
||||
return {
|
||||
workcenterGroups: '',
|
||||
history: '',
|
||||
materials: '',
|
||||
rejects: '',
|
||||
holds: '',
|
||||
splits: '',
|
||||
jobs: '',
|
||||
};
|
||||
}
|
||||
|
||||
function emptyAssociations() {
|
||||
return {
|
||||
materials: [],
|
||||
rejects: [],
|
||||
holds: [],
|
||||
splits: [],
|
||||
jobs: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSubTab(value) {
|
||||
const tab = normalizeText(value).toLowerCase();
|
||||
return LOT_SUB_TABS.includes(tab) ? tab : 'history';
|
||||
}
|
||||
|
||||
function flattenSplitPayload(payload) {
|
||||
if (Array.isArray(payload?.data)) {
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
const productionHistory = Array.isArray(payload?.production_history)
|
||||
? payload.production_history.map((item) => ({
|
||||
RECORD_TYPE: 'PRODUCTION_HISTORY',
|
||||
...item,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const serialRows = Array.isArray(payload?.serial_numbers)
|
||||
? payload.serial_numbers.flatMap((item) => {
|
||||
const serialNumber = item?.serial_number || '';
|
||||
const totalGoodDie = item?.total_good_die || null;
|
||||
const lots = Array.isArray(item?.lots) ? item.lots : [];
|
||||
|
||||
return lots.map((lot) => ({
|
||||
RECORD_TYPE: 'SERIAL_MAPPING',
|
||||
SERIAL_NUMBER: serialNumber,
|
||||
TOTAL_GOOD_DIE: totalGoodDie,
|
||||
LOT_ID: lot?.lot_id || '',
|
||||
WORK_ORDER: lot?.work_order || '',
|
||||
COMBINE_RATIO: lot?.combine_ratio,
|
||||
COMBINE_RATIO_PCT: lot?.combine_ratio_pct || '',
|
||||
GOOD_DIE_QTY: lot?.good_die_qty,
|
||||
ORIGINAL_START_DATE: lot?.original_start_date,
|
||||
}));
|
||||
})
|
||||
: [];
|
||||
|
||||
return [...productionHistory, ...serialRows];
|
||||
}
|
||||
|
||||
function resolveTimeRangeFromHistory(rows) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let minTrackIn = null;
|
||||
let maxTrackOut = null;
|
||||
|
||||
rows.forEach((row) => {
|
||||
const trackIn = parseDateTime(row?.TRACKINTIMESTAMP || row?.TRACKINTIME);
|
||||
const trackOut = parseDateTime(row?.TRACKOUTTIMESTAMP || row?.TRACKOUTTIME);
|
||||
|
||||
if (trackIn && (!minTrackIn || trackIn < minTrackIn)) {
|
||||
minTrackIn = trackIn;
|
||||
}
|
||||
|
||||
if (trackOut && (!maxTrackOut || trackOut > maxTrackOut)) {
|
||||
maxTrackOut = trackOut;
|
||||
}
|
||||
|
||||
if (!maxTrackOut && trackIn && (!maxTrackOut || trackIn > maxTrackOut)) {
|
||||
maxTrackOut = trackIn;
|
||||
}
|
||||
});
|
||||
|
||||
if (!minTrackIn || !maxTrackOut) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
time_start: formatDateTime(minTrackIn),
|
||||
time_end: formatDateTime(maxTrackOut),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEquipmentIdFromHistory(rows) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const equipmentId = normalizeText(row?.EQUIPMENTID || row?.RESOURCEID);
|
||||
if (equipmentId) {
|
||||
return equipmentId;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function useLotDetail(initial = {}) {
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
|
||||
const selectedContainerIds = ref(
|
||||
initial.selectedContainerId ? [normalizeText(initial.selectedContainerId)] : [],
|
||||
);
|
||||
const activeSubTab = ref(normalizeSubTab(initial.activeSubTab));
|
||||
|
||||
const workcenterGroups = ref([]);
|
||||
const selectedWorkcenterGroups = ref(uniqueValues(initial.workcenterGroups || []));
|
||||
|
||||
const historyRows = ref([]);
|
||||
const associationRows = reactive(emptyAssociations());
|
||||
|
||||
const loading = reactive({
|
||||
workcenterGroups: false,
|
||||
history: false,
|
||||
materials: false,
|
||||
rejects: false,
|
||||
holds: false,
|
||||
splits: false,
|
||||
jobs: false,
|
||||
});
|
||||
|
||||
const loaded = reactive(emptyTabFlags());
|
||||
const exporting = reactive(emptyTabFlags());
|
||||
const errors = reactive(emptyTabErrors());
|
||||
|
||||
function clearTabData() {
|
||||
historyRows.value = [];
|
||||
const nextAssociations = emptyAssociations();
|
||||
Object.keys(nextAssociations).forEach((key) => {
|
||||
associationRows[key] = nextAssociations[key];
|
||||
});
|
||||
|
||||
const nextLoaded = emptyTabFlags();
|
||||
Object.keys(nextLoaded).forEach((key) => {
|
||||
loaded[key] = nextLoaded[key];
|
||||
exporting[key] = false;
|
||||
errors[key] = '';
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveCids() {
|
||||
if (selectedContainerIds.value.length > 0) {
|
||||
return selectedContainerIds.value;
|
||||
}
|
||||
const single = selectedContainerId.value;
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
async function loadWorkcenterGroups() {
|
||||
loading.workcenterGroups = true;
|
||||
errors.workcenterGroups = '';
|
||||
|
||||
try {
|
||||
const payload = await apiGet('/api/query-tool/workcenter-groups', {
|
||||
timeout: 60000,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
workcenterGroups.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.workcenterGroups = error?.message || '載入站點群組失敗';
|
||||
workcenterGroups.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.workcenterGroups = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory({ force = false } = {}) {
|
||||
const cids = getActiveCids();
|
||||
if (cids.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!force && loaded.history) {
|
||||
return true;
|
||||
}
|
||||
|
||||
loading.history = true;
|
||||
errors.history = '';
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
cids.map((cid) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('container_id', cid);
|
||||
if (selectedWorkcenterGroups.value.length > 0) {
|
||||
params.set('workcenter_groups', selectedWorkcenterGroups.value.join(','));
|
||||
}
|
||||
return apiGet(`/api/query-tool/lot-history?${params.toString()}`, {
|
||||
timeout: 60000,
|
||||
silent: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const allRows = [];
|
||||
const failedCids = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
const rows = Array.isArray(result.value?.data) ? result.value.data : [];
|
||||
allRows.push(...rows);
|
||||
} else {
|
||||
failedCids.push(cids[index]);
|
||||
}
|
||||
});
|
||||
|
||||
historyRows.value = allRows;
|
||||
loaded.history = true;
|
||||
|
||||
if (failedCids.length > 0) {
|
||||
errors.history = `部分節點歷程載入失敗:${failedCids.join(', ')}`;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.history = error?.message || '載入 LOT 歷程失敗';
|
||||
historyRows.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.history = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssociation(tab, { force = false, silentError = false } = {}) {
|
||||
const associationType = normalizeSubTab(tab);
|
||||
if (!ASSOCIATION_TABS.has(associationType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cids = getActiveCids();
|
||||
if (cids.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!force && loaded[associationType]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
loading[associationType] = true;
|
||||
if (!silentError) {
|
||||
errors[associationType] = '';
|
||||
}
|
||||
|
||||
try {
|
||||
if (associationType === 'jobs') {
|
||||
// Jobs derive equipment/time from merged history — use first CID as anchor
|
||||
if (historyRows.value.length === 0) {
|
||||
await loadHistory();
|
||||
}
|
||||
|
||||
const equipmentId = resolveEquipmentIdFromHistory(historyRows.value);
|
||||
const timeRange = resolveTimeRangeFromHistory(historyRows.value);
|
||||
|
||||
if (!equipmentId || !timeRange?.time_start || !timeRange?.time_end) {
|
||||
throw new Error('無法從 LOT 歷程推導 JOB 查詢條件,請先確認歷程資料');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('container_id', cids[0]);
|
||||
params.set('type', associationType);
|
||||
params.set('equipment_id', equipmentId);
|
||||
params.set('time_start', timeRange.time_start);
|
||||
params.set('time_end', timeRange.time_end);
|
||||
|
||||
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
|
||||
timeout: 120000,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
associationRows[associationType] = Array.isArray(payload?.data) ? payload.data : [];
|
||||
} else {
|
||||
// Non-jobs tabs: load in parallel for all selected CIDs
|
||||
const results = await Promise.allSettled(
|
||||
cids.map((cid) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('container_id', cid);
|
||||
params.set('type', associationType);
|
||||
return apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
|
||||
timeout: 120000,
|
||||
silent: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const allRows = [];
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
const rows = associationType === 'splits'
|
||||
? flattenSplitPayload(result.value)
|
||||
: (Array.isArray(result.value?.data) ? result.value.data : []);
|
||||
allRows.push(...rows);
|
||||
}
|
||||
});
|
||||
associationRows[associationType] = allRows;
|
||||
}
|
||||
|
||||
loaded[associationType] = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
associationRows[associationType] = [];
|
||||
if (!silentError) {
|
||||
errors[associationType] = error?.message || '載入關聯資料失敗';
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
loading[associationType] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureActiveSubTabData() {
|
||||
if (activeSubTab.value === 'history') {
|
||||
const historyOk = await loadHistory();
|
||||
if (historyOk) {
|
||||
// History timeline uses hold/material events as marker sources.
|
||||
await Promise.allSettled([
|
||||
loadAssociation('holds', { silentError: true }),
|
||||
loadAssociation('materials', { silentError: true }),
|
||||
]);
|
||||
}
|
||||
return historyOk;
|
||||
}
|
||||
|
||||
return loadAssociation(activeSubTab.value);
|
||||
}
|
||||
|
||||
async function setActiveSubTab(tab) {
|
||||
activeSubTab.value = normalizeSubTab(tab);
|
||||
return ensureActiveSubTabData();
|
||||
}
|
||||
|
||||
async function setSelectedContainerId(containerId) {
|
||||
const nextId = normalizeText(containerId);
|
||||
selectedContainerIds.value = nextId ? [nextId] : [];
|
||||
|
||||
if (nextId === selectedContainerId.value) {
|
||||
return ensureActiveSubTabData();
|
||||
}
|
||||
|
||||
selectedContainerId.value = nextId;
|
||||
clearTabData();
|
||||
|
||||
if (!nextId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ensureActiveSubTabData();
|
||||
}
|
||||
|
||||
async function setSelectedContainerIds(cids) {
|
||||
const normalized = uniqueValues(
|
||||
(Array.isArray(cids) ? cids : []).map(normalizeText).filter(Boolean),
|
||||
);
|
||||
selectedContainerIds.value = normalized;
|
||||
selectedContainerId.value = normalized[0] || '';
|
||||
clearTabData();
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ensureActiveSubTabData();
|
||||
}
|
||||
|
||||
async function setSelectedWorkcenterGroups(groups) {
|
||||
selectedWorkcenterGroups.value = uniqueValues(groups || []);
|
||||
|
||||
if (!selectedContainerId.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
loaded.history = false;
|
||||
return loadHistory({ force: true });
|
||||
}
|
||||
|
||||
function getRowsByTab(tab) {
|
||||
const normalized = normalizeSubTab(tab);
|
||||
if (normalized === 'history') {
|
||||
return historyRows.value;
|
||||
}
|
||||
if (!ASSOCIATION_TABS.has(normalized)) {
|
||||
return [];
|
||||
}
|
||||
return associationRows[normalized] || [];
|
||||
}
|
||||
|
||||
async function exportSubTab(tab) {
|
||||
const normalized = normalizeSubTab(tab);
|
||||
const exportType = EXPORT_TYPE_MAP[normalized];
|
||||
const containerId = selectedContainerId.value;
|
||||
|
||||
if (!exportType || !containerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
exporting[normalized] = true;
|
||||
errors[normalized] = '';
|
||||
|
||||
try {
|
||||
const params = {
|
||||
container_id: containerId,
|
||||
};
|
||||
|
||||
if (normalized === 'jobs') {
|
||||
if (historyRows.value.length === 0) {
|
||||
await loadHistory();
|
||||
}
|
||||
const equipmentId = resolveEquipmentIdFromHistory(historyRows.value);
|
||||
const timeRange = resolveTimeRangeFromHistory(historyRows.value);
|
||||
if (!equipmentId || !timeRange?.time_start || !timeRange?.time_end) {
|
||||
throw new Error('無法取得 JOB 匯出所需條件');
|
||||
}
|
||||
params.equipment_id = equipmentId;
|
||||
params.time_start = timeRange.time_start;
|
||||
params.time_end = timeRange.time_end;
|
||||
}
|
||||
|
||||
await exportCsv({
|
||||
exportType,
|
||||
params,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors[normalized] = error?.message || '匯出失敗';
|
||||
return false;
|
||||
} finally {
|
||||
exporting[normalized] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lotSubTabs: LOT_SUB_TABS,
|
||||
selectedContainerId,
|
||||
selectedContainerIds,
|
||||
activeSubTab,
|
||||
workcenterGroups,
|
||||
selectedWorkcenterGroups,
|
||||
historyRows,
|
||||
associationRows,
|
||||
loading,
|
||||
loaded,
|
||||
exporting,
|
||||
errors,
|
||||
loadWorkcenterGroups,
|
||||
loadHistory,
|
||||
loadAssociation,
|
||||
ensureActiveSubTabData,
|
||||
setActiveSubTab,
|
||||
setSelectedContainerId,
|
||||
setSelectedContainerIds,
|
||||
setSelectedWorkcenterGroups,
|
||||
getRowsByTab,
|
||||
exportSubTab,
|
||||
clearTabData,
|
||||
};
|
||||
}
|
||||
493
frontend/src/query-tool/composables/useLotLineage.js
Normal file
493
frontend/src/query-tool/composables/useLotLineage.js
Normal file
@@ -0,0 +1,493 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiPost, ensureMesApiAvailable } from '../../core/api.js';
|
||||
import { normalizeText, uniqueValues } from '../utils/values.js';
|
||||
|
||||
const MAX_CONCURRENCY = 3;
|
||||
const MAX_429_RETRY = 3;
|
||||
|
||||
function emptyLineageEntry() {
|
||||
return {
|
||||
children: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
fetched: false,
|
||||
lastUpdatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function extractContainerId(row) {
|
||||
if (!row || typeof row !== 'object') {
|
||||
return '';
|
||||
}
|
||||
return normalizeText(row.container_id || row.CONTAINERID || row.containerId);
|
||||
}
|
||||
|
||||
function createSemaphore(maxConcurrency) {
|
||||
const queue = [];
|
||||
let active = 0;
|
||||
|
||||
function pump() {
|
||||
if (active >= maxConcurrency || queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = queue.shift();
|
||||
active += 1;
|
||||
|
||||
Promise.resolve()
|
||||
.then(item.task)
|
||||
.then(item.resolve)
|
||||
.catch(item.reject)
|
||||
.finally(() => {
|
||||
active = Math.max(0, active - 1);
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
schedule(task) {
|
||||
return new Promise((resolve, reject) => {
|
||||
queue.push({ task, resolve, reject });
|
||||
pump();
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
queue.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function useLotLineage(initial = {}) {
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const lineageMap = reactive(new Map());
|
||||
const nameMap = reactive(new Map());
|
||||
const leafSerials = reactive(new Map());
|
||||
const expandedNodes = ref(new Set());
|
||||
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
|
||||
const selectedContainerIds = ref(
|
||||
initial.selectedContainerId ? [normalizeText(initial.selectedContainerId)] : [],
|
||||
);
|
||||
const rootRows = ref([]);
|
||||
const rootContainerIds = ref([]);
|
||||
const treeRoots = ref([]);
|
||||
|
||||
const inFlight = new Map();
|
||||
const semaphore = createSemaphore(MAX_CONCURRENCY);
|
||||
let generation = 0;
|
||||
|
||||
function ensureEntry(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!lineageMap.has(id)) {
|
||||
lineageMap.set(id, emptyLineageEntry());
|
||||
}
|
||||
|
||||
return lineageMap.get(id);
|
||||
}
|
||||
|
||||
function patchEntry(containerId, patch) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = ensureEntry(id) || emptyLineageEntry();
|
||||
lineageMap.set(id, {
|
||||
...previous,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
function getEntry(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return emptyLineageEntry();
|
||||
}
|
||||
return lineageMap.get(id) || emptyLineageEntry();
|
||||
}
|
||||
|
||||
function getChildren(containerId) {
|
||||
const entry = getEntry(containerId);
|
||||
return Array.isArray(entry.children) ? entry.children : [];
|
||||
}
|
||||
|
||||
function getSerials(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
return id ? (leafSerials.get(id) || []) : [];
|
||||
}
|
||||
|
||||
function getSubtreeCids(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return [];
|
||||
}
|
||||
const result = [];
|
||||
const visited = new Set();
|
||||
function walk(nodeId) {
|
||||
if (!nodeId || visited.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
visited.add(nodeId);
|
||||
result.push(nodeId);
|
||||
getChildren(nodeId).forEach(walk);
|
||||
}
|
||||
walk(id);
|
||||
return result;
|
||||
}
|
||||
|
||||
function isExpanded(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
return id ? expandedNodes.value.has(id) : false;
|
||||
}
|
||||
|
||||
function isSelected(containerId) {
|
||||
return normalizeText(containerId) === selectedContainerId.value;
|
||||
}
|
||||
|
||||
function selectNode(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
selectedContainerId.value = id;
|
||||
if (id && !selectedContainerIds.value.includes(id)) {
|
||||
selectedContainerIds.value = [id];
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedNodes(cids) {
|
||||
const normalized = (Array.isArray(cids) ? cids : [])
|
||||
.map(normalizeText)
|
||||
.filter(Boolean);
|
||||
selectedContainerIds.value = uniqueValues(normalized);
|
||||
selectedContainerId.value = normalized[0] || '';
|
||||
}
|
||||
|
||||
const lineageLoading = computed(() => {
|
||||
for (const entry of lineageMap.values()) {
|
||||
if (entry.loading) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function collapseAll() {
|
||||
expandedNodes.value = new Set();
|
||||
}
|
||||
|
||||
async function requestLineageWithRetry(containerIds) {
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt <= MAX_429_RETRY) {
|
||||
try {
|
||||
return await apiPost(
|
||||
'/api/trace/lineage',
|
||||
{
|
||||
profile: 'query_tool',
|
||||
container_ids: containerIds,
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
);
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status !== 429 || attempt >= MAX_429_RETRY) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const retryAfter = Number(error?.retryAfterSeconds || 0);
|
||||
const fallbackSeconds = 2 ** attempt;
|
||||
const waitSeconds = Math.max(1, Math.min(30, retryAfter || fallbackSeconds));
|
||||
await sleep(waitSeconds * 1000);
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function populateForwardTree(payload) {
|
||||
const childrenMapData = payload?.children_map;
|
||||
const rootsList = payload?.roots || [];
|
||||
const serialsData = payload?.leaf_serials || {};
|
||||
const names = payload?.names;
|
||||
|
||||
// Merge name mapping
|
||||
if (names && typeof names === 'object') {
|
||||
Object.entries(names).forEach(([cid, name]) => {
|
||||
if (cid && name) {
|
||||
nameMap.set(normalizeText(cid), String(name));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Store leaf serial numbers
|
||||
Object.entries(serialsData).forEach(([cid, serials]) => {
|
||||
const id = normalizeText(cid);
|
||||
if (id && Array.isArray(serials)) {
|
||||
leafSerials.set(id, serials);
|
||||
}
|
||||
});
|
||||
|
||||
// Populate children_map for all nodes
|
||||
if (childrenMapData && typeof childrenMapData === 'object') {
|
||||
// First pass: set children for all parent nodes
|
||||
Object.entries(childrenMapData).forEach(([parentId, childIds]) => {
|
||||
const normalized = normalizeText(parentId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
patchEntry(normalized, {
|
||||
children: uniqueValues(childIds || []),
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: '',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
// Second pass: mark leaf nodes (appear as children but not as parents)
|
||||
const allChildCids = new Set();
|
||||
Object.values(childrenMapData).forEach((childIds) => {
|
||||
(childIds || []).forEach((c) => {
|
||||
const nc = normalizeText(c);
|
||||
if (nc) {
|
||||
allChildCids.add(nc);
|
||||
}
|
||||
});
|
||||
});
|
||||
allChildCids.forEach((childCid) => {
|
||||
if (!childrenMapData[childCid] && !getEntry(childCid).fetched) {
|
||||
patchEntry(childCid, {
|
||||
children: [],
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: '',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Also mark roots as fetched
|
||||
rootsList.forEach((rootCid) => {
|
||||
const id = normalizeText(rootCid);
|
||||
if (id && !getEntry(id).fetched) {
|
||||
patchEntry(id, {
|
||||
children: uniqueValues(childrenMapData[rootCid] || []),
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: '',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update tree roots
|
||||
treeRoots.value = rootsList.map((r) => normalizeText(r)).filter(Boolean);
|
||||
}
|
||||
|
||||
async function fetchLineage(containerIds, { force = false } = {}) {
|
||||
const ids = (Array.isArray(containerIds) ? containerIds : [containerIds])
|
||||
.map((c) => normalizeText(c))
|
||||
.filter(Boolean);
|
||||
|
||||
if (ids.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if all already fetched (for single-node requests)
|
||||
if (!force && ids.length === 1) {
|
||||
const existing = getEntry(ids[0]);
|
||||
if (existing.fetched) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const cacheKey = [...ids].sort().join(',');
|
||||
if (inFlight.has(cacheKey)) {
|
||||
return inFlight.get(cacheKey);
|
||||
}
|
||||
|
||||
const runGeneration = generation;
|
||||
ids.forEach((id) => {
|
||||
patchEntry(id, { loading: true, error: '' });
|
||||
});
|
||||
|
||||
const promise = semaphore
|
||||
.schedule(async () => {
|
||||
try {
|
||||
const payload = await requestLineageWithRetry(ids);
|
||||
if (runGeneration !== generation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
populateForwardTree(payload);
|
||||
return ids.length === 1 ? getEntry(ids[0]) : null;
|
||||
} catch (error) {
|
||||
if (runGeneration !== generation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ids.forEach((id) => {
|
||||
patchEntry(id, {
|
||||
children: [],
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: error?.message || '血緣查詢失敗',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
return ids.length === 1 ? getEntry(ids[0]) : null;
|
||||
} finally {
|
||||
inFlight.delete(cacheKey);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
inFlight.delete(cacheKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
inFlight.set(cacheKey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function expandNode(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Set(expandedNodes.value);
|
||||
next.add(id);
|
||||
expandedNodes.value = next;
|
||||
}
|
||||
|
||||
function collapseNode(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Set(expandedNodes.value);
|
||||
next.delete(id);
|
||||
expandedNodes.value = next;
|
||||
}
|
||||
|
||||
function toggleNode(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expandedNodes.value.has(id)) {
|
||||
collapseNode(id);
|
||||
return;
|
||||
}
|
||||
|
||||
expandNode(id);
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
const expanded = new Set();
|
||||
|
||||
function walk(nodeId) {
|
||||
const id = normalizeText(nodeId);
|
||||
if (!id || expanded.has(id)) {
|
||||
return;
|
||||
}
|
||||
const entry = getEntry(id);
|
||||
if (entry.children && entry.children.length > 0) {
|
||||
expanded.add(id);
|
||||
entry.children.forEach((childId) => walk(childId));
|
||||
}
|
||||
}
|
||||
|
||||
treeRoots.value.forEach((rootId) => walk(rootId));
|
||||
expandedNodes.value = expanded;
|
||||
}
|
||||
|
||||
function resetLineageState() {
|
||||
generation += 1;
|
||||
semaphore.clear();
|
||||
inFlight.clear();
|
||||
lineageMap.clear();
|
||||
nameMap.clear();
|
||||
leafSerials.clear();
|
||||
expandedNodes.value = new Set();
|
||||
selectedContainerIds.value = [];
|
||||
treeRoots.value = [];
|
||||
}
|
||||
|
||||
async function primeResolvedLots(lots = []) {
|
||||
resetLineageState();
|
||||
|
||||
rootRows.value = Array.isArray(lots) ? [...lots] : [];
|
||||
rootContainerIds.value = rootRows.value
|
||||
.map((row) => extractContainerId(row))
|
||||
.filter(Boolean);
|
||||
|
||||
// Seed name map from resolve data
|
||||
rootRows.value.forEach((row) => {
|
||||
const cid = extractContainerId(row);
|
||||
const name = normalizeText(row?.lot_id || row?.CONTAINERNAME || row?.input_value);
|
||||
if (cid && name) {
|
||||
nameMap.set(cid, name);
|
||||
}
|
||||
});
|
||||
|
||||
if (rootContainerIds.value.length > 0 && !selectedContainerId.value) {
|
||||
selectedContainerId.value = rootContainerIds.value[0];
|
||||
}
|
||||
|
||||
rootContainerIds.value.forEach((containerId) => {
|
||||
patchEntry(containerId, { loading: true });
|
||||
});
|
||||
|
||||
// Send all seed CIDs in a single request for forward tree
|
||||
await fetchLineage(rootContainerIds.value);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedContainerId.value = '';
|
||||
selectedContainerIds.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
lineageMap,
|
||||
nameMap,
|
||||
leafSerials,
|
||||
expandedNodes,
|
||||
selectedContainerId,
|
||||
selectedContainerIds,
|
||||
lineageLoading,
|
||||
rootRows,
|
||||
rootContainerIds,
|
||||
treeRoots,
|
||||
getEntry,
|
||||
getChildren,
|
||||
getSerials,
|
||||
getSubtreeCids,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
selectNode,
|
||||
setSelectedNodes,
|
||||
fetchLineage,
|
||||
expandNode,
|
||||
collapseNode,
|
||||
toggleNode,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
primeResolvedLots,
|
||||
resetLineageState,
|
||||
clearSelection,
|
||||
extractContainerId,
|
||||
};
|
||||
}
|
||||
152
frontend/src/query-tool/composables/useLotResolve.js
Normal file
152
frontend/src/query-tool/composables/useLotResolve.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiPost, ensureMesApiAvailable } from '../../core/api.js';
|
||||
import { parseInputValues } from '../utils/values.js';
|
||||
|
||||
const INPUT_TYPE_OPTIONS = Object.freeze([
|
||||
{ value: 'lot_id', label: 'LOT ID' },
|
||||
{ value: 'serial_number', label: '流水號' },
|
||||
{ value: 'work_order', label: '工單' },
|
||||
]);
|
||||
|
||||
const INPUT_LIMITS = Object.freeze({
|
||||
lot_id: 50,
|
||||
serial_number: 50,
|
||||
work_order: 10,
|
||||
});
|
||||
|
||||
function normalizeInputType(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (INPUT_LIMITS[text]) {
|
||||
return text;
|
||||
}
|
||||
return 'lot_id';
|
||||
}
|
||||
|
||||
export function useLotResolve(initial = {}) {
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const inputType = ref(normalizeInputType(initial.inputType));
|
||||
const inputText = ref(String(initial.inputText || ''));
|
||||
|
||||
const resolvedLots = ref([]);
|
||||
const notFound = ref([]);
|
||||
const expansionInfo = ref({});
|
||||
|
||||
const errorMessage = ref('');
|
||||
const successMessage = ref('');
|
||||
|
||||
const loading = reactive({
|
||||
resolving: false,
|
||||
});
|
||||
|
||||
const inputTypeOptions = INPUT_TYPE_OPTIONS;
|
||||
const inputValues = computed(() => parseInputValues(inputText.value));
|
||||
const inputLimit = computed(() => INPUT_LIMITS[inputType.value] || 50);
|
||||
|
||||
function clearMessages() {
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '';
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
resolvedLots.value = [];
|
||||
notFound.value = [];
|
||||
expansionInfo.value = {};
|
||||
}
|
||||
|
||||
function reset() {
|
||||
inputText.value = '';
|
||||
clearMessages();
|
||||
clearResults();
|
||||
}
|
||||
|
||||
function setInputType(nextType) {
|
||||
inputType.value = normalizeInputType(nextType);
|
||||
}
|
||||
|
||||
function setInputText(text) {
|
||||
inputText.value = String(text || '');
|
||||
}
|
||||
|
||||
function validateInput(values) {
|
||||
if (values.length === 0) {
|
||||
return '請輸入 LOT/流水號/工單條件';
|
||||
}
|
||||
|
||||
const limit = INPUT_LIMITS[inputType.value] || 50;
|
||||
if (values.length > limit) {
|
||||
return `輸入數量超過上限 (${limit} 筆)`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function resolveLots() {
|
||||
const values = inputValues.value;
|
||||
const validationMessage = validateInput(values);
|
||||
|
||||
if (validationMessage) {
|
||||
errorMessage.value = validationMessage;
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'validation',
|
||||
};
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
clearResults();
|
||||
loading.resolving = true;
|
||||
|
||||
try {
|
||||
const payload = await apiPost(
|
||||
'/api/query-tool/resolve',
|
||||
{
|
||||
input_type: inputType.value,
|
||||
values,
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
);
|
||||
|
||||
resolvedLots.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
notFound.value = Array.isArray(payload?.not_found) ? payload.not_found : [];
|
||||
expansionInfo.value = payload?.expansion_info || {};
|
||||
|
||||
successMessage.value = `解析完成:${resolvedLots.value.length} 筆,未命中 ${notFound.value.length} 筆`;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
resolvedLots: resolvedLots.value,
|
||||
notFound: notFound.value,
|
||||
};
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '解析失敗';
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'request',
|
||||
};
|
||||
} finally {
|
||||
loading.resolving = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inputType,
|
||||
inputText,
|
||||
inputTypeOptions,
|
||||
inputValues,
|
||||
inputLimit,
|
||||
resolvedLots,
|
||||
notFound,
|
||||
expansionInfo,
|
||||
loading,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
clearMessages,
|
||||
clearResults,
|
||||
reset,
|
||||
setInputType,
|
||||
setInputText,
|
||||
resolveLots,
|
||||
};
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet, apiPost, ensureMesApiAvailable } from '../../core/api.js';
|
||||
import { replaceRuntimeHistory } from '../../core/shell-navigation.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
function toDateString(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function parseArrayQuery(params, key) {
|
||||
const repeated = params.getAll(key).map((item) => String(item || '').trim()).filter(Boolean);
|
||||
if (repeated.length > 0) {
|
||||
return repeated;
|
||||
}
|
||||
return String(params.get(key) || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildBatchQueryString(state) {
|
||||
const params = new URLSearchParams();
|
||||
if (state.inputType) params.set('input_type', state.inputType);
|
||||
if (state.selectedContainerId) params.set('container_id', state.selectedContainerId);
|
||||
if (state.associationType) params.set('association_type', state.associationType);
|
||||
state.selectedWorkcenterGroups.forEach((item) => params.append('workcenter_groups', item));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function buildEquipmentQueryString(state) {
|
||||
const params = new URLSearchParams();
|
||||
state.selectedEquipmentIds.forEach((item) => params.append('equipment_ids', item));
|
||||
if (state.startDate) params.set('start_date', state.startDate);
|
||||
if (state.endDate) params.set('end_date', state.endDate);
|
||||
if (state.equipmentQueryType) params.set('query_type', state.equipmentQueryType);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function mapEquipmentExportType(queryType) {
|
||||
const normalized = String(queryType || '').trim();
|
||||
const mapping = {
|
||||
status_hours: 'equipment_status_hours',
|
||||
lots: 'equipment_lots',
|
||||
materials: 'equipment_materials',
|
||||
rejects: 'equipment_rejects',
|
||||
jobs: 'equipment_jobs',
|
||||
};
|
||||
return mapping[normalized] || null;
|
||||
}
|
||||
|
||||
function mapAssociationExportType(associationType) {
|
||||
const normalized = String(associationType || '').trim();
|
||||
const mapping = {
|
||||
materials: 'lot_materials',
|
||||
rejects: 'lot_rejects',
|
||||
holds: 'lot_holds',
|
||||
splits: 'lot_splits',
|
||||
jobs: 'lot_jobs',
|
||||
};
|
||||
return mapping[normalized] || null;
|
||||
}
|
||||
|
||||
export function useQueryToolData() {
|
||||
const loading = reactive({
|
||||
resolving: false,
|
||||
history: false,
|
||||
association: false,
|
||||
equipment: false,
|
||||
exporting: false,
|
||||
bootstrapping: false,
|
||||
});
|
||||
|
||||
const errorMessage = ref('');
|
||||
const successMessage = ref('');
|
||||
|
||||
const batch = reactive({
|
||||
inputType: 'lot_id',
|
||||
inputText: '',
|
||||
resolvedLots: [],
|
||||
selectedContainerId: '',
|
||||
selectedWorkcenterGroups: [],
|
||||
workcenterGroups: [],
|
||||
lotHistoryRows: [],
|
||||
associationType: 'materials',
|
||||
associationRows: [],
|
||||
lineageCache: {},
|
||||
});
|
||||
|
||||
const equipment = reactive({
|
||||
options: [],
|
||||
selectedEquipmentIds: [],
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
equipmentQueryType: 'status_hours',
|
||||
rows: [],
|
||||
});
|
||||
|
||||
const resolvedColumns = computed(() => Object.keys(batch.resolvedLots[0] || {}));
|
||||
const historyColumns = computed(() => Object.keys(batch.lotHistoryRows[0] || {}));
|
||||
const associationColumns = computed(() => Object.keys(batch.associationRows[0] || {}));
|
||||
const equipmentColumns = computed(() => Object.keys(equipment.rows[0] || {}));
|
||||
|
||||
const selectedEquipmentNames = computed(() => {
|
||||
const selectedSet = new Set(equipment.selectedEquipmentIds);
|
||||
return equipment.options
|
||||
.filter((item) => selectedSet.has(item.RESOURCEID))
|
||||
.map((item) => item.RESOURCENAME)
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
function hydrateFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
batch.inputType = String(params.get('input_type') || 'lot_id').trim() || 'lot_id';
|
||||
batch.selectedContainerId = String(params.get('container_id') || '').trim();
|
||||
batch.associationType = String(params.get('association_type') || 'materials').trim() || 'materials';
|
||||
batch.selectedWorkcenterGroups = parseArrayQuery(params, 'workcenter_groups');
|
||||
|
||||
equipment.selectedEquipmentIds = parseArrayQuery(params, 'equipment_ids');
|
||||
equipment.startDate = String(params.get('start_date') || '').trim();
|
||||
equipment.endDate = String(params.get('end_date') || '').trim();
|
||||
equipment.equipmentQueryType = String(params.get('query_type') || 'status_hours').trim() || 'status_hours';
|
||||
}
|
||||
|
||||
function resetEquipmentDateRange() {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 30);
|
||||
equipment.startDate = toDateString(start);
|
||||
equipment.endDate = toDateString(end);
|
||||
}
|
||||
|
||||
function syncBatchUrlState() {
|
||||
const query = buildBatchQueryString(batch);
|
||||
replaceRuntimeHistory(query ? `/query-tool?${query}` : '/query-tool');
|
||||
}
|
||||
|
||||
function syncEquipmentUrlState() {
|
||||
const query = buildEquipmentQueryString(equipment);
|
||||
replaceRuntimeHistory(query ? `/query-tool?${query}` : '/query-tool');
|
||||
}
|
||||
|
||||
function parseBatchInputValues() {
|
||||
return String(batch.inputText || '')
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function loadEquipmentOptions() {
|
||||
try {
|
||||
const payload = await apiGet('/api/query-tool/equipment-list', { timeout: 60000, silent: true });
|
||||
equipment.options = Array.isArray(payload?.data) ? payload.data : [];
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '載入設備選單失敗';
|
||||
equipment.options = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkcenterGroups() {
|
||||
try {
|
||||
const payload = await apiGet('/api/query-tool/workcenter-groups', { timeout: 60000, silent: true });
|
||||
batch.workcenterGroups = Array.isArray(payload?.data) ? payload.data : [];
|
||||
} catch {
|
||||
batch.workcenterGroups = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
loading.bootstrapping = true;
|
||||
errorMessage.value = '';
|
||||
await Promise.all([loadEquipmentOptions(), loadWorkcenterGroups()]);
|
||||
loading.bootstrapping = false;
|
||||
}
|
||||
|
||||
async function resolveLots() {
|
||||
const values = parseBatchInputValues();
|
||||
if (values.length === 0) {
|
||||
errorMessage.value = '請輸入 LOT/流水號/工單條件';
|
||||
return false;
|
||||
}
|
||||
loading.resolving = true;
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '';
|
||||
batch.selectedContainerId = '';
|
||||
batch.lotHistoryRows = [];
|
||||
batch.associationRows = [];
|
||||
batch.lineageCache = {};
|
||||
syncBatchUrlState();
|
||||
try {
|
||||
const payload = await apiPost(
|
||||
'/api/query-tool/resolve',
|
||||
{
|
||||
input_type: batch.inputType,
|
||||
values,
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
);
|
||||
batch.resolvedLots = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const notFound = Array.isArray(payload?.not_found) ? payload.not_found : [];
|
||||
successMessage.value = `解析完成:${batch.resolvedLots.length} 筆,未命中 ${notFound.length} 筆`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '解析失敗';
|
||||
batch.resolvedLots = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.resolving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLotHistory(containerId) {
|
||||
const id = String(containerId || '').trim();
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
loading.history = true;
|
||||
errorMessage.value = '';
|
||||
batch.selectedContainerId = id;
|
||||
syncBatchUrlState();
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('container_id', id);
|
||||
batch.selectedWorkcenterGroups.forEach((item) => params.append('workcenter_groups', item));
|
||||
const payload = await apiGet(`/api/query-tool/lot-history?${params.toString()}`, {
|
||||
timeout: 60000,
|
||||
silent: true,
|
||||
});
|
||||
batch.lotHistoryRows = Array.isArray(payload?.data) ? payload.data : [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '查詢 LOT 歷程失敗';
|
||||
batch.lotHistoryRows = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.history = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLotLineage(containerId) {
|
||||
const id = String(containerId || '').trim();
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cached = batch.lineageCache[id];
|
||||
if (cached?.loading || cached?.ancestors) {
|
||||
return true;
|
||||
}
|
||||
|
||||
batch.lineageCache[id] = {
|
||||
ancestors: null,
|
||||
merges: null,
|
||||
loading: true,
|
||||
error: '',
|
||||
};
|
||||
|
||||
try {
|
||||
const payload = await apiPost(
|
||||
'/api/trace/lineage',
|
||||
{
|
||||
profile: 'query_tool',
|
||||
container_ids: [id],
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
);
|
||||
|
||||
batch.lineageCache[id] = {
|
||||
ancestors: payload?.ancestors || {},
|
||||
merges: payload?.merges || {},
|
||||
loading: false,
|
||||
error: '',
|
||||
};
|
||||
return true;
|
||||
} catch (error) {
|
||||
batch.lineageCache[id] = {
|
||||
ancestors: null,
|
||||
merges: null,
|
||||
loading: false,
|
||||
error: error?.message || '血緣查詢失敗',
|
||||
};
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssociations() {
|
||||
if (!batch.selectedContainerId) {
|
||||
errorMessage.value = '請先選擇一筆 CONTAINERID';
|
||||
return false;
|
||||
}
|
||||
loading.association = true;
|
||||
errorMessage.value = '';
|
||||
syncBatchUrlState();
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
container_id: batch.selectedContainerId,
|
||||
type: batch.associationType,
|
||||
});
|
||||
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
|
||||
timeout: 60000,
|
||||
silent: true,
|
||||
});
|
||||
batch.associationRows = Array.isArray(payload?.data) ? payload.data : [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '查詢關聯資料失敗';
|
||||
batch.associationRows = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.association = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryEquipmentPeriod() {
|
||||
if (equipment.selectedEquipmentIds.length === 0) {
|
||||
errorMessage.value = '請選擇至少一台設備';
|
||||
return false;
|
||||
}
|
||||
if (!equipment.startDate || !equipment.endDate) {
|
||||
errorMessage.value = '請指定設備查詢日期範圍';
|
||||
return false;
|
||||
}
|
||||
loading.equipment = true;
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '';
|
||||
syncEquipmentUrlState();
|
||||
try {
|
||||
const payload = await apiPost(
|
||||
'/api/query-tool/equipment-period',
|
||||
{
|
||||
equipment_ids: equipment.selectedEquipmentIds,
|
||||
equipment_names: selectedEquipmentNames.value,
|
||||
start_date: equipment.startDate,
|
||||
end_date: equipment.endDate,
|
||||
query_type: equipment.equipmentQueryType,
|
||||
},
|
||||
{ timeout: 120000, silent: true },
|
||||
);
|
||||
equipment.rows = Array.isArray(payload?.data) ? payload.data : [];
|
||||
successMessage.value = `設備查詢完成:${equipment.rows.length} 筆`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '設備查詢失敗';
|
||||
equipment.rows = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.equipment = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCurrentCsv() {
|
||||
loading.exporting = true;
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '';
|
||||
|
||||
let exportType = null;
|
||||
let params = {};
|
||||
|
||||
if (equipment.rows.length > 0) {
|
||||
exportType = mapEquipmentExportType(equipment.equipmentQueryType);
|
||||
params = {
|
||||
equipment_ids: equipment.selectedEquipmentIds,
|
||||
equipment_names: selectedEquipmentNames.value,
|
||||
start_date: equipment.startDate,
|
||||
end_date: equipment.endDate,
|
||||
};
|
||||
} else if (batch.selectedContainerId && batch.associationRows.length > 0) {
|
||||
exportType = mapAssociationExportType(batch.associationType);
|
||||
params = {
|
||||
container_id: batch.selectedContainerId,
|
||||
};
|
||||
} else if (batch.selectedContainerId && batch.lotHistoryRows.length > 0) {
|
||||
exportType = 'lot_history';
|
||||
params = {
|
||||
container_id: batch.selectedContainerId,
|
||||
};
|
||||
}
|
||||
|
||||
if (!exportType) {
|
||||
loading.exporting = false;
|
||||
errorMessage.value = '無可匯出的查詢結果';
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/query-tool/export-csv', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
export_type: exportType,
|
||||
params,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `匯出失敗 (${response.status})`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
message = payload?.error || payload?.message || message;
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const href = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = href;
|
||||
anchor.download = `${exportType}.csv`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.URL.revokeObjectURL(href);
|
||||
successMessage.value = `CSV 匯出成功:${exportType}`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '匯出失敗';
|
||||
return false;
|
||||
} finally {
|
||||
loading.exporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
batch,
|
||||
equipment,
|
||||
resolvedColumns,
|
||||
historyColumns,
|
||||
associationColumns,
|
||||
equipmentColumns,
|
||||
hydrateFromUrl,
|
||||
resetEquipmentDateRange,
|
||||
bootstrap,
|
||||
resolveLots,
|
||||
loadLotLineage,
|
||||
loadLotHistory,
|
||||
loadAssociations,
|
||||
queryEquipmentPeriod,
|
||||
exportCurrentCsv,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,180 +0,0 @@
|
||||
.query-tool-page {
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.query-tool-header {
|
||||
border-radius: 12px;
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--portal-brand-start, #667eea) 0%, var(--portal-brand-end, #764ba2) 100%);
|
||||
box-shadow: var(--portal-shadow-panel);
|
||||
}
|
||||
|
||||
.query-tool-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.query-tool-header p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.query-tool-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.query-tool-filter select,
|
||||
.query-tool-filter input {
|
||||
min-width: 160px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.query-tool-textarea {
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.query-tool-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.query-tool-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.query-tool-btn-primary {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.query-tool-btn-success {
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.query-tool-btn-ghost {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.query-tool-row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.query-tool-table-wrap {
|
||||
margin-top: 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.query-tool-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-tool-table th,
|
||||
.query-tool-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.query-tool-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fafc;
|
||||
text-align: left;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.query-tool-table tr.selected {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.query-tool-lineage-row td {
|
||||
background: #f8fafc;
|
||||
border-top: 1px dashed #dbeafe;
|
||||
}
|
||||
|
||||
.query-tool-lineage-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.query-tool-lineage-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-tool-error-inline {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-tool-empty {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.query-tool-error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.query-tool-success {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.query-tool-page {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
76
frontend/src/query-tool/utils/csv.js
Normal file
76
frontend/src/query-tool/utils/csv.js
Normal file
@@ -0,0 +1,76 @@
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function resolveErrorMessage(status, payload) {
|
||||
if (payload?.error?.message) {
|
||||
return String(payload.error.message);
|
||||
}
|
||||
if (typeof payload?.error === 'string') {
|
||||
return payload.error;
|
||||
}
|
||||
if (typeof payload?.message === 'string' && payload.message) {
|
||||
return payload.message;
|
||||
}
|
||||
return `匯出失敗 (${status})`;
|
||||
}
|
||||
|
||||
function resolveDownloadFilename(response, fallbackName) {
|
||||
const disposition = response.headers.get('Content-Disposition') || '';
|
||||
const match = disposition.match(/filename=([^;]+)/i);
|
||||
if (!match?.[1]) {
|
||||
return fallbackName;
|
||||
}
|
||||
return match[1].replace(/(^['\"]|['\"]$)/g, '').trim() || fallbackName;
|
||||
}
|
||||
|
||||
export async function exportCsv({ exportType, params = {}, fallbackFilename = null }) {
|
||||
if (!exportType) {
|
||||
throw new Error('缺少匯出類型');
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/query-tool/export-csv', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
export_type: exportType,
|
||||
params,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
throw new Error(resolveErrorMessage(response.status, payload));
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const href = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const filename = resolveDownloadFilename(
|
||||
response,
|
||||
fallbackFilename || `${exportType}.csv`,
|
||||
);
|
||||
|
||||
link.href = href;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(href);
|
||||
|
||||
return filename;
|
||||
}
|
||||
93
frontend/src/query-tool/utils/values.js
Normal file
93
frontend/src/query-tool/utils/values.js
Normal file
@@ -0,0 +1,93 @@
|
||||
export function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
export function uniqueValues(values = []) {
|
||||
const seen = new Set();
|
||||
const list = [];
|
||||
values.forEach((value) => {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
list.push(normalized);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
export function parseInputValues(raw) {
|
||||
return uniqueValues(String(raw || '').split(/[\n,]/));
|
||||
}
|
||||
|
||||
export function parseArrayParam(params, key) {
|
||||
const repeated = params.getAll(key).map((item) => normalizeText(item)).filter(Boolean);
|
||||
if (repeated.length > 0) {
|
||||
return uniqueValues(repeated);
|
||||
}
|
||||
const fallback = normalizeText(params.get(key));
|
||||
if (!fallback) {
|
||||
return [];
|
||||
}
|
||||
return uniqueValues(fallback.split(','));
|
||||
}
|
||||
|
||||
export function toDateInputValue(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function parseDateTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
const date = new Date(String(value).replace(' ', 'T'));
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
export function formatDateTime(value) {
|
||||
const date = parseDateTime(value);
|
||||
if (!date) {
|
||||
return '-';
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
const second = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
export function formatCellValue(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value.toLocaleString() : '-';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function hashColor(seed) {
|
||||
const text = normalizeText(seed) || 'fallback';
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash = (hash << 5) - hash + text.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
const hue = Math.abs(hash) % 360;
|
||||
return `hsl(${hue} 70% 52%)`;
|
||||
}
|
||||
443
frontend/src/shared-ui/components/TimelineChart.vue
Normal file
443
frontend/src/shared-ui/components/TimelineChart.vue
Normal file
@@ -0,0 +1,443 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { formatDateTime, normalizeText, parseDateTime } from '../../query-tool/utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
tracks: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
events: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
timeRange: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
colorMap: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
labelWidth: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
trackRowHeight: {
|
||||
type: Number,
|
||||
default: 44,
|
||||
},
|
||||
minChartWidth: {
|
||||
type: Number,
|
||||
default: 960,
|
||||
},
|
||||
});
|
||||
|
||||
const AXIS_HEIGHT = 42;
|
||||
const tooltipRef = ref({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
title: '',
|
||||
lines: [],
|
||||
});
|
||||
const containerRef = ref(null);
|
||||
|
||||
function toTimestamp(value) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
const date = parseDateTime(value);
|
||||
return date ? date.getTime() : null;
|
||||
}
|
||||
|
||||
function collectDomainTimestamps() {
|
||||
const timestamps = [];
|
||||
|
||||
props.tracks.forEach((track) => {
|
||||
const layers = Array.isArray(track?.layers) ? track.layers : [];
|
||||
layers.forEach((layer) => {
|
||||
const bars = Array.isArray(layer?.bars) ? layer.bars : [];
|
||||
bars.forEach((bar) => {
|
||||
const startMs = toTimestamp(bar?.start);
|
||||
const endMs = toTimestamp(bar?.end);
|
||||
if (startMs !== null) timestamps.push(startMs);
|
||||
if (endMs !== null) timestamps.push(endMs);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
props.events.forEach((event) => {
|
||||
const timeMs = toTimestamp(event?.time);
|
||||
if (timeMs !== null) timestamps.push(timeMs);
|
||||
});
|
||||
|
||||
return timestamps;
|
||||
}
|
||||
|
||||
const normalizedTimeRange = computed(() => {
|
||||
const explicitStart = toTimestamp(props.timeRange?.start);
|
||||
const explicitEnd = toTimestamp(props.timeRange?.end);
|
||||
|
||||
if (explicitStart !== null && explicitEnd !== null && explicitEnd > explicitStart) {
|
||||
return {
|
||||
startMs: explicitStart,
|
||||
endMs: explicitEnd,
|
||||
};
|
||||
}
|
||||
|
||||
const timestamps = collectDomainTimestamps();
|
||||
if (timestamps.length === 0) {
|
||||
const now = Date.now();
|
||||
return {
|
||||
startMs: now - (1000 * 60 * 60),
|
||||
endMs: now + (1000 * 60 * 60),
|
||||
};
|
||||
}
|
||||
|
||||
const startMs = Math.min(...timestamps);
|
||||
const endMs = Math.max(...timestamps);
|
||||
if (endMs === startMs) {
|
||||
return {
|
||||
startMs,
|
||||
endMs: startMs + (1000 * 60 * 60),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
startMs,
|
||||
endMs,
|
||||
};
|
||||
});
|
||||
|
||||
const totalDurationMs = computed(() => {
|
||||
return Math.max(1, normalizedTimeRange.value.endMs - normalizedTimeRange.value.startMs);
|
||||
});
|
||||
|
||||
const trackCount = computed(() => props.tracks.length);
|
||||
|
||||
const chartWidth = computed(() => {
|
||||
const hours = totalDurationMs.value / (1000 * 60 * 60);
|
||||
const estimated = Math.round(hours * 36);
|
||||
return Math.max(props.minChartWidth, estimated);
|
||||
});
|
||||
|
||||
const svgHeight = computed(() => AXIS_HEIGHT + trackCount.value * props.trackRowHeight + 2);
|
||||
|
||||
function rowTopByIndex(index) {
|
||||
return AXIS_HEIGHT + index * props.trackRowHeight;
|
||||
}
|
||||
|
||||
function xByTimestamp(timestamp) {
|
||||
return ((timestamp - normalizedTimeRange.value.startMs) / totalDurationMs.value) * chartWidth.value;
|
||||
}
|
||||
|
||||
function normalizeBar(bar) {
|
||||
const startMs = toTimestamp(bar?.start);
|
||||
const endMs = toTimestamp(bar?.end);
|
||||
if (startMs === null || endMs === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeEndMs = endMs > startMs ? endMs : startMs + (1000 * 60);
|
||||
return {
|
||||
...bar,
|
||||
startMs,
|
||||
endMs: safeEndMs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event) {
|
||||
const timeMs = toTimestamp(event?.time);
|
||||
if (timeMs === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
timeMs,
|
||||
};
|
||||
}
|
||||
|
||||
const timelineTicks = computed(() => {
|
||||
const ticks = [];
|
||||
const rangeMs = totalDurationMs.value;
|
||||
const rangeHours = rangeMs / (1000 * 60 * 60);
|
||||
|
||||
const stepMs = rangeHours <= 48
|
||||
? (1000 * 60 * 60)
|
||||
: (1000 * 60 * 60 * 24);
|
||||
|
||||
const start = normalizedTimeRange.value.startMs;
|
||||
const end = normalizedTimeRange.value.endMs;
|
||||
|
||||
let cursor = start;
|
||||
while (cursor <= end) {
|
||||
const date = new Date(cursor);
|
||||
const label = stepMs < (1000 * 60 * 60 * 24)
|
||||
? `${String(date.getHours()).padStart(2, '0')}:00`
|
||||
: `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
|
||||
ticks.push({
|
||||
timeMs: cursor,
|
||||
label,
|
||||
});
|
||||
|
||||
cursor += stepMs;
|
||||
}
|
||||
|
||||
if (ticks.length < 2) {
|
||||
ticks.push({
|
||||
timeMs: end,
|
||||
label: stepMs < (1000 * 60 * 60 * 24) ? 'End' : '結束',
|
||||
});
|
||||
}
|
||||
|
||||
return ticks;
|
||||
});
|
||||
|
||||
const colorFallback = Object.freeze({
|
||||
default: '#94a3b8',
|
||||
});
|
||||
|
||||
function resolveColor(type) {
|
||||
const key = normalizeText(type);
|
||||
if (key && props.colorMap[key]) {
|
||||
return props.colorMap[key];
|
||||
}
|
||||
return colorFallback.default;
|
||||
}
|
||||
|
||||
function layerGeometry(trackIndex, layerIndex, layerCount) {
|
||||
const rowTop = rowTopByIndex(trackIndex);
|
||||
const maxBarHeight = Math.max(16, props.trackRowHeight - 14);
|
||||
const scale = layerCount <= 1
|
||||
? 1
|
||||
: (layerIndex === 0 ? 1 : Math.max(0.4, 1 - layerIndex * 0.2));
|
||||
|
||||
const height = maxBarHeight * scale;
|
||||
const y = rowTop + 6 + ((maxBarHeight - height) / 2);
|
||||
|
||||
return {
|
||||
y,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
const legendItems = computed(() => {
|
||||
const usedTypes = new Set();
|
||||
|
||||
props.tracks.forEach((track) => {
|
||||
const layers = Array.isArray(track?.layers) ? track.layers : [];
|
||||
layers.forEach((layer) => {
|
||||
const bars = Array.isArray(layer?.bars) ? layer.bars : [];
|
||||
bars.forEach((bar) => {
|
||||
const key = normalizeText(bar?.type);
|
||||
if (key) {
|
||||
usedTypes.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
props.events.forEach((event) => {
|
||||
const key = normalizeText(event?.type);
|
||||
if (key) {
|
||||
usedTypes.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
const keys = usedTypes.size > 0 ? [...usedTypes] : Object.keys(props.colorMap);
|
||||
return keys.map((key) => ({
|
||||
key,
|
||||
color: resolveColor(key),
|
||||
}));
|
||||
});
|
||||
|
||||
function showTooltip(event, title, lines = []) {
|
||||
const host = containerRef.value;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = host.getBoundingClientRect();
|
||||
tooltipRef.value = {
|
||||
visible: true,
|
||||
x: event.clientX - bounds.left + 12,
|
||||
y: event.clientY - bounds.top + 12,
|
||||
title,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
tooltipRef.value.visible = false;
|
||||
}
|
||||
|
||||
function handleBarHover(mouseEvent, bar, trackLabel) {
|
||||
const normalized = normalizeBar(bar);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = formatDateTime(normalized.start);
|
||||
const end = formatDateTime(normalized.end);
|
||||
const durationHours = ((normalized.endMs - normalized.startMs) / (1000 * 60 * 60)).toFixed(2);
|
||||
|
||||
const title = normalizeText(normalized.label) || normalizeText(normalized.type) || '區段';
|
||||
const lines = [
|
||||
`Track: ${trackLabel}`,
|
||||
`Start: ${start}`,
|
||||
`End: ${end}`,
|
||||
`Duration: ${durationHours}h`,
|
||||
normalizeText(normalized.detail),
|
||||
].filter(Boolean);
|
||||
|
||||
showTooltip(mouseEvent, title, lines);
|
||||
}
|
||||
|
||||
function handleEventHover(mouseEvent, eventItem, trackLabel) {
|
||||
const normalized = normalizeEvent(eventItem);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = normalizeText(normalized.label) || normalizeText(normalized.type) || '事件';
|
||||
const lines = [
|
||||
`Track: ${trackLabel}`,
|
||||
`Time: ${formatDateTime(normalized.time)}`,
|
||||
normalizeText(normalized.detail),
|
||||
].filter(Boolean);
|
||||
|
||||
showTooltip(mouseEvent, title, lines);
|
||||
}
|
||||
|
||||
function eventPath(type, x, y) {
|
||||
const normalizedType = normalizeText(type).toLowerCase();
|
||||
|
||||
if (normalizedType.includes('job') || normalizedType.includes('maint')) {
|
||||
return `M ${x} ${y - 7} L ${x - 7} ${y + 5} L ${x + 7} ${y + 5} Z`;
|
||||
}
|
||||
|
||||
return `M ${x} ${y - 7} L ${x - 7} ${y} L ${x} ${y + 7} L ${x + 7} ${y} Z`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-3 flex flex-wrap items-center gap-3 text-xs text-slate-600">
|
||||
<span class="font-medium text-slate-700">Timeline</span>
|
||||
<div v-for="item in legendItems" :key="item.key" class="flex items-center gap-1">
|
||||
<span class="inline-block size-2 rounded-full" :style="{ backgroundColor: item.color }" />
|
||||
<span>{{ item.key }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative overflow-hidden rounded-card border border-stroke-soft bg-surface-muted/30"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<div class="grid" :style="{ gridTemplateColumns: `${labelWidth}px minmax(0, 1fr)` }">
|
||||
<div class="sticky left-0 z-20 border-r border-stroke-soft bg-white">
|
||||
<div class="flex h-[42px] items-center border-b border-stroke-soft px-3 text-xs font-semibold tracking-wide text-slate-500">
|
||||
Track
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="track in tracks"
|
||||
:key="track.id || track.label"
|
||||
class="flex items-center border-b border-stroke-soft/70 px-3 text-xs text-slate-700"
|
||||
:style="{ height: `${trackRowHeight}px` }"
|
||||
>
|
||||
<span class="line-clamp-1">{{ track.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<svg
|
||||
:width="chartWidth"
|
||||
:height="svgHeight"
|
||||
:viewBox="`0 0 ${chartWidth} ${svgHeight}`"
|
||||
class="block"
|
||||
>
|
||||
<rect x="0" y="0" :width="chartWidth" :height="svgHeight" fill="#ffffff" />
|
||||
|
||||
<g>
|
||||
<line x1="0" :x2="chartWidth" y1="41" y2="41" stroke="#cbd5e1" stroke-width="1" />
|
||||
<g v-for="tick in timelineTicks" :key="tick.timeMs">
|
||||
<line
|
||||
:x1="xByTimestamp(tick.timeMs)"
|
||||
:x2="xByTimestamp(tick.timeMs)"
|
||||
y1="0"
|
||||
:y2="svgHeight"
|
||||
stroke="#e2e8f0"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
<text
|
||||
:x="xByTimestamp(tick.timeMs) + 2"
|
||||
y="14"
|
||||
fill="#475569"
|
||||
font-size="11"
|
||||
>
|
||||
{{ tick.label }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g v-for="(track, trackIndex) in tracks" :key="track.id || track.label">
|
||||
<rect
|
||||
x="0"
|
||||
:y="rowTopByIndex(trackIndex)"
|
||||
:width="chartWidth"
|
||||
:height="trackRowHeight"
|
||||
:fill="trackIndex % 2 === 0 ? '#f8fafc' : '#f1f5f9'"
|
||||
opacity="0.45"
|
||||
/>
|
||||
|
||||
<g v-for="(layer, layerIndex) in (track.layers || [])" :key="layer.id || layerIndex">
|
||||
<template
|
||||
v-for="(bar, barIndex) in (layer.bars || [])"
|
||||
:key="bar.id || `${trackIndex}-${layerIndex}-${barIndex}`"
|
||||
>
|
||||
<rect
|
||||
v-if="normalizeBar(bar)"
|
||||
:x="xByTimestamp(normalizeBar(bar).startMs)"
|
||||
:y="layerGeometry(trackIndex, layerIndex, (track.layers || []).length).y"
|
||||
:width="Math.max(2, xByTimestamp(normalizeBar(bar).endMs) - xByTimestamp(normalizeBar(bar).startMs))"
|
||||
:height="layerGeometry(trackIndex, layerIndex, (track.layers || []).length).height"
|
||||
:fill="bar.color || resolveColor(bar.type)"
|
||||
:opacity="layer.opacity ?? (layerIndex === 0 ? 0.45 : 0.9)"
|
||||
rx="3"
|
||||
@mousemove="handleBarHover($event, bar, track.label)"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
|
||||
<template v-for="(eventItem, eventIndex) in events" :key="eventItem.id || `${trackIndex}-event-${eventIndex}`">
|
||||
<path
|
||||
v-if="normalizeEvent(eventItem) && normalizeText(eventItem.trackId) === normalizeText(track.id)"
|
||||
:d="eventPath(eventItem.shape || eventItem.type, xByTimestamp(normalizeEvent(eventItem).timeMs), rowTopByIndex(trackIndex) + (trackRowHeight / 2))"
|
||||
:fill="eventItem.color || resolveColor(eventItem.type)"
|
||||
stroke="#0f172a"
|
||||
stroke-width="0.5"
|
||||
@mousemove="handleEventHover($event, eventItem, track.label)"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="tooltipRef.visible"
|
||||
class="pointer-events-none absolute z-30 max-w-72 rounded-card border border-stroke-soft bg-slate-900/95 px-2 py-1.5 text-[11px] text-slate-100 shadow-lg"
|
||||
:style="{ left: `${tooltipRef.x}px`, top: `${tooltipRef.y}px` }"
|
||||
>
|
||||
<p class="font-semibold text-white">{{ tooltipRef.title }}</p>
|
||||
<p v-for="line in tooltipRef.lines" :key="line" class="mt-0.5 text-slate-200">{{ line }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-12
|
||||
155
openspec/changes/archive/2026-02-13-query-tool-rewrite/design.md
Normal file
155
openspec/changes/archive/2026-02-13-query-tool-rewrite/design.md
Normal file
@@ -0,0 +1,155 @@
|
||||
## Context
|
||||
|
||||
The query-tool ("批次追蹤工具") is the primary batch tracing page used by production engineers to trace LOT lineage, inspect production history, and query equipment records. The current implementation is a monolithic `App.vue` (343L) + `useQueryToolData.js` (448L) with custom CSS, no component decomposition, and flat `<ul>` lineage display.
|
||||
|
||||
The backend is fully ready:
|
||||
- `/api/query-tool/resolve` — LOT/Serial/WO resolution (max 50/50/10 inputs)
|
||||
- `/api/trace/lineage` — LineageEngine genealogy (ancestors + merges, rate limit 10/60s)
|
||||
- `/api/query-tool/lot-history` — production history with workcenter filter
|
||||
- `/api/query-tool/lot-associations` — materials/rejects/holds/splits/jobs
|
||||
- `/api/query-tool/equipment-period` — 5 query types (status_hours, lots, materials, rejects, jobs)
|
||||
- `/api/query-tool/export-csv` — 11 export types
|
||||
|
||||
All backend endpoints remain unchanged. This is a pure frontend rewrite.
|
||||
|
||||
Existing design patterns from other modernized pages:
|
||||
- Tailwind tokens: `brand-500`, `surface-card`, `stroke-soft`, `state-*`, `rounded-card`, `shadow-panel`
|
||||
- Shared components: `SectionCard`, `FilterToolbar`, `StatusBadge`, `PaginationControl`, `TraceProgressBar`
|
||||
- Shared composables: `useAutoRefresh`, `usePaginationState`, `useQueryState`, `useTraceProgress`
|
||||
- Font: Noto Sans TC / Microsoft JhengHei, body 13px
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Complete frontend rewrite of query-tool with Tailwind + component architecture
|
||||
- Tab-based layout: LOT tracing / Equipment query as independent tabs
|
||||
- Lineage decomposition tree with auto-fire + concurrency control + growth animation
|
||||
- Production timeline (Gantt-style) for both LOT history and equipment activity
|
||||
- Left-right master-detail for LOT tab (tree left, detail right)
|
||||
- Per-sub-tab CSV export
|
||||
- URL state persistence for tab, filters, and selected lot
|
||||
|
||||
**Non-Goals:**
|
||||
- No backend API changes
|
||||
- No new npm dependencies (timeline is SVG/CSS, no chart library)
|
||||
- No changes to other pages (mid-section-defect uses its own TraceProgressBar integration)
|
||||
- No real-time/WebSocket features
|
||||
- No pagination on lineage or association tables (datasets are bounded by single-lot scope)
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Tab-based separation of LOT and Equipment
|
||||
|
||||
LOT tracing and equipment query are completely unrelated workflows. Users never need both simultaneously.
|
||||
|
||||
**Decision**: Two top-level tabs with independent state. Tab state tracked via URL `?tab=lot|equipment`.
|
||||
|
||||
**Alternative considered**: Keep as single scrollable page → rejected because the page becomes too long and users lose context scrolling between sections.
|
||||
|
||||
### D2: Lineage tree as left navigation panel
|
||||
|
||||
The lineage decomposition tree serves dual purpose: it IS the lot list AND the genealogy visualization.
|
||||
|
||||
**Decision**: After resolve, root nodes (resolved lots) appear in the left panel. Each root can expand to show ancestors. Clicking any node (root or ancestor) selects it and loads detail in the right panel.
|
||||
|
||||
**Rationale**: Eliminates the need for a separate "lot list" component. The tree naturally represents the resolve results + their relationships.
|
||||
|
||||
**Layout**: Left panel ~300px fixed width, right panel fills remaining space. Below 1024px, stack vertically.
|
||||
|
||||
### D3: Auto-fire lineage with concurrency limiter
|
||||
|
||||
After resolve, lineage is the primary value — users want to see the genealogy immediately.
|
||||
|
||||
**Decision**: Auto-fire `POST /api/trace/lineage` for each resolved lot with `concurrency=3`. Results render progressively as they arrive, animating tree growth.
|
||||
|
||||
**Concurrency calculation**: Rate limit is 10/60s. With concurrency=3 and avg ~1.5s per call, we'll sustain ~2 calls/s, comfortably below the limit. For 50 lots, all lineage completes in ~25s with continuous tree growth.
|
||||
|
||||
**Alternative considered**: On-demand only (click to expand) → rejected because the user explicitly wants to see the full picture immediately. The auto-fire + animation creates an engaging "tree growing" experience.
|
||||
|
||||
**Cache**: Per-lot lineage cached in a reactive Map. Cache cleared on new resolve.
|
||||
|
||||
### D4: Progressive tree growth animation
|
||||
|
||||
**Decision**: Use Vue `<TransitionGroup>` with CSS transforms:
|
||||
- Root nodes: `opacity 0→1` (fade-in on resolve)
|
||||
- Branch nodes: `translateX(-16px)→0` + `opacity 0→1` (slide-in from left)
|
||||
- Sibling stagger: 30-50ms delay between consecutive siblings
|
||||
- Level 2+ nodes animate when their parent's lineage data arrives (same animation)
|
||||
|
||||
**Implementation**: `LineageNode.vue` is a recursive component. Each node wraps its children in `<TransitionGroup>`. When the reactive lineage cache updates for a lot, Vue reactivity triggers child insertion, which triggers the enter transition.
|
||||
|
||||
### D5: TimelineChart as shared SVG component
|
||||
|
||||
Both LOT production timeline and equipment timeline need the same Gantt-style visualization.
|
||||
|
||||
**Decision**: Create a `TimelineChart.vue` component that accepts:
|
||||
```
|
||||
Props:
|
||||
tracks: Array<{ id, label, layers: Array<{ bars: Array<{start, end, type}> }> }>
|
||||
events: Array<{ trackId, time, type, label, detail }>
|
||||
timeRange: { start: Date, end: Date }
|
||||
colorMap: Record<string, string>
|
||||
```
|
||||
|
||||
**Rendering**: Pure SVG with:
|
||||
- Sticky left labels (CSS `position: sticky`)
|
||||
- Horizontal time axis with auto-scaled ticks (hours or days)
|
||||
- Multi-layer bars per track (background layer + foreground layer)
|
||||
- Event markers as SVG icons (diamond shape) with hover tooltips
|
||||
- Horizontal scroll container for wide timelines
|
||||
|
||||
**No external deps**: SVG + CSS only. Time calculations use native Date. No D3, no ECharts.
|
||||
|
||||
### D6: LOT Timeline data mapping
|
||||
|
||||
LOT production history records have TRACKINTIMESTAMP and TRACKOUTTIMESTAMP per station.
|
||||
|
||||
**Decision**: Map lot-history rows to TimelineChart tracks:
|
||||
- Each unique WORKCENTERNAME = one track
|
||||
- Each row = one bar (track-in to track-out, colored by workcenter group)
|
||||
- Hold events from lot-holds = event markers
|
||||
- Workcenter filter shows/hides tracks
|
||||
|
||||
### D7: Equipment Timeline multi-source composition
|
||||
|
||||
Equipment timeline overlays three data sources on a single track per equipment.
|
||||
|
||||
**Decision**: Fetch `status_hours`, `lots`, and `jobs` data, then compose:
|
||||
- Layer 0 (background): Status bars (PRD=green, SBY=amber, UDT=red, SDT=blue-gray)
|
||||
- Layer 1 (foreground): Lot processing bars (track-in to track-out)
|
||||
- Events: Maintenance job markers (JOBID + CAUSECODENAME as label)
|
||||
|
||||
**Note**: This requires 3 API calls per equipment tab query. They can fire in parallel since they're independent.
|
||||
|
||||
### D8: Composable architecture
|
||||
|
||||
Split the monolithic `useQueryToolData.js` (448L) into focused composables:
|
||||
|
||||
| Composable | Responsibility | State |
|
||||
|-----------|---------------|-------|
|
||||
| `useLotResolve` | Input parsing, resolve API, URL state for resolve params | `resolvedLots`, `notFound`, `loading.resolving` |
|
||||
| `useLotLineage` | Lineage auto-fire, concurrency limiter, tree expand/collapse state, cache | `lineageMap`, `expandedNodes`, `loadingSet` |
|
||||
| `useLotDetail` | Per-lot history + associations, sub-tab active state, sub-tab cache | `activeSubTab`, `historyRows`, `associationRows`, caches |
|
||||
| `useEquipmentQuery` | Equipment list, date range, 4 query types, sub-tab state | `equipment.*`, `activeSubTab`, query results |
|
||||
|
||||
Each composable manages its own loading/error state. No global error banner — errors display contextually within each section.
|
||||
|
||||
### D9: Delete legacy files
|
||||
|
||||
- `frontend/src/query-tool/main.js` → rewrite to minimal Vite entry (3-5 lines: import App, createApp, mount)
|
||||
- `frontend/src/query-tool/style.css` → delete entirely, all styling via Tailwind
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[100+ lots from work order → tree overwhelm]** → The lineage tree with 100+ root nodes could be visually overwhelming. Mitigation: Virtual scroll or "show first 20, load more" pattern for the tree panel if needed. Start without it and evaluate.
|
||||
|
||||
**[Lineage auto-fire rate limit pressure]** → With 50 lots and concurrency=3, we'll make ~50 requests within ~25s. Rate limit is 10/60s which means we'd hit the limit at lot #10. Mitigation: The concurrency limiter must respect 429 responses and back off. If rate limited, pause and retry after `Retry-After` header. Alternatively, batch multiple container_ids per lineage call (backend already supports arrays).
|
||||
|
||||
**[Timeline SVG performance with large datasets]** → Equipment timeline spanning 90 days with 20 equipment could generate thousands of SVG elements. Mitigation: Aggregate status bars at coarse granularity for wide ranges, detailed view for narrow ranges. Start with naive rendering and optimize if needed.
|
||||
|
||||
**[Left panel width on narrow screens]** → 300px fixed width may be too wide on 1024-1280px screens. Mitigation: Make the left panel collapsible/resizable, or use a narrower default (240px) with a expand-on-hover pattern.
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
- **Q1 (Resolved)**: Lineage API calls SHALL be per-lot independent (not batched). This preserves the progressive tree growth animation — each API response triggers a visual branch expansion. With concurrency=3 and 429 backoff, this stays within rate limits while providing engaging UX.
|
||||
- **Q2 (Resolved)**: Equipment timeline SHALL use the `DW_MES_RESOURCESTATUS_SHIFT` aggregate table (8h shift granularity). This avoids querying raw status change events which are voluminous and not readily available via existing API. The 8h blocks are sufficient for timeline overview; users needing finer granularity can inspect the lots/jobs sub-tabs for exact timestamps.
|
||||
@@ -0,0 +1,33 @@
|
||||
## Why
|
||||
|
||||
The query-tool page ("批次追蹤工具") is the last major page still running as a monolithic Vue SFC with custom CSS. Its 343-line App.vue and 448-line useQueryToolData composable pack all three unrelated features (LOT tracing, LOT detail, equipment period query) into one vertical scroll with no component decomposition. Lineage visualization is a flat `<ul><li>` list, production history is a static table with no timeline view, and there is no progressive loading animation despite the staged trace API being available. The page needs a complete frontend rewrite to match the Tailwind + component-based architecture used by all other modernized pages, while significantly improving the tracing UX.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Complete rewrite** of `frontend/src/query-tool/` — new component tree, composables, and Tailwind-only styling (no style.css)
|
||||
- **Tab-based layout**: Split LOT tracing and equipment query into independent top-level tabs
|
||||
- **Lineage decomposition tree**: Replace flat ancestor list with an interactive tree that "grows" progressively as lineage API calls return (limited concurrency, animated node insertion)
|
||||
- **Left-right master-detail layout**: Lineage tree as left navigation panel, LOT detail (sub-tabs) on right
|
||||
- **Production timeline** (shared `TimelineChart.vue`): Gantt-style visualization for both LOT production history (stations over time) and equipment activity (lots + maintenance + status)
|
||||
- **Equipment tab redesign**: Replace 5 generic query types with 4 focused sub-tabs — production lots, maintenance records (with cause/repair/symptom codes), scrap records, and equipment timeline
|
||||
- **Auto-fire lineage with concurrency control**: After resolve, lineage API calls fire automatically with concurrency=3, tree grows as results arrive
|
||||
- **Per-sub-tab CSV export**: Each detail sub-tab has its own export button instead of one shared context-aware export
|
||||
- **Delete legacy `main.js`**: The 448-line vanilla JS module in query-tool is dead code superseded by the Vue SFC
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `query-tool-lot-trace`: LOT tracing tab — query bar, lineage decomposition tree with progressive growth animation, left-right master-detail layout, LOT detail sub-tabs (history with workcenter filter + timeline, materials, rejects, holds, splits, jobs), per-tab CSV export
|
||||
- `query-tool-equipment`: Equipment query tab — equipment/date selection, 4 sub-tabs (production lots, maintenance records, scrap records, equipment timeline), per-tab CSV export
|
||||
- `timeline-chart`: Shared Gantt-style timeline visualization component — horizontal time axis, configurable tracks with colored bars, event markers, tooltips, used by both LOT and equipment views
|
||||
|
||||
### Modified Capabilities
|
||||
- `progressive-trace-ux`: Lineage tree now auto-fires with concurrency-limited parallel requests and animated progressive rendering (expanding the on-demand spec to support auto-fire mode)
|
||||
|
||||
## Impact
|
||||
|
||||
- **Frontend**: Complete rewrite of `frontend/src/query-tool/` (App.vue, composables, new component tree of ~15 files)
|
||||
- **Backend**: Zero changes — all existing `/api/query-tool/*` and `/api/trace/*` endpoints remain unchanged
|
||||
- **Shared UI**: New `TimelineChart.vue` component may live in `shared-ui/` or query-tool local components
|
||||
- **Dead code**: `frontend/src/query-tool/main.js` (448L) and `frontend/src/query-tool/style.css` deleted
|
||||
- **Dependencies**: No new npm packages — timeline rendered with SVG/CSS, tree with recursive Vue components + TransitionGroup
|
||||
@@ -0,0 +1,16 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: query-tool lineage tab SHALL load on-demand
|
||||
The query-tool lineage tree SHALL auto-fire lineage API calls after lot resolution with concurrency-limited parallel requests and progressive rendering, while preserving on-demand expand/collapse for tree navigation.
|
||||
|
||||
#### Scenario: Auto-fire lineage after resolve
|
||||
- **WHEN** lot resolution completes with N resolved lots
|
||||
- **THEN** lineage SHALL be fetched via `POST /api/trace/lineage` for each lot automatically
|
||||
- **THEN** concurrent requests SHALL be limited to 3 at a time to respect rate limits (10/60s)
|
||||
- **THEN** response time SHALL be ≤3s per individual lot
|
||||
|
||||
#### Scenario: Multiple lots lineage results cached
|
||||
- **WHEN** lineage data has been fetched for multiple lots
|
||||
- **THEN** each lot's lineage data SHALL be preserved independently (not re-fetched)
|
||||
- **WHEN** a new resolve query is executed
|
||||
- **THEN** all cached lineage data SHALL be cleared
|
||||
@@ -0,0 +1,72 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Equipment tab SHALL provide equipment selection with date range filtering
|
||||
The equipment tab SHALL allow selecting multiple equipment and a date range for all sub-tab queries.
|
||||
|
||||
#### Scenario: Equipment and date selection
|
||||
- **WHEN** the user opens the equipment tab
|
||||
- **THEN** a MultiSelect dropdown SHALL list available equipment from `GET /api/query-tool/equipment-list`
|
||||
- **THEN** date inputs SHALL default to the last 30 days
|
||||
- **THEN** a query button SHALL trigger data loading for the active sub-tab
|
||||
|
||||
#### Scenario: Shared filter state across sub-tabs
|
||||
- **WHEN** the user selects equipment and date range then switches sub-tabs
|
||||
- **THEN** the filter values SHALL persist across all equipment sub-tabs
|
||||
- **THEN** switching to a new sub-tab with the same filters SHALL trigger a fresh query for that sub-tab's data type
|
||||
|
||||
### Requirement: Equipment Production Lots sub-tab SHALL display lots processed by selected equipment
|
||||
The Production Lots sub-tab SHALL show all lots processed on the selected equipment within the date range.
|
||||
|
||||
#### Scenario: Production lots query
|
||||
- **WHEN** the user queries with selected equipment and date range
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/equipment-period` with `query_type: "lots"`
|
||||
- **THEN** results SHALL display in a table showing CONTAINERID, SPECNAME, TRACK_IN, TRACK_OUT, QTY, EQUIPMENTNAME
|
||||
|
||||
#### Scenario: Partial track-out handling
|
||||
- **WHEN** a lot has multiple track-out records (partial processing)
|
||||
- **THEN** all records SHALL be displayed (not deduplicated)
|
||||
|
||||
### Requirement: Equipment Maintenance sub-tab SHALL display maintenance job records with expandable detail
|
||||
The Maintenance sub-tab SHALL show maintenance jobs from `DW_MES_JOB` with cause/repair/symptom codes.
|
||||
|
||||
#### Scenario: Maintenance job list
|
||||
- **WHEN** the user queries maintenance records
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/equipment-period` with `query_type: "jobs"`
|
||||
- **THEN** results SHALL display: JOBID, STATUS, CAUSECODENAME, REPAIRCODENAME, SYMPTOMCODENAME, CREATE/COMPLETE dates
|
||||
|
||||
#### Scenario: Job detail expansion
|
||||
- **WHEN** the user clicks on a maintenance job row
|
||||
- **THEN** the row SHALL expand to show full detail including employee names, secondary codes, and related lot IDs (CONTAINERNAMES)
|
||||
|
||||
### Requirement: Equipment Scrap sub-tab SHALL display reject/defect records
|
||||
The Scrap sub-tab SHALL show reject statistics grouped by loss reason for the selected equipment and date range.
|
||||
|
||||
#### Scenario: Scrap records query
|
||||
- **WHEN** the user queries scrap records
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/equipment-period` with `query_type: "rejects"`
|
||||
- **THEN** results SHALL display: EQUIPMENTNAME, LOSSREASONNAME, TOTAL_REJECT_QTY, TOTAL_DEFECT_QTY, AFFECTED_LOT_COUNT
|
||||
|
||||
### Requirement: Equipment Timeline sub-tab SHALL visualize equipment activity over time
|
||||
The Timeline sub-tab SHALL render a Gantt-style timeline showing equipment status, lots processed, and maintenance events.
|
||||
|
||||
#### Scenario: Multi-layer timeline rendering
|
||||
- **WHEN** the user views the equipment timeline
|
||||
- **THEN** the timeline SHALL overlay three data layers: status bars (PRD/SBY/UDT/SDT), lot processing bars, and maintenance event markers
|
||||
- **THEN** each equipment SHALL appear as a separate track row
|
||||
|
||||
#### Scenario: Status color coding
|
||||
- **WHEN** the timeline renders status bars
|
||||
- **THEN** PRD SHALL be green, SBY SHALL be amber, UDT SHALL be red, SDT SHALL be blue-gray
|
||||
- **THEN** a legend SHALL be displayed showing the color mapping
|
||||
|
||||
#### Scenario: Maintenance marker interaction
|
||||
- **WHEN** the user hovers over or clicks a maintenance event marker on the timeline
|
||||
- **THEN** a tooltip or expanded panel SHALL show the job detail (CAUSECODENAME, REPAIRCODENAME, SYMPTOMCODENAME)
|
||||
|
||||
### Requirement: Each equipment sub-tab SHALL support CSV export
|
||||
Every equipment sub-tab SHALL have its own export button calling the existing export API.
|
||||
|
||||
#### Scenario: Equipment CSV export
|
||||
- **WHEN** the user clicks export on any equipment sub-tab
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/export-csv` with the appropriate `export_type` (equipment_lots, equipment_jobs, equipment_rejects)
|
||||
- **THEN** the exported params SHALL include the current equipment_ids/equipment_names and date range
|
||||
@@ -0,0 +1,152 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Query-tool page SHALL use tab-based layout separating LOT tracing from equipment queries
|
||||
The query-tool page SHALL present two top-level tabs: "LOT 追蹤" and "設備查詢", each with independent state and UI.
|
||||
|
||||
#### Scenario: Tab switching preserves state
|
||||
- **WHEN** the user switches from LOT tab to Equipment tab and back
|
||||
- **THEN** the LOT tab SHALL retain its resolved lots, lineage tree state, and selected lot detail
|
||||
- **THEN** the Equipment tab SHALL retain its query results independently
|
||||
|
||||
#### Scenario: URL state reflects active tab
|
||||
- **WHEN** the user is on a specific tab
|
||||
- **THEN** the URL SHALL include a `tab` parameter (e.g., `?tab=lot` or `?tab=equipment`)
|
||||
- **THEN** reloading the page SHALL restore the active tab
|
||||
|
||||
### Requirement: QueryBar SHALL resolve LOT/Serial/WorkOrder inputs
|
||||
The query bar SHALL accept multi-value input (newline or comma-separated) with input type selection and resolve via `POST /api/query-tool/resolve`.
|
||||
|
||||
#### Scenario: Successful LOT resolution
|
||||
- **WHEN** the user enters lot IDs and clicks resolve
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/resolve` with `input_type` and `values`
|
||||
- **THEN** resolved lots SHALL appear as root nodes in the lineage tree
|
||||
- **THEN** not-found values SHALL be displayed as warnings below the tree
|
||||
|
||||
#### Scenario: Work order expansion
|
||||
- **WHEN** the user enters work order IDs (max 10)
|
||||
- **THEN** each work order MAY expand to 100+ lots
|
||||
- **THEN** all expanded lots SHALL appear as root nodes in the lineage tree
|
||||
|
||||
#### Scenario: Input validation
|
||||
- **WHEN** the user submits empty input or exceeds limits (50 lot IDs, 50 serial numbers, 10 work orders)
|
||||
- **THEN** the system SHALL display a validation error without making an API call
|
||||
|
||||
### Requirement: LineageTree SHALL display as a decomposition tree with progressive growth animation
|
||||
After resolve completes, the lineage tree SHALL auto-fire lineage API calls for each resolved lot with concurrency control, rendering an animated tree that grows as results arrive.
|
||||
|
||||
#### Scenario: Auto-fire lineage after resolve
|
||||
- **WHEN** lot resolution completes with N resolved lots
|
||||
- **THEN** the system SHALL automatically call `POST /api/trace/lineage` for each lot
|
||||
- **THEN** concurrent lineage requests SHALL be limited to 3 at a time
|
||||
- **THEN** the lineage tree SHALL render root nodes immediately (resolved lots)
|
||||
|
||||
#### Scenario: Progressive tree growth animation
|
||||
- **WHEN** a lineage API response returns for a lot
|
||||
- **THEN** ancestor nodes SHALL animate into the tree (slide-in + fade, staggered 30-50ms per sibling)
|
||||
- **THEN** the animation SHALL give the visual impression of a tree "growing" its branches
|
||||
|
||||
#### Scenario: Tree node expand/collapse
|
||||
- **WHEN** the user clicks a tree node with children (ancestors)
|
||||
- **THEN** children SHALL toggle between expanded and collapsed state
|
||||
- **THEN** expand/collapse SHALL be a client-side operation (no additional API call)
|
||||
|
||||
#### Scenario: Expand-all and collapse-all
|
||||
- **WHEN** the user clicks "全部展開"
|
||||
- **THEN** all tree nodes at all levels SHALL expand with staggered animation
|
||||
- **WHEN** the user clicks "收合"
|
||||
- **THEN** all tree nodes SHALL collapse to show only root nodes
|
||||
|
||||
#### Scenario: Merge relationships visually distinct
|
||||
- **WHEN** the lineage data includes merge relationships
|
||||
- **THEN** merge nodes SHALL display a distinct icon (🔀) and/or color to differentiate from direct ancestor relationships
|
||||
|
||||
#### Scenario: Leaf nodes without expand affordance
|
||||
- **WHEN** a tree node has no ancestors (leaf/terminal node)
|
||||
- **THEN** it SHALL NOT display an expand button or clickable expand area
|
||||
|
||||
#### Scenario: Lineage cache prevents duplicate fetches
|
||||
- **WHEN** lineage data has already been fetched for a lot
|
||||
- **THEN** subsequent interactions SHALL use cached data without re-fetching
|
||||
- **WHEN** a new resolve query is executed
|
||||
- **THEN** the lineage cache SHALL be cleared
|
||||
|
||||
### Requirement: Left-right master-detail layout SHALL show tree and LOT detail side by side
|
||||
The LOT tracing tab SHALL use a left-right split layout with the lineage tree on the left and LOT detail on the right.
|
||||
|
||||
#### Scenario: LOT selection from tree
|
||||
- **WHEN** the user clicks any node in the lineage tree (root lot or ancestor)
|
||||
- **THEN** the right panel SHALL load detail for that node's container ID
|
||||
- **THEN** the selected node SHALL be visually highlighted in the tree
|
||||
|
||||
#### Scenario: Right panel sub-tabs
|
||||
- **WHEN** a LOT is selected
|
||||
- **THEN** the right panel SHALL display sub-tabs: 歷程 (History), 物料 (Materials), 退貨 (Rejects), Hold, Split, Job
|
||||
- **THEN** each sub-tab SHALL load data on-demand when activated (not pre-fetched)
|
||||
|
||||
#### Scenario: Responsive behavior
|
||||
- **WHEN** the viewport width is below 1024px
|
||||
- **THEN** the layout SHALL stack vertically (tree above, detail below)
|
||||
|
||||
### Requirement: LOT History sub-tab SHALL display production history with workcenter filter
|
||||
The History sub-tab SHALL show production history data from `GET /api/query-tool/lot-history` with workcenter group filtering.
|
||||
|
||||
#### Scenario: History table display
|
||||
- **WHEN** the user selects the History sub-tab for a LOT
|
||||
- **THEN** the system SHALL call `GET /api/query-tool/lot-history?container_id=X`
|
||||
- **THEN** results SHALL display in a table with sticky headers and horizontal scroll
|
||||
|
||||
#### Scenario: Workcenter group filter
|
||||
- **WHEN** the user selects workcenter groups from the filter dropdown
|
||||
- **THEN** the history query SHALL include the selected groups as filter parameters
|
||||
- **THEN** the history table SHALL refresh with filtered results
|
||||
|
||||
### Requirement: LOT Production Timeline SHALL visualize station progression over time
|
||||
The History sub-tab SHALL include a timeline visualization showing the lot's journey through production stations.
|
||||
|
||||
#### Scenario: Timeline rendering
|
||||
- **WHEN** lot history data is loaded
|
||||
- **THEN** a horizontal Gantt-style timeline SHALL render with time on the X-axis
|
||||
- **THEN** each workcenter/station SHALL appear as a track with a colored bar from track-in to track-out time
|
||||
|
||||
#### Scenario: Workcenter filter affects timeline
|
||||
- **WHEN** the user filters by workcenter groups
|
||||
- **THEN** the timeline SHALL show only stations matching the selected groups
|
||||
- **THEN** filtered-out stations SHALL be hidden (not grayed out)
|
||||
|
||||
#### Scenario: Timeline event markers
|
||||
- **WHEN** hold events or material consumption events exist within the timeline range
|
||||
- **THEN** they SHALL be displayed as markers on the timeline with tooltip details on hover
|
||||
|
||||
### Requirement: LOT Association sub-tabs SHALL load data on-demand
|
||||
Each association sub-tab (Materials, Rejects, Holds, Splits, Jobs) SHALL fetch data independently when activated.
|
||||
|
||||
#### Scenario: Association data loading
|
||||
- **WHEN** the user activates a sub-tab (e.g., Materials)
|
||||
- **THEN** the system SHALL call `GET /api/query-tool/lot-associations?container_id=X&type=materials`
|
||||
- **THEN** results SHALL display in a table with dynamic columns
|
||||
|
||||
#### Scenario: Sub-tab data caching within session
|
||||
- **WHEN** the user switches between sub-tabs for the same LOT
|
||||
- **THEN** previously loaded sub-tab data SHALL be preserved (not re-fetched)
|
||||
- **WHEN** the user selects a different LOT
|
||||
- **THEN** all sub-tab caches SHALL be cleared
|
||||
|
||||
### Requirement: Each sub-tab SHALL support independent CSV export
|
||||
Every detail sub-tab SHALL have its own export button.
|
||||
|
||||
#### Scenario: Per-tab export
|
||||
- **WHEN** the user clicks export on the Materials sub-tab
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/export-csv` with `export_type: "lot_materials"` and the current container_id
|
||||
- **THEN** a CSV file SHALL download with the appropriate filename
|
||||
|
||||
#### Scenario: Export disabled when no data
|
||||
- **WHEN** a sub-tab has no data loaded or the data is empty
|
||||
- **THEN** the export button SHALL be disabled
|
||||
|
||||
### Requirement: Legacy dead code SHALL be removed
|
||||
The legacy `frontend/src/query-tool/main.js` (448L vanilla JS) and `frontend/src/query-tool/style.css` SHALL be deleted.
|
||||
|
||||
#### Scenario: Dead code removal
|
||||
- **WHEN** the rewrite is complete
|
||||
- **THEN** `frontend/src/query-tool/main.js` SHALL contain only the Vite entry point (createApp + mount)
|
||||
- **THEN** `frontend/src/query-tool/style.css` SHALL be deleted (all styling via Tailwind)
|
||||
@@ -0,0 +1,50 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: TimelineChart component SHALL render configurable Gantt-style timelines
|
||||
A shared `TimelineChart` component SHALL accept structured track/event data and render a horizontal timeline with SVG/CSS.
|
||||
|
||||
#### Scenario: Basic track rendering
|
||||
- **WHEN** TimelineChart receives tracks with bars (each bar having start time, end time, type)
|
||||
- **THEN** it SHALL render a horizontal time axis and one row per track
|
||||
- **THEN** each bar SHALL be positioned proportionally along the time axis with width reflecting duration
|
||||
|
||||
#### Scenario: Color mapping
|
||||
- **WHEN** bars have different `type` values
|
||||
- **THEN** each type SHALL be rendered with a color from the provided `colorMap` prop
|
||||
- **THEN** a legend SHALL be displayed showing type-to-color mapping
|
||||
|
||||
#### Scenario: Event markers
|
||||
- **WHEN** the component receives events (point-in-time markers)
|
||||
- **THEN** each event SHALL render as a marker (diamond/triangle icon) at the corresponding time position on its track
|
||||
- **THEN** hovering over a marker SHALL display a tooltip with the event label and details
|
||||
|
||||
#### Scenario: Time axis adapts to data range
|
||||
- **WHEN** the timeline data spans hours
|
||||
- **THEN** the time axis SHALL show hour ticks (e.g., 06:00, 07:00, ...)
|
||||
- **WHEN** the timeline data spans days
|
||||
- **THEN** the time axis SHALL show date ticks (e.g., 02-10, 02-11, ...)
|
||||
|
||||
#### Scenario: Horizontal scroll for long timelines
|
||||
- **WHEN** the timeline data exceeds the visible width
|
||||
- **THEN** the component SHALL support horizontal scrolling
|
||||
- **THEN** track labels on the left SHALL remain fixed (sticky) during scroll
|
||||
|
||||
#### Scenario: Bar tooltip on hover
|
||||
- **WHEN** the user hovers over a bar segment
|
||||
- **THEN** a tooltip SHALL display the bar's label, start time, end time, and duration
|
||||
|
||||
### Requirement: TimelineChart SHALL support multi-layer overlapping tracks
|
||||
The component SHALL support rendering multiple bar layers on a single track row (e.g., status bars behind lot bars).
|
||||
|
||||
#### Scenario: Overlapping layers
|
||||
- **WHEN** a track has multiple layers (e.g., `statusBars` and `lotBars`)
|
||||
- **THEN** background layer bars SHALL render behind foreground layer bars
|
||||
- **THEN** both layers SHALL be visible (foreground bars shorter in height or semi-transparent)
|
||||
|
||||
### Requirement: TimelineChart SHALL have no external charting dependencies
|
||||
The component SHALL be implemented using only SVG elements and CSS, with no external charting library.
|
||||
|
||||
#### Scenario: Zero additional dependencies
|
||||
- **WHEN** the TimelineChart component is used
|
||||
- **THEN** it SHALL NOT require any npm package not already in the project
|
||||
- **THEN** rendering SHALL use inline SVG elements within the Vue template
|
||||
@@ -0,0 +1,67 @@
|
||||
## 1. App Shell + Tab Layout
|
||||
|
||||
- [x] 1.1 Rewrite `App.vue` as tab shell with "LOT 追蹤" and "設備查詢" top-level tabs using Tailwind (gradient header, tab buttons, active indicator)
|
||||
- [x] 1.2 Implement URL `?tab=lot|equipment` sync — persist and restore active tab on page load
|
||||
- [x] 1.3 Rewrite `main.js` to minimal Vite entry point (createApp + mount, ~5 lines)
|
||||
- [x] 1.4 Delete `style.css` — all styling via Tailwind from this point forward
|
||||
|
||||
## 2. QueryBar + Resolve
|
||||
|
||||
- [x] 2.1 Create `QueryBar.vue` — input type selector (lot_id/serial_number/work_order), multi-line textarea, resolve button, Tailwind styling consistent with other pages' FilterToolbar patterns
|
||||
- [x] 2.2 Create `useLotResolve.js` composable — input parsing (split by newline/comma), validation (empty check, max limits), `POST /api/query-tool/resolve` call, reactive state (`resolvedLots`, `notFound`, `loading.resolving`, `errorMessage`)
|
||||
- [x] 2.3 Wire QueryBar into `LotTraceView.vue` — resolve results feed into the lineage tree below
|
||||
|
||||
## 3. Lineage Tree with Progressive Growth
|
||||
|
||||
- [x] 3.1 Create `useLotLineage.js` composable — auto-fire lineage calls after resolve, concurrency limiter (max 3 concurrent), per-lot reactive cache (`lineageMap: Map<containerId, {ancestors, merges, loading, error}>`), expand/collapse state (`expandedNodes: Set`), cache clearing on new resolve, 429 backoff handling
|
||||
- [x] 3.2 Create `LineageNode.vue` recursive component — displays node label (container name, lot type icon for merge 🔀), expand/collapse toggle for non-leaf nodes, emits `select` event on click, highlight for selected node
|
||||
- [x] 3.3 Create `LineageTree.vue` — renders resolved lots as root nodes, wraps children in `<TransitionGroup>` for growth animation, "全部展開" / "收合" buttons, not-found warnings below tree
|
||||
- [x] 3.4 Implement CSS transitions for tree growth — `translateX(-16px)→0` + `opacity 0→1` enter transition, 30-50ms stagger via `transition-delay` on siblings, root nodes fade-in on resolve
|
||||
|
||||
## 4. Left-Right Master-Detail Layout
|
||||
|
||||
- [x] 4.1 Create `LotTraceView.vue` — left-right split layout (left panel ~280-300px for lineage tree, right panel fills remaining), responsive stacking below 1024px
|
||||
- [x] 4.2 Create `LotDetail.vue` — right panel container with sub-tab bar (歷程, 物料, 退貨, Hold, Split, Job), active tab indicator, on-demand data loading when tab activated, contextual error display
|
||||
- [x] 4.3 Wire tree node selection → right panel detail loading — clicking any node in LineageTree sets `selectedContainerId` and triggers active sub-tab data fetch
|
||||
|
||||
## 5. LOT Detail Sub-tabs
|
||||
|
||||
- [x] 5.1 Create `useLotDetail.js` composable — manages `activeSubTab`, per-tab data cache (invalidated on lot change), `GET /api/query-tool/lot-history` with workcenter group filter, `GET /api/query-tool/lot-associations?type=X` for each association type, loading/error state per sub-tab
|
||||
- [x] 5.2 Create `LotHistoryTable.vue` — production history table with sticky headers, workcenter group MultiSelect filter, dynamic columns, horizontal scroll
|
||||
- [x] 5.3 Create `LotAssociationTable.vue` — shared table component for materials/rejects/holds/splits/jobs, dynamic columns from response, empty state message
|
||||
- [x] 5.4 Add per-sub-tab `ExportButton.vue` — calls `POST /api/query-tool/export-csv` with appropriate `export_type` and `container_id`, disabled when no data, download blob as CSV
|
||||
|
||||
## 6. TimelineChart Shared Component
|
||||
|
||||
- [x] 6.1 Create `TimelineChart.vue` — props interface (`tracks`, `events`, `timeRange`, `colorMap`), SVG rendering with horizontal time axis, auto-scaled ticks (hour/day granularity), sticky left track labels
|
||||
- [x] 6.2 Implement multi-layer bar rendering — background layer behind foreground layer per track, proportional positioning from time range, color from colorMap
|
||||
- [x] 6.3 Implement event markers — diamond/triangle SVG icons at time positions, hover tooltip with event label and detail
|
||||
- [x] 6.4 Implement horizontal scroll container — overflow-x scroll wrapper, sticky label column, responsive width
|
||||
|
||||
## 7. LOT Production Timeline
|
||||
|
||||
- [x] 7.1 Create `LotTimeline.vue` — maps lot-history rows to TimelineChart tracks (one track per WORKCENTERNAME, bars from TRACKINTIMESTAMP to TRACKOUTTIMESTAMP), respects workcenter group filter
|
||||
- [x] 7.2 Overlay hold/material event markers on timeline — fetches hold events and material consumption events, renders as markers on corresponding time positions
|
||||
- [x] 7.3 Integrate into History sub-tab — timeline renders above or alongside the history table, shares the workcenter filter state
|
||||
|
||||
## 8. Equipment Tab
|
||||
|
||||
- [x] 8.1 Create `EquipmentView.vue` — filter bar (MultiSelect for equipment, date range inputs, query button), sub-tab bar (生產紀錄, 維修紀錄, 報廢紀錄, Timeline), shared filter state across sub-tabs
|
||||
- [x] 8.2 Create `useEquipmentQuery.js` composable — equipment list bootstrap from `GET /api/query-tool/equipment-list`, date range default (last 30 days), `POST /api/query-tool/equipment-period` calls per query type, loading/error per sub-tab, URL state sync for equipment_ids + dates
|
||||
- [x] 8.3 Create `EquipmentLotsTable.vue` — production lots table (CONTAINERID, SPECNAME, TRACK_IN/OUT, QTY, EQUIPMENTNAME), sticky headers, export button
|
||||
- [x] 8.4 Create `EquipmentJobsPanel.vue` — maintenance job table (JOBID, STATUS, CAUSECODENAME, REPAIRCODENAME, SYMPTOMCODENAME, dates), expandable row detail (employee names, secondary codes, CONTAINERNAMES), export button
|
||||
- [x] 8.5 Create `EquipmentRejectsTable.vue` — scrap records table (EQUIPMENTNAME, LOSSREASONNAME, TOTAL_REJECT_QTY, TOTAL_DEFECT_QTY, AFFECTED_LOT_COUNT), export button
|
||||
|
||||
## 9. Equipment Timeline
|
||||
|
||||
- [x] 9.1 Create `EquipmentTimeline.vue` — composes 3 data sources (status_hours + lots + jobs) into TimelineChart tracks, fires 3 API calls in parallel, one track per equipment
|
||||
- [x] 9.2 Map status data to background layer bars — PRD=green, SBY=amber, UDT=red, SDT=blue-gray
|
||||
- [x] 9.3 Map lot data to foreground layer bars — lot processing bars from track-in to track-out
|
||||
- [x] 9.4 Map maintenance jobs to event markers — JOBID + CAUSECODENAME as label, tooltip with full detail
|
||||
|
||||
## 10. Polish + Cleanup
|
||||
|
||||
- [x] 10.1 Full URL state sync — all filter values (tab, input_type, container_id, workcenter_groups, equipment_ids, dates, sub-tabs) persisted to URL and restored on page load via `useQueryState` or custom sync
|
||||
- [x] 10.2 Responsive layout testing — verify left-right split stacks correctly below 1024px, tab layout works on mobile, timeline horizontal scroll works on touch
|
||||
- [x] 10.3 Delete dead code — remove old monolithic `useQueryToolData.js`, verify no imports reference deleted files
|
||||
- [x] 10.4 Visual consistency audit — verify gradient header, button styles, table styles, card borders match other modernized pages (wip-detail, hold-detail, mid-section-defect)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-13
|
||||
46
openspec/changes/reject-history-query-page/proposal.md
Normal file
46
openspec/changes/reject-history-query-page/proposal.md
Normal file
@@ -0,0 +1,46 @@
|
||||
## Why
|
||||
|
||||
目前專案只有在 `query-tool` 的設備子頁籤提供報廢相關查詢,缺少一個可長期追蹤「報廢歷史」的專用報表頁。隨著既有架構已完成 portal-shell、route contract、Vite 多頁治理,現在適合用同一套架構新增 `報廢歷史查詢`,避免再引入獨立樣式或旁路流程。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `報廢歷史查詢` 頁面路由 `/reject-history`,採用既有 pure Vite + portal-shell native route 模式。
|
||||
- 新增後端 `reject-history` API 群組(摘要、趨勢、原因 Pareto、明細、匯出),提供前端報表所需資料。
|
||||
- 新增 `reject-history` service + SQL 模組,統一計算:
|
||||
- 扣帳報廢:`REJECT_TOTAL_QTY = REJECTQTY + STANDBYQTY + QTYTOPROCESS + INPROCESSQTY + PROCESSEDQTY`
|
||||
- 不扣帳報廢:`DEFECT_QTY = DEFECTQTY`
|
||||
- 將新頁面納入既有導航與契約治理(page registry、drawer、routeContracts、nativeModuleRegistry、shell coverage)。
|
||||
- 補齊對應測試(API/服務單元測試、route contract 治理測試、必要的頁面整合測試)。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `reject-history-page`: 新增報廢歷史查詢頁面,提供篩選、KPI、趨勢、原因分析與明細查詢/匯出。
|
||||
- `reject-history-api`: 新增報廢歷史 API 能力與資料聚合邏輯,定義扣帳報廢與不扣帳報廢的並列指標語義。
|
||||
|
||||
### Modified Capabilities
|
||||
- `unified-shell-route-coverage`: 新增 `/reject-history` 後,路由契約清單與前後端契約對照規則需同步更新。
|
||||
- `vue-vite-page-architecture`: 新頁面需納入 Vite entry/output 與 Flask static HTML 服務規範,保持既有純 Vite 頁治理一致性。
|
||||
|
||||
## Impact
|
||||
|
||||
- 前端:
|
||||
- 新增 `frontend/src/reject-history/`(`App.vue`、`main.js`/`index.html`、components、composables、style)
|
||||
- 更新 `frontend/src/portal-shell/nativeModuleRegistry.js`
|
||||
- 更新 `frontend/src/portal-shell/routeContracts.js`
|
||||
- 更新 `frontend/vite.config.js`
|
||||
- 後端:
|
||||
- 新增 `src/mes_dashboard/routes/reject_history_routes.py`
|
||||
- 新增 `src/mes_dashboard/services/reject_history_service.py`
|
||||
- 新增 `src/mes_dashboard/sql/reject_history/*.sql`
|
||||
- 更新 `src/mes_dashboard/routes/__init__.py`
|
||||
- 更新 `src/mes_dashboard/app.py`(`/reject-history` 靜態頁 route)
|
||||
- 導航/治理:
|
||||
- 更新 `data/page_status.json`(drawer 與頁面可見性)
|
||||
- 更新 shell route contract 對應治理資產與測試基準
|
||||
- 測試:
|
||||
- 新增 `tests/test_reject_history_service.py`
|
||||
- 新增 `tests/test_reject_history_routes.py`
|
||||
- 補充 route coverage / contract parity / e2e smoke
|
||||
- 依賴:
|
||||
- 不新增第三方套件,沿用現有 Flask + Vue + Vite + SQLLoader + QueryBuilder 架構
|
||||
@@ -1,64 +1,16 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: useTraceProgress composable SHALL orchestrate staged fetching with reactive state
|
||||
`useTraceProgress` SHALL provide a shared composable for sequential stage fetching with per-stage reactive state updates.
|
||||
|
||||
#### Scenario: Normal three-stage fetch sequence
|
||||
- **WHEN** `useTraceProgress` is invoked with profile and params
|
||||
- **THEN** it SHALL execute seed-resolve → lineage → events sequentially
|
||||
- **THEN** after each stage completes, `current_stage` and `completed_stages` reactive refs SHALL update immediately
|
||||
- **THEN** `stage_results` SHALL accumulate results from completed stages
|
||||
|
||||
#### Scenario: Stage failure does not block completed results
|
||||
- **WHEN** the lineage stage fails after seed-resolve has completed
|
||||
- **THEN** seed-resolve results SHALL remain visible and accessible
|
||||
- **THEN** the error SHALL be captured in stage-specific error state
|
||||
- **THEN** subsequent stages (events) SHALL NOT execute
|
||||
|
||||
### Requirement: mid-section-defect SHALL render progressively as stages complete
|
||||
The mid-section-defect page SHALL display partial results as each trace stage completes.
|
||||
|
||||
#### Scenario: Seed lots visible before lineage completes
|
||||
- **WHEN** seed-resolve stage completes (≤3s for ≥10 seed lots)
|
||||
- **THEN** the seed lots list SHALL be rendered immediately
|
||||
- **THEN** lineage and events sections SHALL show skeleton placeholders
|
||||
|
||||
#### Scenario: KPI/charts visible after events complete
|
||||
- **WHEN** lineage and events stages complete
|
||||
- **THEN** KPI cards and charts SHALL render with fade-in animation
|
||||
- **THEN** no layout shift SHALL occur (skeleton placeholders SHALL have matching dimensions)
|
||||
|
||||
#### Scenario: Detail table pagination unchanged
|
||||
- **WHEN** the user requests detail data
|
||||
- **THEN** the existing detail endpoint with pagination SHALL be used (not the staged API)
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: query-tool lineage tab SHALL load on-demand
|
||||
The query-tool lineage tab SHALL load lineage data for individual lots on user interaction, not batch-load all lots.
|
||||
The query-tool lineage tree SHALL auto-fire lineage API calls after lot resolution with concurrency-limited parallel requests and progressive rendering, while preserving on-demand expand/collapse for tree navigation.
|
||||
|
||||
#### Scenario: User clicks a lot to view lineage
|
||||
- **WHEN** the user clicks a lot card to expand lineage information
|
||||
- **THEN** lineage SHALL be fetched via `/api/trace/lineage` for that single lot's container IDs
|
||||
- **THEN** response time SHALL be ≤3s for the individual lot
|
||||
#### Scenario: Auto-fire lineage after resolve
|
||||
- **WHEN** lot resolution completes with N resolved lots
|
||||
- **THEN** lineage SHALL be fetched via `POST /api/trace/lineage` for each lot automatically
|
||||
- **THEN** concurrent requests SHALL be limited to 3 at a time to respect rate limits (10/60s)
|
||||
- **THEN** response time SHALL be ≤3s per individual lot
|
||||
|
||||
#### Scenario: Multiple lots expanded
|
||||
- **WHEN** the user expands lineage for multiple lots
|
||||
- **THEN** each lot's lineage SHALL be fetched independently (not batch)
|
||||
- **THEN** already-fetched lineage data SHALL be preserved (not re-fetched)
|
||||
|
||||
### Requirement: Both pages SHALL display a stage progress indicator
|
||||
Both mid-section-defect and query-tool SHALL display a progress indicator showing the current trace stage.
|
||||
|
||||
#### Scenario: Progress indicator during staged fetch
|
||||
- **WHEN** a trace query is in progress
|
||||
- **THEN** a progress indicator SHALL display the current stage (seed → lineage → events)
|
||||
- **THEN** completed stages SHALL be visually distinct from pending/active stages
|
||||
- **THEN** the indicator SHALL replace the existing single loading spinner
|
||||
|
||||
### Requirement: Legacy static query-tool.js SHALL be removed
|
||||
The pre-Vite static file `src/mes_dashboard/static/js/query-tool.js` (3056L, 126KB) SHALL be deleted as dead code.
|
||||
|
||||
#### Scenario: Dead code removal verification
|
||||
- **WHEN** `static/js/query-tool.js` is deleted
|
||||
- **THEN** grep for `static/js/query-tool.js` SHALL return zero results across the codebase
|
||||
- **THEN** `query_tool.html` template SHALL continue to function via `frontend_asset('query-tool.js')` which resolves to the Vite-built bundle
|
||||
- **THEN** `frontend/src/query-tool/main.js` (Vue 3 Vite entry) SHALL remain unaffected
|
||||
#### Scenario: Multiple lots lineage results cached
|
||||
- **WHEN** lineage data has been fetched for multiple lots
|
||||
- **THEN** each lot's lineage data SHALL be preserved independently (not re-fetched)
|
||||
- **WHEN** a new resolve query is executed
|
||||
- **THEN** all cached lineage data SHALL be cleared
|
||||
|
||||
72
openspec/specs/query-tool-equipment/spec.md
Normal file
72
openspec/specs/query-tool-equipment/spec.md
Normal file
@@ -0,0 +1,72 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Equipment tab SHALL provide equipment selection with date range filtering
|
||||
The equipment tab SHALL allow selecting multiple equipment and a date range for all sub-tab queries.
|
||||
|
||||
#### Scenario: Equipment and date selection
|
||||
- **WHEN** the user opens the equipment tab
|
||||
- **THEN** a MultiSelect dropdown SHALL list available equipment from `GET /api/query-tool/equipment-list`
|
||||
- **THEN** date inputs SHALL default to the last 30 days
|
||||
- **THEN** a query button SHALL trigger data loading for the active sub-tab
|
||||
|
||||
#### Scenario: Shared filter state across sub-tabs
|
||||
- **WHEN** the user selects equipment and date range then switches sub-tabs
|
||||
- **THEN** the filter values SHALL persist across all equipment sub-tabs
|
||||
- **THEN** switching to a new sub-tab with the same filters SHALL trigger a fresh query for that sub-tab's data type
|
||||
|
||||
### Requirement: Equipment Production Lots sub-tab SHALL display lots processed by selected equipment
|
||||
The Production Lots sub-tab SHALL show all lots processed on the selected equipment within the date range.
|
||||
|
||||
#### Scenario: Production lots query
|
||||
- **WHEN** the user queries with selected equipment and date range
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/equipment-period` with `query_type: "lots"`
|
||||
- **THEN** results SHALL display in a table showing CONTAINERID, SPECNAME, TRACK_IN, TRACK_OUT, QTY, EQUIPMENTNAME
|
||||
|
||||
#### Scenario: Partial track-out handling
|
||||
- **WHEN** a lot has multiple track-out records (partial processing)
|
||||
- **THEN** all records SHALL be displayed (not deduplicated)
|
||||
|
||||
### Requirement: Equipment Maintenance sub-tab SHALL display maintenance job records with expandable detail
|
||||
The Maintenance sub-tab SHALL show maintenance jobs from `DW_MES_JOB` with cause/repair/symptom codes.
|
||||
|
||||
#### Scenario: Maintenance job list
|
||||
- **WHEN** the user queries maintenance records
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/equipment-period` with `query_type: "jobs"`
|
||||
- **THEN** results SHALL display: JOBID, STATUS, CAUSECODENAME, REPAIRCODENAME, SYMPTOMCODENAME, CREATE/COMPLETE dates
|
||||
|
||||
#### Scenario: Job detail expansion
|
||||
- **WHEN** the user clicks on a maintenance job row
|
||||
- **THEN** the row SHALL expand to show full detail including employee names, secondary codes, and related lot IDs (CONTAINERNAMES)
|
||||
|
||||
### Requirement: Equipment Scrap sub-tab SHALL display reject/defect records
|
||||
The Scrap sub-tab SHALL show reject statistics grouped by loss reason for the selected equipment and date range.
|
||||
|
||||
#### Scenario: Scrap records query
|
||||
- **WHEN** the user queries scrap records
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/equipment-period` with `query_type: "rejects"`
|
||||
- **THEN** results SHALL display: EQUIPMENTNAME, LOSSREASONNAME, TOTAL_REJECT_QTY, TOTAL_DEFECT_QTY, AFFECTED_LOT_COUNT
|
||||
|
||||
### Requirement: Equipment Timeline sub-tab SHALL visualize equipment activity over time
|
||||
The Timeline sub-tab SHALL render a Gantt-style timeline showing equipment status, lots processed, and maintenance events.
|
||||
|
||||
#### Scenario: Multi-layer timeline rendering
|
||||
- **WHEN** the user views the equipment timeline
|
||||
- **THEN** the timeline SHALL overlay three data layers: status bars (PRD/SBY/UDT/SDT), lot processing bars, and maintenance event markers
|
||||
- **THEN** each equipment SHALL appear as a separate track row
|
||||
|
||||
#### Scenario: Status color coding
|
||||
- **WHEN** the timeline renders status bars
|
||||
- **THEN** PRD SHALL be green, SBY SHALL be amber, UDT SHALL be red, SDT SHALL be blue-gray
|
||||
- **THEN** a legend SHALL be displayed showing the color mapping
|
||||
|
||||
#### Scenario: Maintenance marker interaction
|
||||
- **WHEN** the user hovers over or clicks a maintenance event marker on the timeline
|
||||
- **THEN** a tooltip or expanded panel SHALL show the job detail (CAUSECODENAME, REPAIRCODENAME, SYMPTOMCODENAME)
|
||||
|
||||
### Requirement: Each equipment sub-tab SHALL support CSV export
|
||||
Every equipment sub-tab SHALL have its own export button calling the existing export API.
|
||||
|
||||
#### Scenario: Equipment CSV export
|
||||
- **WHEN** the user clicks export on any equipment sub-tab
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/export-csv` with the appropriate `export_type` (equipment_lots, equipment_jobs, equipment_rejects)
|
||||
- **THEN** the exported params SHALL include the current equipment_ids/equipment_names and date range
|
||||
152
openspec/specs/query-tool-lot-trace/spec.md
Normal file
152
openspec/specs/query-tool-lot-trace/spec.md
Normal file
@@ -0,0 +1,152 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Query-tool page SHALL use tab-based layout separating LOT tracing from equipment queries
|
||||
The query-tool page SHALL present two top-level tabs: "LOT 追蹤" and "設備查詢", each with independent state and UI.
|
||||
|
||||
#### Scenario: Tab switching preserves state
|
||||
- **WHEN** the user switches from LOT tab to Equipment tab and back
|
||||
- **THEN** the LOT tab SHALL retain its resolved lots, lineage tree state, and selected lot detail
|
||||
- **THEN** the Equipment tab SHALL retain its query results independently
|
||||
|
||||
#### Scenario: URL state reflects active tab
|
||||
- **WHEN** the user is on a specific tab
|
||||
- **THEN** the URL SHALL include a `tab` parameter (e.g., `?tab=lot` or `?tab=equipment`)
|
||||
- **THEN** reloading the page SHALL restore the active tab
|
||||
|
||||
### Requirement: QueryBar SHALL resolve LOT/Serial/WorkOrder inputs
|
||||
The query bar SHALL accept multi-value input (newline or comma-separated) with input type selection and resolve via `POST /api/query-tool/resolve`.
|
||||
|
||||
#### Scenario: Successful LOT resolution
|
||||
- **WHEN** the user enters lot IDs and clicks resolve
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/resolve` with `input_type` and `values`
|
||||
- **THEN** resolved lots SHALL appear as root nodes in the lineage tree
|
||||
- **THEN** not-found values SHALL be displayed as warnings below the tree
|
||||
|
||||
#### Scenario: Work order expansion
|
||||
- **WHEN** the user enters work order IDs (max 10)
|
||||
- **THEN** each work order MAY expand to 100+ lots
|
||||
- **THEN** all expanded lots SHALL appear as root nodes in the lineage tree
|
||||
|
||||
#### Scenario: Input validation
|
||||
- **WHEN** the user submits empty input or exceeds limits (50 lot IDs, 50 serial numbers, 10 work orders)
|
||||
- **THEN** the system SHALL display a validation error without making an API call
|
||||
|
||||
### Requirement: LineageTree SHALL display as a decomposition tree with progressive growth animation
|
||||
After resolve completes, the lineage tree SHALL auto-fire lineage API calls for each resolved lot with concurrency control, rendering an animated tree that grows as results arrive.
|
||||
|
||||
#### Scenario: Auto-fire lineage after resolve
|
||||
- **WHEN** lot resolution completes with N resolved lots
|
||||
- **THEN** the system SHALL automatically call `POST /api/trace/lineage` for each lot
|
||||
- **THEN** concurrent lineage requests SHALL be limited to 3 at a time
|
||||
- **THEN** the lineage tree SHALL render root nodes immediately (resolved lots)
|
||||
|
||||
#### Scenario: Progressive tree growth animation
|
||||
- **WHEN** a lineage API response returns for a lot
|
||||
- **THEN** ancestor nodes SHALL animate into the tree (slide-in + fade, staggered 30-50ms per sibling)
|
||||
- **THEN** the animation SHALL give the visual impression of a tree "growing" its branches
|
||||
|
||||
#### Scenario: Tree node expand/collapse
|
||||
- **WHEN** the user clicks a tree node with children (ancestors)
|
||||
- **THEN** children SHALL toggle between expanded and collapsed state
|
||||
- **THEN** expand/collapse SHALL be a client-side operation (no additional API call)
|
||||
|
||||
#### Scenario: Expand-all and collapse-all
|
||||
- **WHEN** the user clicks "全部展開"
|
||||
- **THEN** all tree nodes at all levels SHALL expand with staggered animation
|
||||
- **WHEN** the user clicks "收合"
|
||||
- **THEN** all tree nodes SHALL collapse to show only root nodes
|
||||
|
||||
#### Scenario: Merge relationships visually distinct
|
||||
- **WHEN** the lineage data includes merge relationships
|
||||
- **THEN** merge nodes SHALL display a distinct icon and/or color to differentiate from direct ancestor relationships
|
||||
|
||||
#### Scenario: Leaf nodes without expand affordance
|
||||
- **WHEN** a tree node has no ancestors (leaf/terminal node)
|
||||
- **THEN** it SHALL NOT display an expand button or clickable expand area
|
||||
|
||||
#### Scenario: Lineage cache prevents duplicate fetches
|
||||
- **WHEN** lineage data has already been fetched for a lot
|
||||
- **THEN** subsequent interactions SHALL use cached data without re-fetching
|
||||
- **WHEN** a new resolve query is executed
|
||||
- **THEN** the lineage cache SHALL be cleared
|
||||
|
||||
### Requirement: Left-right master-detail layout SHALL show tree and LOT detail side by side
|
||||
The LOT tracing tab SHALL use a left-right split layout with the lineage tree on the left and LOT detail on the right.
|
||||
|
||||
#### Scenario: LOT selection from tree
|
||||
- **WHEN** the user clicks any node in the lineage tree (root lot or ancestor)
|
||||
- **THEN** the right panel SHALL load detail for that node's container ID
|
||||
- **THEN** the selected node SHALL be visually highlighted in the tree
|
||||
|
||||
#### Scenario: Right panel sub-tabs
|
||||
- **WHEN** a LOT is selected
|
||||
- **THEN** the right panel SHALL display sub-tabs: 歷程 (History), 物料 (Materials), 退貨 (Rejects), Hold, Split, Job
|
||||
- **THEN** each sub-tab SHALL load data on-demand when activated (not pre-fetched)
|
||||
|
||||
#### Scenario: Responsive behavior
|
||||
- **WHEN** the viewport width is below 1024px
|
||||
- **THEN** the layout SHALL stack vertically (tree above, detail below)
|
||||
|
||||
### Requirement: LOT History sub-tab SHALL display production history with workcenter filter
|
||||
The History sub-tab SHALL show production history data from `GET /api/query-tool/lot-history` with workcenter group filtering.
|
||||
|
||||
#### Scenario: History table display
|
||||
- **WHEN** the user selects the History sub-tab for a LOT
|
||||
- **THEN** the system SHALL call `GET /api/query-tool/lot-history?container_id=X`
|
||||
- **THEN** results SHALL display in a table with sticky headers and horizontal scroll
|
||||
|
||||
#### Scenario: Workcenter group filter
|
||||
- **WHEN** the user selects workcenter groups from the filter dropdown
|
||||
- **THEN** the history query SHALL include the selected groups as filter parameters
|
||||
- **THEN** the history table SHALL refresh with filtered results
|
||||
|
||||
### Requirement: LOT Production Timeline SHALL visualize station progression over time
|
||||
The History sub-tab SHALL include a timeline visualization showing the lot's journey through production stations.
|
||||
|
||||
#### Scenario: Timeline rendering
|
||||
- **WHEN** lot history data is loaded
|
||||
- **THEN** a horizontal Gantt-style timeline SHALL render with time on the X-axis
|
||||
- **THEN** each workcenter/station SHALL appear as a track with a colored bar from track-in to track-out time
|
||||
|
||||
#### Scenario: Workcenter filter affects timeline
|
||||
- **WHEN** the user filters by workcenter groups
|
||||
- **THEN** the timeline SHALL show only stations matching the selected groups
|
||||
- **THEN** filtered-out stations SHALL be hidden (not grayed out)
|
||||
|
||||
#### Scenario: Timeline event markers
|
||||
- **WHEN** hold events or material consumption events exist within the timeline range
|
||||
- **THEN** they SHALL be displayed as markers on the timeline with tooltip details on hover
|
||||
|
||||
### Requirement: LOT Association sub-tabs SHALL load data on-demand
|
||||
Each association sub-tab (Materials, Rejects, Holds, Splits, Jobs) SHALL fetch data independently when activated.
|
||||
|
||||
#### Scenario: Association data loading
|
||||
- **WHEN** the user activates a sub-tab (e.g., Materials)
|
||||
- **THEN** the system SHALL call `GET /api/query-tool/lot-associations?container_id=X&type=materials`
|
||||
- **THEN** results SHALL display in a table with dynamic columns
|
||||
|
||||
#### Scenario: Sub-tab data caching within session
|
||||
- **WHEN** the user switches between sub-tabs for the same LOT
|
||||
- **THEN** previously loaded sub-tab data SHALL be preserved (not re-fetched)
|
||||
- **WHEN** the user selects a different LOT
|
||||
- **THEN** all sub-tab caches SHALL be cleared
|
||||
|
||||
### Requirement: Each sub-tab SHALL support independent CSV export
|
||||
Every detail sub-tab SHALL have its own export button.
|
||||
|
||||
#### Scenario: Per-tab export
|
||||
- **WHEN** the user clicks export on the Materials sub-tab
|
||||
- **THEN** the system SHALL call `POST /api/query-tool/export-csv` with `export_type: "lot_materials"` and the current container_id
|
||||
- **THEN** a CSV file SHALL download with the appropriate filename
|
||||
|
||||
#### Scenario: Export disabled when no data
|
||||
- **WHEN** a sub-tab has no data loaded or the data is empty
|
||||
- **THEN** the export button SHALL be disabled
|
||||
|
||||
### Requirement: Legacy dead code SHALL be removed
|
||||
The legacy `frontend/src/query-tool/main.js` (448L vanilla JS) and `frontend/src/query-tool/style.css` SHALL be deleted.
|
||||
|
||||
#### Scenario: Dead code removal
|
||||
- **WHEN** the rewrite is complete
|
||||
- **THEN** `frontend/src/query-tool/main.js` SHALL contain only the Vite entry point (createApp + mount)
|
||||
- **THEN** `frontend/src/query-tool/style.css` SHALL be deleted (all styling via Tailwind)
|
||||
50
openspec/specs/timeline-chart/spec.md
Normal file
50
openspec/specs/timeline-chart/spec.md
Normal file
@@ -0,0 +1,50 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: TimelineChart component SHALL render configurable Gantt-style timelines
|
||||
A shared `TimelineChart` component SHALL accept structured track/event data and render a horizontal timeline with SVG/CSS.
|
||||
|
||||
#### Scenario: Basic track rendering
|
||||
- **WHEN** TimelineChart receives tracks with bars (each bar having start time, end time, type)
|
||||
- **THEN** it SHALL render a horizontal time axis and one row per track
|
||||
- **THEN** each bar SHALL be positioned proportionally along the time axis with width reflecting duration
|
||||
|
||||
#### Scenario: Color mapping
|
||||
- **WHEN** bars have different `type` values
|
||||
- **THEN** each type SHALL be rendered with a color from the provided `colorMap` prop
|
||||
- **THEN** a legend SHALL be displayed showing type-to-color mapping
|
||||
|
||||
#### Scenario: Event markers
|
||||
- **WHEN** the component receives events (point-in-time markers)
|
||||
- **THEN** each event SHALL render as a marker (diamond/triangle icon) at the corresponding time position on its track
|
||||
- **THEN** hovering over a marker SHALL display a tooltip with the event label and details
|
||||
|
||||
#### Scenario: Time axis adapts to data range
|
||||
- **WHEN** the timeline data spans hours
|
||||
- **THEN** the time axis SHALL show hour ticks (e.g., 06:00, 07:00, ...)
|
||||
- **WHEN** the timeline data spans days
|
||||
- **THEN** the time axis SHALL show date ticks (e.g., 02-10, 02-11, ...)
|
||||
|
||||
#### Scenario: Horizontal scroll for long timelines
|
||||
- **WHEN** the timeline data exceeds the visible width
|
||||
- **THEN** the component SHALL support horizontal scrolling
|
||||
- **THEN** track labels on the left SHALL remain fixed (sticky) during scroll
|
||||
|
||||
#### Scenario: Bar tooltip on hover
|
||||
- **WHEN** the user hovers over a bar segment
|
||||
- **THEN** a tooltip SHALL display the bar's label, start time, end time, and duration
|
||||
|
||||
### Requirement: TimelineChart SHALL support multi-layer overlapping tracks
|
||||
The component SHALL support rendering multiple bar layers on a single track row (e.g., status bars behind lot bars).
|
||||
|
||||
#### Scenario: Overlapping layers
|
||||
- **WHEN** a track has multiple layers (e.g., `statusBars` and `lotBars`)
|
||||
- **THEN** background layer bars SHALL render behind foreground layer bars
|
||||
- **THEN** both layers SHALL be visible (foreground bars shorter in height or semi-transparent)
|
||||
|
||||
### Requirement: TimelineChart SHALL have no external charting dependencies
|
||||
The component SHALL be implemented using only SVG elements and CSS, with no external charting library.
|
||||
|
||||
#### Scenario: Zero additional dependencies
|
||||
- **WHEN** the TimelineChart component is used
|
||||
- **THEN** it SHALL NOT require any npm package not already in the project
|
||||
- **THEN** rendering SHALL use inline SVG elements within the Vue template
|
||||
@@ -34,6 +34,7 @@ logger = logging.getLogger("mes_dashboard.trace_routes")
|
||||
trace_bp = Blueprint("trace", __name__, url_prefix="/api/trace")
|
||||
|
||||
TRACE_STAGE_TIMEOUT_SECONDS = 10.0
|
||||
TRACE_LINEAGE_TIMEOUT_SECONDS = 60.0
|
||||
TRACE_CACHE_TTL_SECONDS = 300
|
||||
|
||||
PROFILE_QUERY_TOOL = "query_tool"
|
||||
@@ -220,7 +221,13 @@ def _seed_resolve_mid_section_defect(
|
||||
return result, None
|
||||
|
||||
|
||||
def _build_lineage_response(container_ids: List[str], ancestors_raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _build_lineage_response(
|
||||
container_ids: List[str],
|
||||
ancestors_raw: Dict[str, Any],
|
||||
cid_to_name: Optional[Dict[str, str]] = None,
|
||||
parent_map: Optional[Dict[str, List[str]]] = None,
|
||||
merge_edges: Optional[Dict[str, List[str]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
normalized_ancestors: Dict[str, List[str]] = {}
|
||||
all_nodes = set(container_ids)
|
||||
for seed in container_ids:
|
||||
@@ -234,12 +241,28 @@ def _build_lineage_response(container_ids: List[str], ancestors_raw: Dict[str, A
|
||||
normalized_ancestors[seed] = normalized_list
|
||||
all_nodes.update(normalized_list)
|
||||
|
||||
return {
|
||||
response: Dict[str, Any] = {
|
||||
"stage": "lineage",
|
||||
"ancestors": normalized_ancestors,
|
||||
"merges": {},
|
||||
"total_nodes": len(all_nodes),
|
||||
}
|
||||
if cid_to_name:
|
||||
response["names"] = {
|
||||
cid: name for cid, name in cid_to_name.items()
|
||||
if cid in all_nodes and name
|
||||
}
|
||||
if parent_map:
|
||||
response["parent_map"] = {
|
||||
child: parents for child, parents in parent_map.items()
|
||||
if child in all_nodes
|
||||
}
|
||||
if merge_edges:
|
||||
response["merge_edges"] = {
|
||||
child: sources for child, sources in merge_edges.items()
|
||||
if child in all_nodes
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
def _flatten_domain_records(events_by_cid: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
||||
@@ -382,7 +405,7 @@ def lineage():
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
ancestors_raw = LineageEngine.resolve_full_genealogy(container_ids)
|
||||
forward_tree = LineageEngine.resolve_forward_tree(container_ids)
|
||||
except Exception as exc:
|
||||
if _is_timeout_exception(exc):
|
||||
return _error("LINEAGE_TIMEOUT", "lineage query timed out", 504)
|
||||
@@ -390,10 +413,18 @@ def lineage():
|
||||
return _error("LINEAGE_FAILED", "lineage stage failed", 500)
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
if elapsed > TRACE_STAGE_TIMEOUT_SECONDS:
|
||||
if elapsed > TRACE_LINEAGE_TIMEOUT_SECONDS:
|
||||
return _error("LINEAGE_TIMEOUT", "lineage query timed out", 504)
|
||||
|
||||
response = _build_lineage_response(container_ids, ancestors_raw)
|
||||
cid_to_name = forward_tree.get("cid_to_name") or {}
|
||||
response: Dict[str, Any] = {
|
||||
"stage": "lineage",
|
||||
"roots": forward_tree.get("roots", []),
|
||||
"children_map": forward_tree.get("children_map", {}),
|
||||
"leaf_serials": forward_tree.get("leaf_serials", {}),
|
||||
"names": {cid: name for cid, name in cid_to_name.items() if name},
|
||||
"total_nodes": forward_tree.get("total_nodes", 0),
|
||||
}
|
||||
cache_set(lineage_cache_key, response, ttl=TRACE_CACHE_TTL_SECONDS)
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@@ -41,6 +41,44 @@ def _safe_str(value: Any) -> Optional[str]:
|
||||
return value if value else None
|
||||
|
||||
|
||||
def _build_parent_map(
|
||||
child_to_parent: Dict[str, str],
|
||||
merge_child_to_parent: Dict[str, str],
|
||||
merge_source_map: Dict[str, List[str]],
|
||||
cid_to_name: Dict[str, str],
|
||||
) -> tuple:
|
||||
"""Build per-node direct parent lists and merge edge lists.
|
||||
|
||||
Returns:
|
||||
(parent_map, merge_edges) where:
|
||||
- parent_map: {child_cid: [direct_parent_cids]}
|
||||
- merge_edges: {child_cid: [merge_source_cids]}
|
||||
"""
|
||||
parent_map: Dict[str, List[str]] = defaultdict(list)
|
||||
merge_edges: Dict[str, List[str]] = defaultdict(list)
|
||||
|
||||
for child, parent in child_to_parent.items():
|
||||
parent_map[child].append(parent)
|
||||
|
||||
for child, parent in merge_child_to_parent.items():
|
||||
if parent not in parent_map[child]:
|
||||
parent_map[child].append(parent)
|
||||
|
||||
if merge_source_map and cid_to_name:
|
||||
name_to_cids: Dict[str, List[str]] = defaultdict(list)
|
||||
for cid, name in cid_to_name.items():
|
||||
name_to_cids[name].append(cid)
|
||||
|
||||
for name, source_cids in merge_source_map.items():
|
||||
for owner_cid in name_to_cids.get(name, []):
|
||||
for source_cid in source_cids:
|
||||
if source_cid != owner_cid and source_cid not in parent_map[owner_cid]:
|
||||
parent_map[owner_cid].append(source_cid)
|
||||
merge_edges[owner_cid].append(source_cid)
|
||||
|
||||
return dict(parent_map), dict(merge_edges)
|
||||
|
||||
|
||||
class LineageEngine:
|
||||
"""Unified split/merge genealogy resolver."""
|
||||
|
||||
@@ -148,19 +186,211 @@ class LineageEngine:
|
||||
)
|
||||
return mapped
|
||||
|
||||
@staticmethod
|
||||
def resolve_split_descendants(
|
||||
root_cids: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve split lineage downward from root(s) via CONNECT BY.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"children_map": {parent_cid: [child_cids]},
|
||||
"cid_to_name": {cid: container_name},
|
||||
}
|
||||
"""
|
||||
normalized_roots = _normalize_list(root_cids)
|
||||
children_map: Dict[str, List[str]] = defaultdict(list)
|
||||
cid_to_name: Dict[str, str] = {}
|
||||
|
||||
if not normalized_roots:
|
||||
return {"children_map": dict(children_map), "cid_to_name": cid_to_name}
|
||||
|
||||
for i in range(0, len(normalized_roots), ORACLE_IN_BATCH_SIZE):
|
||||
batch = normalized_roots[i:i + ORACLE_IN_BATCH_SIZE]
|
||||
builder = QueryBuilder()
|
||||
builder.add_in_condition("c.CONTAINERID", batch)
|
||||
|
||||
sql = SQLLoader.load_with_params(
|
||||
"lineage/split_descendants",
|
||||
ROOT_FILTER=builder.get_conditions_sql(),
|
||||
)
|
||||
|
||||
df = read_sql_df(sql, builder.params)
|
||||
if df is None or df.empty:
|
||||
continue
|
||||
|
||||
for _, row in df.iterrows():
|
||||
cid = _safe_str(row.get("CONTAINERID"))
|
||||
if not cid:
|
||||
continue
|
||||
|
||||
name = _safe_str(row.get("CONTAINERNAME"))
|
||||
if name:
|
||||
cid_to_name[cid] = name
|
||||
|
||||
depth_raw = row.get("SPLIT_DEPTH")
|
||||
depth = int(depth_raw) if depth_raw is not None else 0
|
||||
if depth > MAX_SPLIT_DEPTH:
|
||||
continue
|
||||
|
||||
parent = _safe_str(row.get("SPLITFROMID"))
|
||||
if parent and parent != cid:
|
||||
if cid not in children_map[parent]:
|
||||
children_map[parent].append(cid)
|
||||
|
||||
logger.info(
|
||||
"Split descendant resolution completed: roots=%s, edges=%s, names=%s",
|
||||
len(normalized_roots),
|
||||
sum(len(v) for v in children_map.values()),
|
||||
len(cid_to_name),
|
||||
)
|
||||
return {"children_map": dict(children_map), "cid_to_name": cid_to_name}
|
||||
|
||||
@staticmethod
|
||||
def resolve_leaf_serials(
|
||||
container_ids: List[str],
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Find finished product serial numbers for leaf lot CIDs.
|
||||
|
||||
Returns:
|
||||
{cid: [finished_names]}
|
||||
"""
|
||||
normalized_cids = _normalize_list(container_ids)
|
||||
if not normalized_cids:
|
||||
return {}
|
||||
|
||||
result: Dict[str, List[str]] = defaultdict(list)
|
||||
|
||||
for i in range(0, len(normalized_cids), ORACLE_IN_BATCH_SIZE):
|
||||
batch = normalized_cids[i:i + ORACLE_IN_BATCH_SIZE]
|
||||
builder = QueryBuilder()
|
||||
builder.add_in_condition("ca.CONTAINERID", batch)
|
||||
|
||||
sql = SQLLoader.load_with_params(
|
||||
"lineage/leaf_serial_numbers",
|
||||
CID_FILTER=builder.get_conditions_sql(),
|
||||
)
|
||||
|
||||
df = read_sql_df(sql, builder.params)
|
||||
if df is None or df.empty:
|
||||
continue
|
||||
|
||||
for _, row in df.iterrows():
|
||||
cid = _safe_str(row.get("CONTAINERID"))
|
||||
finished = _safe_str(row.get("FINISHEDNAME"))
|
||||
if cid and finished and finished not in result[cid]:
|
||||
result[cid].append(finished)
|
||||
|
||||
logger.info(
|
||||
"Leaf serial resolution completed: leaf_cids=%s, with_serials=%s",
|
||||
len(normalized_cids),
|
||||
len(result),
|
||||
)
|
||||
return dict(result)
|
||||
|
||||
@staticmethod
|
||||
def resolve_forward_tree(
|
||||
container_ids: List[str],
|
||||
initial_names: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve a forward production tree: root(wafer) → splits → leaf → serial numbers.
|
||||
|
||||
1. Trace UP from seed CIDs to find root(s) via split_ancestors
|
||||
2. Trace DOWN from root(s) to get all descendants via split_descendants
|
||||
3. For leaf nodes, query COMBINEDASSYLOTS for finished serial numbers
|
||||
|
||||
Returns:
|
||||
{
|
||||
"roots": [root_cids],
|
||||
"children_map": {parent_cid: [child_cids]},
|
||||
"leaf_serials": {leaf_cid: [finished_names]},
|
||||
"cid_to_name": {cid: container_name},
|
||||
"total_nodes": int,
|
||||
}
|
||||
"""
|
||||
seed_cids = _normalize_list(container_ids)
|
||||
empty = {
|
||||
"roots": [],
|
||||
"children_map": {},
|
||||
"leaf_serials": {},
|
||||
"cid_to_name": {},
|
||||
"total_nodes": 0,
|
||||
}
|
||||
if not seed_cids:
|
||||
return empty
|
||||
|
||||
# Step 1: Trace UP to find roots
|
||||
split_result = LineageEngine.resolve_split_ancestors(seed_cids, initial_names)
|
||||
child_to_parent = split_result["child_to_parent"]
|
||||
cid_to_name = dict(split_result["cid_to_name"])
|
||||
|
||||
# Find root CIDs: trace each seed's chain to the top
|
||||
roots_set: Set[str] = set()
|
||||
for seed in seed_cids:
|
||||
current = seed
|
||||
depth = 0
|
||||
while current in child_to_parent and depth < MAX_SPLIT_DEPTH:
|
||||
depth += 1
|
||||
parent = child_to_parent[current]
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
roots_set.add(current)
|
||||
|
||||
roots = sorted(roots_set)
|
||||
|
||||
# Step 2: Trace DOWN from roots to get full tree
|
||||
desc_result = LineageEngine.resolve_split_descendants(roots)
|
||||
children_map = desc_result["children_map"]
|
||||
cid_to_name.update(desc_result["cid_to_name"])
|
||||
|
||||
# Collect all nodes in the tree
|
||||
all_nodes: Set[str] = set(roots)
|
||||
for parent, children in children_map.items():
|
||||
all_nodes.add(parent)
|
||||
all_nodes.update(children)
|
||||
|
||||
# Step 3: Find leaf nodes (nodes with no children in children_map)
|
||||
leaf_cids = [cid for cid in all_nodes if cid not in children_map]
|
||||
|
||||
# Step 4: Query serial numbers for leaf nodes
|
||||
leaf_serials = LineageEngine.resolve_leaf_serials(leaf_cids) if leaf_cids else {}
|
||||
|
||||
logger.info(
|
||||
"Forward tree resolution completed: seeds=%s, roots=%s, nodes=%s, leaves=%s, serials=%s",
|
||||
len(seed_cids),
|
||||
len(roots),
|
||||
len(all_nodes),
|
||||
len(leaf_cids),
|
||||
len(leaf_serials),
|
||||
)
|
||||
|
||||
return {
|
||||
"roots": roots,
|
||||
"children_map": children_map,
|
||||
"leaf_serials": leaf_serials,
|
||||
"cid_to_name": cid_to_name,
|
||||
"total_nodes": len(all_nodes),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def resolve_full_genealogy(
|
||||
container_ids: List[str],
|
||||
initial_names: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Set[str]]:
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve combined split + merge genealogy graph.
|
||||
|
||||
Returns:
|
||||
{seed_cid: set(ancestor_cids)}
|
||||
{
|
||||
"ancestors": {seed_cid: set(ancestor_cids)},
|
||||
"cid_to_name": {cid: container_name},
|
||||
"parent_map": {child_cid: [direct_parent_cids]},
|
||||
"merge_edges": {child_cid: [merge_source_cids]},
|
||||
}
|
||||
"""
|
||||
seed_cids = _normalize_list(container_ids)
|
||||
if not seed_cids:
|
||||
return {}
|
||||
return {"ancestors": {}, "cid_to_name": {}, "parent_map": {}, "merge_edges": {}}
|
||||
|
||||
split_result = LineageEngine.resolve_split_ancestors(seed_cids, initial_names)
|
||||
child_to_parent = split_result["child_to_parent"]
|
||||
@@ -183,7 +413,8 @@ class LineageEngine:
|
||||
all_names = [name for name in cid_to_name.values() if _safe_str(name)]
|
||||
merge_source_map = LineageEngine.resolve_merge_sources(all_names)
|
||||
if not merge_source_map:
|
||||
return ancestors
|
||||
pm, me = _build_parent_map(child_to_parent, {}, {}, cid_to_name)
|
||||
return {"ancestors": ancestors, "cid_to_name": cid_to_name, "parent_map": pm, "merge_edges": me}
|
||||
|
||||
merge_source_cids_all: Set[str] = set()
|
||||
for seed in seed_cids:
|
||||
@@ -201,10 +432,12 @@ class LineageEngine:
|
||||
seen = set(seed_cids) | set(child_to_parent.keys()) | set(child_to_parent.values())
|
||||
new_merge_cids = list(merge_source_cids_all - seen)
|
||||
if not new_merge_cids:
|
||||
return ancestors
|
||||
pm, me = _build_parent_map(child_to_parent, {}, merge_source_map, cid_to_name)
|
||||
return {"ancestors": ancestors, "cid_to_name": cid_to_name, "parent_map": pm, "merge_edges": me}
|
||||
|
||||
merge_split_result = LineageEngine.resolve_split_ancestors(new_merge_cids)
|
||||
merge_child_to_parent = merge_split_result["child_to_parent"]
|
||||
cid_to_name.update(merge_split_result["cid_to_name"])
|
||||
|
||||
for seed in seed_cids:
|
||||
for merge_cid in list(ancestors[seed] & merge_source_cids_all):
|
||||
@@ -218,4 +451,5 @@ class LineageEngine:
|
||||
ancestors[seed].add(parent)
|
||||
current = parent
|
||||
|
||||
return ancestors
|
||||
pm, me = _build_parent_map(child_to_parent, merge_child_to_parent, merge_source_map, cid_to_name)
|
||||
return {"ancestors": ancestors, "cid_to_name": cid_to_name, "parent_map": pm, "merge_edges": me}
|
||||
|
||||
@@ -583,7 +583,8 @@ def _resolve_full_genealogy(
|
||||
tmtt_names: Dict[str, str],
|
||||
) -> Dict[str, Set[str]]:
|
||||
"""Resolve full genealogy for TMTT lots via shared LineageEngine."""
|
||||
ancestors = LineageEngine.resolve_full_genealogy(tmtt_cids, tmtt_names)
|
||||
result = LineageEngine.resolve_full_genealogy(tmtt_cids, tmtt_names)
|
||||
ancestors = result.get("ancestors", {}) if isinstance(result, dict) else result
|
||||
_log_genealogy_summary(ancestors, tmtt_cids, 0)
|
||||
return ancestors
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ def _resolve_by_serial_number(serial_numbers: List[str]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _resolve_by_work_order(work_orders: List[str]) -> Dict[str, Any]:
|
||||
"""Resolve work orders (PJ_WORKORDER) to CONTAINERID.
|
||||
"""Resolve work orders (MFGORDERNAME) to CONTAINERID.
|
||||
|
||||
Note: One work order may expand to many CONTAINERIDs (can be 100+).
|
||||
|
||||
@@ -315,7 +315,7 @@ def _resolve_by_work_order(work_orders: List[str]) -> Dict[str, Any]:
|
||||
Resolution result dict.
|
||||
"""
|
||||
builder = QueryBuilder()
|
||||
builder.add_in_condition("h.PJ_WORKORDER", work_orders)
|
||||
builder.add_in_condition("MFGORDERNAME", work_orders)
|
||||
sql = SQLLoader.load_with_params(
|
||||
"query_tool/lot_resolve_work_order",
|
||||
WORK_ORDER_FILTER=builder.get_conditions_sql(),
|
||||
@@ -327,7 +327,7 @@ def _resolve_by_work_order(work_orders: List[str]) -> Dict[str, Any]:
|
||||
# Group by work order
|
||||
wo_to_containers = {}
|
||||
for r in data:
|
||||
wo = r['PJ_WORKORDER']
|
||||
wo = r['MFGORDERNAME']
|
||||
if wo not in wo_to_containers:
|
||||
wo_to_containers[wo] = []
|
||||
wo_to_containers[wo].append({
|
||||
|
||||
13
src/mes_dashboard/sql/lineage/leaf_serial_numbers.sql
Normal file
13
src/mes_dashboard/sql/lineage/leaf_serial_numbers.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Unified LineageEngine - Leaf Serial Numbers
|
||||
-- Find finished product serial numbers (FINISHEDNAME) for leaf lot CIDs.
|
||||
-- Source: DW_MES_PJ_COMBINEDASSYLOTS (TMTT assembly merge records).
|
||||
--
|
||||
-- Parameters:
|
||||
-- CID_FILTER - QueryBuilder-generated condition on ca.CONTAINERID
|
||||
--
|
||||
SELECT DISTINCT
|
||||
ca.CONTAINERID,
|
||||
ca.FINISHEDNAME
|
||||
FROM DWH.DW_MES_PJ_COMBINEDASSYLOTS ca
|
||||
WHERE {{ CID_FILTER }}
|
||||
AND ca.FINISHEDNAME IS NOT NULL
|
||||
22
src/mes_dashboard/sql/lineage/split_descendants.sql
Normal file
22
src/mes_dashboard/sql/lineage/split_descendants.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Unified LineageEngine - Split Descendants (Forward Tree)
|
||||
-- Resolve split genealogy downward from root(s) via DW_MES_CONTAINER.SPLITFROMID
|
||||
--
|
||||
-- Parameters:
|
||||
-- ROOT_FILTER - QueryBuilder-generated condition for START WITH (root CIDs)
|
||||
--
|
||||
-- Notes:
|
||||
-- - CONNECT BY NOCYCLE PRIOR prevents infinite loops on cyclic data.
|
||||
-- - LEVEL <= 20 matches MAX_SPLIT_DEPTH guard.
|
||||
-- - Direction is reversed from split_ancestors.sql:
|
||||
-- ancestors: PRIOR SPLITFROMID = CONTAINERID (child → parent)
|
||||
-- descendants: PRIOR CONTAINERID = SPLITFROMID (parent → child)
|
||||
--
|
||||
SELECT
|
||||
c.CONTAINERID,
|
||||
c.SPLITFROMID,
|
||||
c.CONTAINERNAME,
|
||||
LEVEL AS SPLIT_DEPTH
|
||||
FROM DWH.DW_MES_CONTAINER c
|
||||
START WITH {{ ROOT_FILTER }}
|
||||
CONNECT BY NOCYCLE PRIOR c.CONTAINERID = c.SPLITFROMID
|
||||
AND LEVEL <= 20
|
||||
@@ -1,14 +1,14 @@
|
||||
-- GA Work Order to CONTAINERID Resolution
|
||||
-- Expands work orders to associated CONTAINERIDs.
|
||||
-- Work Order to CONTAINERID Resolution
|
||||
-- Expands work orders (MFGORDERNAME) to associated CONTAINERIDs.
|
||||
-- Uses DW_MES_CONTAINER directly (same source as LOT ID resolve).
|
||||
--
|
||||
-- Parameters:
|
||||
-- WORK_ORDER_FILTER - QueryBuilder filter on h.PJ_WORKORDER
|
||||
-- WORK_ORDER_FILTER - QueryBuilder filter on MFGORDERNAME
|
||||
--
|
||||
SELECT DISTINCT
|
||||
h.CONTAINERID,
|
||||
h.PJ_WORKORDER,
|
||||
c.CONTAINERNAME,
|
||||
c.SPECNAME
|
||||
FROM DWH.DW_MES_LOTWIPHISTORY h
|
||||
LEFT JOIN DWH.DW_MES_CONTAINER c ON h.CONTAINERID = c.CONTAINERID
|
||||
SELECT
|
||||
CONTAINERID,
|
||||
MFGORDERNAME,
|
||||
CONTAINERNAME,
|
||||
SPECNAME
|
||||
FROM DWH.DW_MES_CONTAINER
|
||||
WHERE {{ WORK_ORDER_FILTER }}
|
||||
|
||||
153
src/mes_dashboard/sql/reject_history/performance_daily.sql
Normal file
153
src/mes_dashboard/sql/reject_history/performance_daily.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
-- Reject History Performance (Daily Grain)
|
||||
-- Aggregates reject history into a performance dataset for reporting.
|
||||
--
|
||||
-- Parameters:
|
||||
-- :start_date - Start date (YYYY-MM-DD)
|
||||
-- :end_date - End date (YYYY-MM-DD)
|
||||
--
|
||||
-- Source tables:
|
||||
-- DWH.DW_MES_LOTREJECTHISTORY (fact)
|
||||
-- DWH.DW_MES_CONTAINER (lot/product/workorder dimensions)
|
||||
-- DWH.DW_MES_SPEC_WORKCENTER_V (workcenter group mapping)
|
||||
--
|
||||
-- Important rules:
|
||||
-- 1) REJECT_TOTAL_QTY = REJECTQTY + STANDBYQTY + QTYTOPROCESS + INPROCESSQTY + PROCESSEDQTY.
|
||||
-- 2) DEFECT_QTY uses DEFECTQTY field and is tracked separately (non-charge-off).
|
||||
-- 3) MOVEIN_QTY is de-duplicated at event level by HISTORYMAINLINEID.
|
||||
-- 4) SPEC_WORKCENTER_V is pre-aggregated to avoid row multiplication.
|
||||
|
||||
WITH workcenter_map AS (
|
||||
SELECT
|
||||
WORK_CENTER,
|
||||
MIN(WORK_CENTER_GROUP) KEEP (
|
||||
DENSE_RANK FIRST ORDER BY WORKCENTERSEQUENCE_GROUP
|
||||
) AS WORKCENTER_GROUP,
|
||||
MIN(WORKCENTERSEQUENCE_GROUP) AS WORKCENTERSEQUENCE_GROUP
|
||||
FROM DWH.DW_MES_SPEC_WORKCENTER_V
|
||||
WHERE WORK_CENTER IS NOT NULL
|
||||
GROUP BY WORK_CENTER
|
||||
),
|
||||
reject_raw AS (
|
||||
SELECT
|
||||
TRUNC(r.TXNDATE) AS TXN_DAY,
|
||||
TO_CHAR(TRUNC(r.TXNDATE), 'YYYY-MM') AS TXN_MONTH,
|
||||
r.CONTAINERID,
|
||||
NVL(TRIM(r.PJ_WORKORDER), TRIM(c.MFGORDERNAME)) AS PJ_WORKORDER,
|
||||
NVL(TRIM(c.PJ_TYPE), '(NA)') AS PJ_TYPE,
|
||||
NVL(TRIM(c.PRODUCTLINENAME), '(NA)') AS PRODUCTLINENAME,
|
||||
NVL(TRIM(r.WORKCENTERNAME), '(NA)') AS WORKCENTERNAME,
|
||||
NVL(TRIM(wm.WORKCENTER_GROUP), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) AS WORKCENTER_GROUP,
|
||||
NVL(wm.WORKCENTERSEQUENCE_GROUP, 999) AS WORKCENTERSEQUENCE_GROUP,
|
||||
NVL(TRIM(r.SPECNAME), '(NA)') AS SPECNAME,
|
||||
NVL(TRIM(r.EQUIPMENTNAME), '(NA)') AS EQUIPMENTNAME,
|
||||
NVL(
|
||||
TRIM(REGEXP_SUBSTR(r.EQUIPMENTNAME, '[^,]+', 1, 1)),
|
||||
NVL(TRIM(r.EQUIPMENTNAME), '(NA)')
|
||||
) AS PRIMARY_EQUIPMENTNAME,
|
||||
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') AS LOSSREASONNAME,
|
||||
NVL(TRIM(r.REJECTCATEGORYNAME), '(未填寫)') AS REJECTCATEGORYNAME,
|
||||
NVL(r.MOVEINQTY, 0) AS MOVEINQTY,
|
||||
NVL(r.REJECTQTY, 0) AS REJECT_QTY,
|
||||
NVL(r.STANDBYQTY, 0) AS STANDBY_QTY,
|
||||
NVL(r.QTYTOPROCESS, 0) AS QTYTOPROCESS_QTY,
|
||||
NVL(r.INPROCESSQTY, 0) AS INPROCESS_QTY,
|
||||
NVL(r.PROCESSEDQTY, 0) AS PROCESSED_QTY,
|
||||
NVL(r.REJECTQTY, 0)
|
||||
+ NVL(r.STANDBYQTY, 0)
|
||||
+ NVL(r.QTYTOPROCESS, 0)
|
||||
+ NVL(r.INPROCESSQTY, 0)
|
||||
+ NVL(r.PROCESSEDQTY, 0) AS REJECT_TOTAL_QTY,
|
||||
NVL(r.DEFECTQTY, 0) AS DEFECT_QTY,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY NVL(
|
||||
TRIM(r.HISTORYMAINLINEID),
|
||||
TRIM(r.CONTAINERID) || ':' || TO_CHAR(r.TXNDATE, 'YYYYMMDDHH24MISS') || ':' || NVL(TRIM(r.SPECID), '-')
|
||||
)
|
||||
ORDER BY NVL(TRIM(r.LOSSREASONNAME), ' ')
|
||||
) AS EVENT_RN
|
||||
FROM DWH.DW_MES_LOTREJECTHISTORY r
|
||||
LEFT JOIN DWH.DW_MES_CONTAINER c
|
||||
ON c.CONTAINERID = r.CONTAINERID
|
||||
LEFT JOIN workcenter_map wm
|
||||
ON wm.WORK_CENTER = r.WORKCENTERNAME
|
||||
WHERE r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
|
||||
AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
|
||||
),
|
||||
daily_agg AS (
|
||||
SELECT
|
||||
TXN_DAY,
|
||||
TXN_MONTH,
|
||||
WORKCENTER_GROUP,
|
||||
WORKCENTERSEQUENCE_GROUP,
|
||||
WORKCENTERNAME,
|
||||
SPECNAME,
|
||||
EQUIPMENTNAME,
|
||||
PRIMARY_EQUIPMENTNAME,
|
||||
PRODUCTLINENAME,
|
||||
PJ_TYPE,
|
||||
LOSSREASONNAME,
|
||||
REJECTCATEGORYNAME,
|
||||
COUNT(*) AS REJECT_EVENT_ROWS,
|
||||
COUNT(DISTINCT CONTAINERID) AS AFFECTED_LOT_COUNT,
|
||||
COUNT(DISTINCT PJ_WORKORDER) AS AFFECTED_WORKORDER_COUNT,
|
||||
SUM(CASE WHEN EVENT_RN = 1 THEN MOVEINQTY ELSE 0 END) AS MOVEIN_QTY,
|
||||
SUM(REJECT_QTY) AS REJECT_QTY,
|
||||
SUM(REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY,
|
||||
SUM(DEFECT_QTY) AS DEFECT_QTY,
|
||||
SUM(STANDBY_QTY) AS STANDBY_QTY,
|
||||
SUM(QTYTOPROCESS_QTY) AS QTYTOPROCESS_QTY,
|
||||
SUM(INPROCESS_QTY) AS INPROCESS_QTY,
|
||||
SUM(PROCESSED_QTY) AS PROCESSED_QTY
|
||||
FROM reject_raw
|
||||
GROUP BY
|
||||
TXN_DAY,
|
||||
TXN_MONTH,
|
||||
WORKCENTER_GROUP,
|
||||
WORKCENTERSEQUENCE_GROUP,
|
||||
WORKCENTERNAME,
|
||||
SPECNAME,
|
||||
EQUIPMENTNAME,
|
||||
PRIMARY_EQUIPMENTNAME,
|
||||
PRODUCTLINENAME,
|
||||
PJ_TYPE,
|
||||
LOSSREASONNAME,
|
||||
REJECTCATEGORYNAME
|
||||
)
|
||||
SELECT
|
||||
TXN_DAY,
|
||||
TXN_MONTH,
|
||||
WORKCENTER_GROUP,
|
||||
WORKCENTERSEQUENCE_GROUP,
|
||||
WORKCENTERNAME,
|
||||
SPECNAME,
|
||||
EQUIPMENTNAME,
|
||||
PRIMARY_EQUIPMENTNAME,
|
||||
PRODUCTLINENAME,
|
||||
PJ_TYPE,
|
||||
LOSSREASONNAME,
|
||||
REJECTCATEGORYNAME,
|
||||
REJECT_EVENT_ROWS,
|
||||
AFFECTED_LOT_COUNT,
|
||||
AFFECTED_WORKORDER_COUNT,
|
||||
MOVEIN_QTY,
|
||||
REJECT_QTY,
|
||||
REJECT_TOTAL_QTY,
|
||||
DEFECT_QTY,
|
||||
STANDBY_QTY,
|
||||
QTYTOPROCESS_QTY,
|
||||
INPROCESS_QTY,
|
||||
PROCESSED_QTY,
|
||||
CASE
|
||||
WHEN MOVEIN_QTY = 0 THEN 0
|
||||
ELSE ROUND(REJECT_TOTAL_QTY * 100 / MOVEIN_QTY, 4)
|
||||
END AS REJECT_RATE_PCT,
|
||||
CASE
|
||||
WHEN MOVEIN_QTY = 0 THEN 0
|
||||
ELSE ROUND(DEFECT_QTY * 100 / MOVEIN_QTY, 4)
|
||||
END AS DEFECT_RATE_PCT,
|
||||
CASE
|
||||
WHEN (REJECT_TOTAL_QTY + DEFECT_QTY) = 0 THEN 0
|
||||
ELSE ROUND(REJECT_TOTAL_QTY * 100 / (REJECT_TOTAL_QTY + DEFECT_QTY), 4)
|
||||
END AS REJECT_SHARE_PCT
|
||||
FROM daily_agg
|
||||
ORDER BY TXN_DAY DESC, WORKCENTERSEQUENCE_GROUP, WORKCENTERNAME, DEFECT_QTY DESC
|
||||
File diff suppressed because it is too large
Load Diff
533
tests/e2e/test_query_tool_e2e.py
Normal file
533
tests/e2e/test_query_tool_e2e.py
Normal file
@@ -0,0 +1,533 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""E2E coverage for the Query Tool page (LOT 追蹤 + 設備查詢 tabs)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The query-tool page is served inside the portal shell as a native Vue SPA.
|
||||
# Standalone /query-tool redirects to /portal-shell/query-tool.
|
||||
QUERY_TOOL_BASE = "/portal-shell/query-tool"
|
||||
|
||||
|
||||
def _intercept_navigation_as_admin(page: Page, app_server: str):
|
||||
"""Intercept /api/portal/navigation to inject is_admin=True + query-tool route.
|
||||
|
||||
The query-tool page has status 'dev' in an admin-only drawer. The server
|
||||
filters out admin-only drawers for non-admin requests, so we must both set
|
||||
``is_admin=True`` AND inject the query-tool page into the drawers list.
|
||||
"""
|
||||
|
||||
def handle_route(route):
|
||||
response = route.fetch()
|
||||
body = response.json()
|
||||
|
||||
body["is_admin"] = True
|
||||
|
||||
# Ensure the query-tool page is present in a drawer
|
||||
query_tool_entry = {
|
||||
"name": "批次追蹤工具",
|
||||
"order": 4,
|
||||
"route": "/query-tool",
|
||||
"status": "dev",
|
||||
}
|
||||
|
||||
drawers = body.get("drawers", [])
|
||||
found = False
|
||||
for drawer in drawers:
|
||||
for pg in drawer.get("pages", []):
|
||||
if pg.get("route") == "/query-tool":
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
# Add to the first non-admin drawer, or create a test drawer
|
||||
target_drawer = None
|
||||
for drawer in drawers:
|
||||
if not drawer.get("admin_only"):
|
||||
target_drawer = drawer
|
||||
break
|
||||
if target_drawer:
|
||||
target_drawer["pages"].append(query_tool_entry)
|
||||
else:
|
||||
drawers.append({
|
||||
"id": "e2e-test",
|
||||
"name": "E2E Test",
|
||||
"order": 99,
|
||||
"admin_only": False,
|
||||
"pages": [query_tool_entry],
|
||||
})
|
||||
|
||||
body["drawers"] = drawers
|
||||
route.fulfill(
|
||||
status=response.status,
|
||||
headers={**response.headers, "content-type": "application/json"},
|
||||
body=json.dumps(body),
|
||||
)
|
||||
|
||||
page.route("**/api/portal/navigation", handle_route)
|
||||
|
||||
|
||||
def _wait_for_api_response(page: Page, url_token: str, timeout_seconds: float = 60.0):
|
||||
"""Wait until a response URL contains the given token and return it."""
|
||||
matched = []
|
||||
|
||||
def handle_response(resp):
|
||||
if url_token in resp.url and resp.status < 500:
|
||||
matched.append(resp)
|
||||
|
||||
page.on("response", handle_response)
|
||||
deadline = time.time() + timeout_seconds
|
||||
while time.time() < deadline and not matched:
|
||||
page.wait_for_timeout(300)
|
||||
page.remove_listener("response", handle_response)
|
||||
return matched[0] if matched else None
|
||||
|
||||
|
||||
def _collect_api_responses(page: Page, url_tokens: list[str], timeout_seconds: float = 60.0):
|
||||
"""Collect responses whose URLs contain any of the given tokens."""
|
||||
collected = {}
|
||||
|
||||
def handle_response(resp):
|
||||
for token in url_tokens:
|
||||
if token in resp.url:
|
||||
collected.setdefault(token, []).append(resp)
|
||||
|
||||
page.on("response", handle_response)
|
||||
deadline = time.time() + timeout_seconds
|
||||
while time.time() < deadline and len(collected) < len(url_tokens):
|
||||
page.wait_for_timeout(300)
|
||||
page.remove_listener("response", handle_response)
|
||||
return collected
|
||||
|
||||
|
||||
def _api_post_json(app_server: str, path: str, body: dict, timeout: float = 60.0):
|
||||
"""Direct API POST for backend integration checks."""
|
||||
resp = requests.post(
|
||||
f"{app_server}{path}",
|
||||
json=body,
|
||||
timeout=timeout,
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend Integration Tests (no browser needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestQueryToolBackendIntegration:
|
||||
"""Verify query-tool API endpoints are functional."""
|
||||
|
||||
def test_resolve_work_order(self, app_server: str):
|
||||
"""POST /api/query-tool/resolve with work_order returns lots."""
|
||||
resp = _api_post_json(app_server, "/api/query-tool/resolve", {
|
||||
"input_type": "work_order",
|
||||
"values": ["GA26010001"],
|
||||
})
|
||||
assert resp.status_code == 200, f"resolve returned {resp.status_code}: {resp.text[:300]}"
|
||||
payload = resp.json()
|
||||
assert "data" in payload, f"response missing 'data': {list(payload.keys())}"
|
||||
assert isinstance(payload["data"], list)
|
||||
assert len(payload["data"]) > 0, "Expected at least 1 resolved lot for GA26010001"
|
||||
|
||||
def test_resolve_returns_not_found_for_garbage(self, app_server: str):
|
||||
"""POST /api/query-tool/resolve returns not_found for non-existent values."""
|
||||
resp = _api_post_json(app_server, "/api/query-tool/resolve", {
|
||||
"input_type": "lot_id",
|
||||
"values": ["NONEXISTENT_LOT_12345"],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
not_found = payload.get("not_found", [])
|
||||
assert "NONEXISTENT_LOT_12345" in not_found
|
||||
|
||||
def test_workcenter_groups_endpoint(self, app_server: str):
|
||||
"""GET /api/query-tool/workcenter-groups returns list."""
|
||||
resp = requests.get(f"{app_server}/api/query-tool/workcenter-groups", timeout=30)
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert "data" in payload
|
||||
assert isinstance(payload["data"], list)
|
||||
|
||||
def test_equipment_list_endpoint(self, app_server: str):
|
||||
"""GET /api/query-tool/equipment-list returns equipment options."""
|
||||
resp = requests.get(f"{app_server}/api/query-tool/equipment-list", timeout=30)
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert "data" in payload
|
||||
assert isinstance(payload["data"], list)
|
||||
assert len(payload["data"]) > 0, "Expected at least 1 equipment option"
|
||||
|
||||
def test_lot_history_with_resolved_container(self, app_server: str):
|
||||
"""Resolve a work order then fetch lot history for first container."""
|
||||
resolve_resp = _api_post_json(app_server, "/api/query-tool/resolve", {
|
||||
"input_type": "work_order",
|
||||
"values": ["GA26010001"],
|
||||
})
|
||||
assert resolve_resp.status_code == 200
|
||||
lots = resolve_resp.json().get("data", [])
|
||||
assert len(lots) > 0
|
||||
|
||||
container_id = str(
|
||||
lots[0].get("container_id")
|
||||
or lots[0].get("CONTAINERID")
|
||||
or lots[0].get("containerId")
|
||||
or ""
|
||||
)
|
||||
assert container_id, "Could not extract container_id from resolved lot"
|
||||
|
||||
history_resp = requests.get(
|
||||
f"{app_server}/api/query-tool/lot-history",
|
||||
params={"container_id": container_id},
|
||||
timeout=60,
|
||||
)
|
||||
assert history_resp.status_code == 200
|
||||
history_payload = history_resp.json()
|
||||
assert "data" in history_payload
|
||||
assert isinstance(history_payload["data"], list)
|
||||
|
||||
def test_lot_associations_materials(self, app_server: str):
|
||||
"""Fetch materials association for a resolved container."""
|
||||
resolve_resp = _api_post_json(app_server, "/api/query-tool/resolve", {
|
||||
"input_type": "work_order",
|
||||
"values": ["GA26010001"],
|
||||
})
|
||||
lots = resolve_resp.json().get("data", [])
|
||||
if not lots:
|
||||
pytest.skip("No lots resolved for GA26010001")
|
||||
|
||||
container_id = str(
|
||||
lots[0].get("container_id")
|
||||
or lots[0].get("CONTAINERID")
|
||||
or ""
|
||||
)
|
||||
resp = requests.get(
|
||||
f"{app_server}/api/query-tool/lot-associations",
|
||||
params={"container_id": container_id, "type": "materials"},
|
||||
timeout=60,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_lineage_for_resolved_container(self, app_server: str):
|
||||
"""POST /api/trace/lineage for a resolved container."""
|
||||
resolve_resp = _api_post_json(app_server, "/api/query-tool/resolve", {
|
||||
"input_type": "work_order",
|
||||
"values": ["GA26010001"],
|
||||
})
|
||||
lots = resolve_resp.json().get("data", [])
|
||||
if not lots:
|
||||
pytest.skip("No lots resolved for GA26010001")
|
||||
|
||||
container_id = str(
|
||||
lots[0].get("container_id")
|
||||
or lots[0].get("CONTAINERID")
|
||||
or ""
|
||||
)
|
||||
lineage_resp = _api_post_json(app_server, "/api/trace/lineage", {
|
||||
"profile": "query_tool",
|
||||
"container_ids": [container_id],
|
||||
})
|
||||
assert lineage_resp.status_code == 200
|
||||
payload = lineage_resp.json()
|
||||
assert "ancestors" in payload or "data" in payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser E2E Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestQueryToolPageE2E:
|
||||
"""Browser-based E2E tests for the query-tool page."""
|
||||
|
||||
def test_page_loads_with_tab_shell(self, page: Page, app_server: str):
|
||||
"""Query tool page loads and displays both top-level tabs."""
|
||||
_intercept_navigation_as_admin(page, app_server)
|
||||
page.goto(f"{app_server}{QUERY_TOOL_BASE}", wait_until="commit", timeout=60000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
# Header should be visible (portal shell has its own h1, use the page heading)
|
||||
heading = page.get_by_role("heading", name="批次追蹤工具")
|
||||
expect(heading).to_be_visible()
|
||||
|
||||
# Both tab buttons should exist
|
||||
lot_tab = page.locator("button", has_text="LOT 追蹤")
|
||||
equipment_tab = page.locator("button", has_text="設備查詢")
|
||||
expect(lot_tab).to_be_visible()
|
||||
expect(equipment_tab).to_be_visible()
|
||||
|
||||
def test_lot_tab_resolve_work_order(self, page: Page, app_server: str):
|
||||
"""Enter work order, click resolve, verify lineage tree and detail panel appear."""
|
||||
_intercept_navigation_as_admin(page, app_server)
|
||||
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Select work_order input type
|
||||
select = page.locator("select")
|
||||
select.select_option("work_order")
|
||||
|
||||
# Enter work order in textarea
|
||||
textarea = page.locator("textarea")
|
||||
textarea.fill("GA26010001")
|
||||
|
||||
# Collect API responses during resolve
|
||||
api_tokens = ["/api/query-tool/resolve", "/api/trace/lineage"]
|
||||
collected = {}
|
||||
|
||||
def handle_response(resp):
|
||||
for token in api_tokens:
|
||||
if token in resp.url:
|
||||
collected.setdefault(token, []).append(resp)
|
||||
|
||||
page.on("response", handle_response)
|
||||
|
||||
# Click resolve button
|
||||
resolve_btn = page.locator("button", has_text="解析")
|
||||
resolve_btn.click()
|
||||
|
||||
# Wait for resolve + lineage responses
|
||||
deadline = time.time() + 60
|
||||
while time.time() < deadline and len(collected) < 2:
|
||||
page.wait_for_timeout(500)
|
||||
page.remove_listener("response", handle_response)
|
||||
|
||||
# Verify resolve API was called
|
||||
assert "/api/query-tool/resolve" in collected, "Resolve API was not called"
|
||||
resolve_resp = collected["/api/query-tool/resolve"][0]
|
||||
assert resolve_resp.ok, f"Resolve API returned {resolve_resp.status}"
|
||||
|
||||
# Verify lineage API was auto-fired
|
||||
assert "/api/trace/lineage" in collected, "Lineage API was not auto-fired after resolve"
|
||||
|
||||
# Lineage tree should show nodes
|
||||
page.wait_for_timeout(2000)
|
||||
tree_section = page.locator("text=批次血緣樹")
|
||||
expect(tree_section).to_be_visible()
|
||||
|
||||
def test_lot_tab_url_state_sync(self, page: Page, app_server: str):
|
||||
"""URL params are written after resolve and restored on reload."""
|
||||
_intercept_navigation_as_admin(page, app_server)
|
||||
page.goto(
|
||||
f"{app_server}{QUERY_TOOL_BASE}?tab=lot&input_type=work_order&values=GA26010001",
|
||||
wait_until="commit",
|
||||
timeout=60000,
|
||||
)
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Verify URL params are preserved
|
||||
url = page.url
|
||||
assert "tab=lot" in url
|
||||
assert "input_type=work_order" in url
|
||||
assert "values=GA26010001" in url or "GA26010001" in url
|
||||
|
||||
def test_lot_detail_sub_tabs_render(self, page: Page, app_server: str):
|
||||
"""After resolve, clicking a tree node shows detail panel with sub-tabs."""
|
||||
_intercept_navigation_as_admin(page, app_server)
|
||||
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
# Select work_order and resolve
|
||||
page.locator("select").select_option("work_order")
|
||||
page.locator("textarea").fill("GA26010001")
|
||||
page.locator("button", has_text="解析").click()
|
||||
|
||||
# Wait for resolve + lineage + detail loading
|
||||
resolve_done = _wait_for_api_response(page, "/api/query-tool/resolve", timeout_seconds=60)
|
||||
if not resolve_done:
|
||||
pytest.fail("Resolve did not complete within timeout")
|
||||
|
||||
page.wait_for_timeout(8000)
|
||||
|
||||
# Detail panel sub-tabs should be visible
|
||||
detail_tabs = ["歷程", "物料", "退貨", "Hold", "Split", "Job"]
|
||||
for tab_label in detail_tabs:
|
||||
tab_btn = page.locator(f"button:has-text('{tab_label}')")
|
||||
if tab_btn.count() > 0:
|
||||
expect(tab_btn.first).to_be_visible()
|
||||
|
||||
def test_equipment_tab_loads_filter_bar(self, page: Page, app_server: str):
|
||||
"""Switching to equipment tab shows filter bar with equipment MultiSelect."""
|
||||
_intercept_navigation_as_admin(page, app_server)
|
||||
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=equipment", wait_until="commit", timeout=60000)
|
||||
|
||||
# Wait for equipment list to bootstrap
|
||||
equipment_resp = _wait_for_api_response(page, "/api/query-tool/equipment-list", timeout_seconds=30)
|
||||
assert equipment_resp is not None, "Equipment list API was not called"
|
||||
assert equipment_resp.ok
|
||||
|
||||
# Filter bar should show date inputs
|
||||
start_date = page.locator("input[type='date']").first
|
||||
expect(start_date).to_be_visible()
|
||||
|
||||
# Equipment sub-tabs should be visible
|
||||
for label in ["生產紀錄", "維修紀錄", "報廢紀錄", "Timeline"]:
|
||||
tab_btn = page.locator(f"button:has-text('{label}')")
|
||||
if tab_btn.count() > 0:
|
||||
expect(tab_btn.first).to_be_visible()
|
||||
|
||||
def test_tab_switching_preserves_state(self, page: Page, app_server: str):
|
||||
"""Switching between LOT and equipment tabs preserves entered data."""
|
||||
_intercept_navigation_as_admin(page, app_server)
|
||||
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
# Enter text in LOT tab
|
||||
textarea = page.locator("textarea")
|
||||
textarea.fill("GA26010001")
|
||||
|
||||
# Switch to equipment tab
|
||||
equipment_tab = page.locator("button", has_text="設備查詢")
|
||||
equipment_tab.click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Switch back to LOT tab
|
||||
lot_tab = page.locator("button", has_text="LOT 追蹤")
|
||||
lot_tab.click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Verify textarea still has the value (v-show preserves state)
|
||||
expect(textarea).to_have_value("GA26010001")
|
||||
|
||||
def test_lineage_tree_expand_collapse(self, page: Page, app_server: str):
|
||||
"""After resolve, expand-all and collapse-all buttons work."""
|
||||
_intercept_navigation_as_admin(page, app_server)
|
||||
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
page.locator("select").select_option("work_order")
|
||||
page.locator("textarea").fill("GA26010001")
|
||||
page.locator("button", has_text="解析").click()
|
||||
|
||||
# Wait for resolve + lineage
|
||||
page.wait_for_timeout(8000)
|
||||
|
||||
# Try expand all
|
||||
expand_btn = page.locator("button", has_text="全部展開")
|
||||
if expand_btn.count() > 0 and expand_btn.is_visible():
|
||||
expand_btn.click()
|
||||
page.wait_for_timeout(5000)
|
||||
|
||||
# Try collapse all
|
||||
collapse_btn = page.locator("button", has_text="收合")
|
||||
if collapse_btn.count() > 0:
|
||||
collapse_btn.click()
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
def test_export_button_present_when_data_loaded(self, page: Page, app_server: str):
|
||||
"""After resolving and selecting a lot, export button should appear."""
|
||||
_intercept_navigation_as_admin(page, app_server)
|
||||
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
page.locator("select").select_option("work_order")
|
||||
page.locator("textarea").fill("GA26010001")
|
||||
page.locator("button", has_text="解析").click()
|
||||
|
||||
# Wait for resolve + detail load
|
||||
page.wait_for_timeout(8000)
|
||||
|
||||
# Look for export button (should appear in detail panel)
|
||||
export_btn = page.locator("button", has_text="匯出")
|
||||
# May or may not be visible depending on data state, just verify no crash
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full Flow Integration (API → UI round-trip)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestQueryToolFullFlowE2E:
|
||||
"""End-to-end full workflow: resolve → lineage → history → association."""
|
||||
|
||||
def test_work_order_full_flow(self, page: Page, app_server: str):
|
||||
"""
|
||||
Complete flow:
|
||||
1. Navigate to query-tool
|
||||
2. Select work_order, enter GA26010001
|
||||
3. Click resolve
|
||||
4. Verify resolve API → lineage auto-fire → history load
|
||||
5. Click through sub-tabs
|
||||
"""
|
||||
_intercept_navigation_as_admin(page, app_server)
|
||||
page.goto(f"{app_server}{QUERY_TOOL_BASE}?tab=lot", wait_until="commit", timeout=60000)
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Step 1: Configure input
|
||||
page.locator("select").select_option("work_order")
|
||||
page.locator("textarea").fill("GA26010001")
|
||||
|
||||
# Step 2: Track all API calls
|
||||
api_calls = {}
|
||||
|
||||
def track_response(resp):
|
||||
for key in [
|
||||
"/api/query-tool/resolve",
|
||||
"/api/trace/lineage",
|
||||
"/api/query-tool/lot-history",
|
||||
"/api/query-tool/lot-associations",
|
||||
]:
|
||||
if key in resp.url:
|
||||
api_calls.setdefault(key, []).append({
|
||||
"status": resp.status,
|
||||
"url": resp.url,
|
||||
})
|
||||
|
||||
page.on("response", track_response)
|
||||
|
||||
# Step 3: Click resolve
|
||||
page.locator("button", has_text="解析").click()
|
||||
|
||||
# Step 4: Wait for cascade of API calls
|
||||
deadline = time.time() + 90
|
||||
while time.time() < deadline:
|
||||
page.wait_for_timeout(500)
|
||||
# Minimum: resolve + lineage + history
|
||||
if (
|
||||
"/api/query-tool/resolve" in api_calls
|
||||
and "/api/trace/lineage" in api_calls
|
||||
and "/api/query-tool/lot-history" in api_calls
|
||||
):
|
||||
break
|
||||
|
||||
page.remove_listener("response", track_response)
|
||||
|
||||
# Step 5: Verify API cascade
|
||||
assert "/api/query-tool/resolve" in api_calls, \
|
||||
f"resolve not called. Calls seen: {list(api_calls.keys())}"
|
||||
assert api_calls["/api/query-tool/resolve"][0]["status"] == 200
|
||||
|
||||
assert "/api/trace/lineage" in api_calls, \
|
||||
f"lineage not auto-fired. Calls seen: {list(api_calls.keys())}"
|
||||
|
||||
assert "/api/query-tool/lot-history" in api_calls, \
|
||||
f"lot-history not loaded. Calls seen: {list(api_calls.keys())}"
|
||||
|
||||
# Step 6: Verify URL state updated
|
||||
current_url = page.url
|
||||
assert "tab=lot" in current_url
|
||||
assert "work_order" in current_url
|
||||
|
||||
# Step 7: Click through sub-tabs if available
|
||||
for tab_label in ["物料", "Hold", "歷程"]:
|
||||
tab_btn = page.locator(f"button:has-text('{tab_label}')")
|
||||
if tab_btn.count() > 0 and tab_btn.first.is_visible():
|
||||
tab_btn.first.click()
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Step 8: Success message should be visible
|
||||
success_msg = page.locator("text=解析完成")
|
||||
if success_msg.count() > 0:
|
||||
expect(success_msg.first).to_be_visible()
|
||||
@@ -130,7 +130,20 @@ def test_resolve_full_genealogy_combines_split_and_merge(
|
||||
|
||||
result = LineageEngine.resolve_full_genealogy(["A"], {"A": "LOT-A"})
|
||||
|
||||
assert result == {"A": {"B", "C", "M1", "M0"}}
|
||||
assert result["ancestors"] == {"A": {"B", "C", "M1", "M0"}}
|
||||
assert result["cid_to_name"]["A"] == "LOT-A"
|
||||
assert result["cid_to_name"]["M0"] == "LOT-M0"
|
||||
|
||||
# parent_map should have direct edges only
|
||||
pm = result["parent_map"]
|
||||
assert pm["A"] == ["B"]
|
||||
assert pm["B"] == ["C", "M1"] or set(pm["B"]) == {"C", "M1"}
|
||||
assert pm["M1"] == ["M0"]
|
||||
|
||||
# merge_edges: B → M1 (LOT-B matched merge source)
|
||||
me = result["merge_edges"]
|
||||
assert "M1" in me.get("B", [])
|
||||
|
||||
assert mock_resolve_split_ancestors.call_count == 2
|
||||
mock_resolve_merge_sources.assert_called_once()
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ class TestResolveQueriesUseBindParams:
|
||||
mock_read.return_value = pd.DataFrame([
|
||||
{
|
||||
'CONTAINERID': 'CID-1',
|
||||
'PJ_WORKORDER': 'WO-1',
|
||||
'MFGORDERNAME': 'WO-1',
|
||||
'CONTAINERNAME': 'LOT-1',
|
||||
'SPECNAME': 'SPEC-1',
|
||||
}
|
||||
|
||||
@@ -91,10 +91,14 @@ def test_seed_resolve_rate_limited_returns_429(_mock_rate_limit):
|
||||
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
|
||||
|
||||
|
||||
@patch('mes_dashboard.routes.trace_routes.LineageEngine.resolve_full_genealogy')
|
||||
def test_lineage_success_returns_snake_case(mock_resolve_genealogy):
|
||||
mock_resolve_genealogy.return_value = {
|
||||
'CID-001': {'CID-A', 'CID-B'}
|
||||
@patch('mes_dashboard.routes.trace_routes.LineageEngine.resolve_forward_tree')
|
||||
def test_lineage_success_returns_forward_tree(mock_resolve_tree):
|
||||
mock_resolve_tree.return_value = {
|
||||
'roots': ['CID-ROOT'],
|
||||
'children_map': {'CID-ROOT': ['CID-A'], 'CID-A': ['CID-001']},
|
||||
'leaf_serials': {'CID-001': ['SN-001']},
|
||||
'cid_to_name': {'CID-ROOT': 'WAFER-001', 'CID-A': 'LOT-A', 'CID-001': 'LOT-001'},
|
||||
'total_nodes': 3,
|
||||
}
|
||||
|
||||
client = _client()
|
||||
@@ -109,16 +113,20 @@ def test_lineage_success_returns_snake_case(mock_resolve_genealogy):
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload['stage'] == 'lineage'
|
||||
assert sorted(payload['ancestors']['CID-001']) == ['CID-A', 'CID-B']
|
||||
assert payload['roots'] == ['CID-ROOT']
|
||||
assert payload['children_map']['CID-ROOT'] == ['CID-A']
|
||||
assert payload['children_map']['CID-A'] == ['CID-001']
|
||||
assert payload['leaf_serials']['CID-001'] == ['SN-001']
|
||||
assert payload['total_nodes'] == 3
|
||||
assert 'totalNodes' not in payload
|
||||
assert payload['names']['CID-ROOT'] == 'WAFER-001'
|
||||
assert payload['names']['CID-A'] == 'LOT-A'
|
||||
|
||||
|
||||
@patch(
|
||||
'mes_dashboard.routes.trace_routes.LineageEngine.resolve_full_genealogy',
|
||||
'mes_dashboard.routes.trace_routes.LineageEngine.resolve_forward_tree',
|
||||
side_effect=TimeoutError('lineage timed out'),
|
||||
)
|
||||
def test_lineage_timeout_returns_504(_mock_resolve_genealogy):
|
||||
def test_lineage_timeout_returns_504(_mock_resolve_tree):
|
||||
client = _client()
|
||||
response = client.post(
|
||||
'/api/trace/lineage',
|
||||
|
||||
Reference in New Issue
Block a user