feat(query-tool): align lot reject detail with reject-history layout

This commit is contained in:
egg
2026-02-22 18:17:44 +08:00
parent 97872cca97
commit bfec6b2293
5 changed files with 297 additions and 50 deletions

View File

@@ -4,6 +4,7 @@ import { computed } from 'vue';
import ExportButton from './ExportButton.vue';
import LotAssociationTable from './LotAssociationTable.vue';
import LotHistoryTable from './LotHistoryTable.vue';
import LotRejectTable from './LotRejectTable.vue';
import LotTimeline from './LotTimeline.vue';
const props = defineProps({
@@ -105,36 +106,35 @@ const activeEmptyText = computed(() => {
});
const activeColumnLabels = computed(() => {
if (props.activeSubTab !== 'materials') {
return {};
if (props.activeSubTab === 'materials') {
return {
CONTAINERNAME: 'LOT ID',
};
}
return {
CONTAINERNAME: 'LOT ID',
};
return {};
});
const activeHiddenColumns = computed(() => {
if (props.activeSubTab !== 'materials') {
return [];
if (props.activeSubTab === 'materials') {
return ['CONTAINERID', 'WORKCENTER_GROUP'];
}
return ['CONTAINERID'];
return [];
});
const activeColumnOrder = computed(() => {
if (props.activeSubTab !== 'materials') {
return [];
if (props.activeSubTab === 'materials') {
return [
'CONTAINERNAME',
'MATERIALPARTNAME',
'MATERIALLOTNAME',
'QTYCONSUMED',
'WORKCENTERNAME',
'SPECNAME',
'EQUIPMENTNAME',
'TXNDATE',
];
}
return [
'CONTAINERNAME',
'MATERIALPARTNAME',
'MATERIALLOTNAME',
'QTYCONSUMED',
'WORKCENTERNAME',
'SPECNAME',
'EQUIPMENTNAME',
'TXNDATE',
'WORKCENTER_GROUP',
];
return [];
});
const canExport = computed(() => {
@@ -223,7 +223,7 @@ const detailCountLabel = computed(() => {
</div>
<LotAssociationTable
v-else
v-else-if="activeSubTab !== 'rejects'"
:rows="activeRows"
:loading="activeLoading"
:empty-text="activeLoaded ? activeEmptyText : '尚未查詢此分頁資料'"
@@ -231,6 +231,13 @@ const detailCountLabel = computed(() => {
:hidden-columns="activeHiddenColumns"
:column-order="activeColumnOrder"
/>
<LotRejectTable
v-else
:rows="activeRows"
:loading="activeLoading"
:empty-text="activeLoaded ? activeEmptyText : '尚未查詢此分頁資料'"
/>
</template>
</section>
</template>

View File

@@ -0,0 +1,169 @@
<script setup>
import { computed, ref } from 'vue';
import { formatDateTime, parseDateTime } from '../utils/values.js';
const props = defineProps({
rows: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
emptyText: {
type: String,
default: '無報廢資料',
},
});
const showRejectBreakdown = ref(false);
function toNumber(value, defaultValue = 0) {
const num = Number(value);
return Number.isFinite(num) ? num : defaultValue;
}
function formatNumber(value) {
return toNumber(value).toLocaleString('zh-TW');
}
function normalizeText(value, fallback = '') {
const text = String(value || '').trim();
return text || fallback;
}
function normalizeRejectRow(row) {
const rejectQty = toNumber(row?.REJECT_QTY ?? row?.REJECTQTY);
const standbyQty = toNumber(row?.STANDBY_QTY ?? row?.STANDBYQTY);
const qtyToProcessQty = toNumber(row?.QTYTOPROCESS_QTY ?? row?.QTYTOPROCESS);
const inProcessQty = toNumber(row?.INPROCESS_QTY ?? row?.INPROCESSQTY);
const processedQty = toNumber(row?.PROCESSED_QTY ?? row?.PROCESSEDQTY);
const computedRejectTotal = rejectQty + standbyQty + qtyToProcessQty + inProcessQty + processedQty;
const rejectTotalQty = toNumber(row?.REJECT_TOTAL_QTY, computedRejectTotal);
const defectQty = toNumber(row?.DEFECT_QTY);
const txnTimeRaw = row?.TXN_TIME || row?.TXNDATE || row?.TXN_DAY || '';
const txnDate = parseDateTime(txnTimeRaw);
return {
CONTAINERNAME: normalizeText(row?.CONTAINERNAME, normalizeText(row?.CONTAINERID)),
WORKCENTERNAME: normalizeText(row?.WORKCENTERNAME),
PRODUCTLINENAME: normalizeText(row?.PRODUCTLINENAME),
PJ_FUNCTION: normalizeText(row?.PJ_FUNCTION),
PJ_TYPE: normalizeText(row?.PJ_TYPE),
PRODUCTNAME: normalizeText(row?.PRODUCTNAME),
LOSSREASONNAME: normalizeText(row?.LOSSREASONNAME),
EQUIPMENTNAME: normalizeText(row?.EQUIPMENTNAME),
REJECTCOMMENT: normalizeText(row?.REJECTCOMMENT || row?.COMMENTS),
REJECT_TOTAL_QTY: rejectTotalQty,
REJECT_QTY: rejectQty,
STANDBY_QTY: standbyQty,
QTYTOPROCESS_QTY: qtyToProcessQty,
INPROCESS_QTY: inProcessQty,
PROCESSED_QTY: processedQty,
DEFECT_QTY: defectQty,
TXN_TIME_RAW: txnTimeRaw,
TXN_TIME: txnDate ? formatDateTime(txnDate) : normalizeText(txnTimeRaw),
TXN_DAY_SORT: txnDate ? txnDate.getTime() : 0,
WORKCENTERSEQUENCE_GROUP: toNumber(row?.WORKCENTERSEQUENCE_GROUP, 999),
};
}
const normalizedRows = computed(() => {
return (props.rows || []).map(normalizeRejectRow);
});
const sortedRows = computed(() => {
return [...normalizedRows.value].sort((a, b) => {
if (a.TXN_DAY_SORT !== b.TXN_DAY_SORT) {
return b.TXN_DAY_SORT - a.TXN_DAY_SORT;
}
if (a.WORKCENTERSEQUENCE_GROUP !== b.WORKCENTERSEQUENCE_GROUP) {
return a.WORKCENTERSEQUENCE_GROUP - b.WORKCENTERSEQUENCE_GROUP;
}
if (a.WORKCENTERNAME !== b.WORKCENTERNAME) {
return a.WORKCENTERNAME.localeCompare(b.WORKCENTERNAME, 'zh-Hant');
}
if (a.REJECT_TOTAL_QTY !== b.REJECT_TOTAL_QTY) {
return b.REJECT_TOTAL_QTY - a.REJECT_TOTAL_QTY;
}
return a.CONTAINERNAME.localeCompare(b.CONTAINERNAME, 'zh-Hant');
});
});
</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="sortedRows.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 class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">LOT</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">WORKCENTER</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">Package</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">FUNCTION</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">TYPE</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">PRODUCT</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">原因</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">EQUIPMENT</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">COMMENT</th>
<th
class="cursor-pointer whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold hover:text-brand-700"
@click="showRejectBreakdown = !showRejectBreakdown"
>
扣帳報廢量 <span>{{ showRejectBreakdown ? '▾' : '▸' }}</span>
</th>
<template v-if="showRejectBreakdown">
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">REJECT</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">STANDBY</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">QTYTOPROCESS</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">INPROCESS</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">PROCESSED</th>
</template>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">不扣帳報廢量</th>
<th class="whitespace-nowrap border-b border-stroke-soft px-2 py-1.5 text-left font-semibold">報廢時間</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, idx) in sortedRows"
:key="`${row.TXN_TIME_RAW}-${row.CONTAINERNAME}-${row.LOSSREASONNAME}-${idx}`"
class="odd:bg-white even:bg-slate-50"
>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.CONTAINERNAME }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.WORKCENTERNAME || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.PRODUCTLINENAME || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.PJ_FUNCTION || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.PJ_TYPE || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.PRODUCTNAME || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.LOSSREASONNAME || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.EQUIPMENTNAME || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.REJECTCOMMENT || '-' }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.REJECT_TOTAL_QTY) }}</td>
<template v-if="showRejectBreakdown">
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.REJECT_QTY) }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.STANDBY_QTY) }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.QTYTOPROCESS_QTY) }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.INPROCESS_QTY) }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.PROCESSED_QTY) }}</td>
</template>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ formatNumber(row.DEFECT_QTY) }}</td>
<td class="whitespace-nowrap border-b border-stroke-soft/70 px-2 py-1.5 text-slate-700">{{ row.TXN_TIME || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>

View File

@@ -31,6 +31,7 @@ _DOMAIN_SPECS: Dict[str, Dict[str, Any]] = {
"materials": {
"filter_column": "m.CONTAINERID",
"cache_ttl": 300,
"schema_version": 2,
"bucket": "event-materials",
"max_env": "EVT_MATERIALS_RATE_MAX_REQUESTS",
"window_env": "EVT_MATERIALS_RATE_WINDOW_SECONDS",
@@ -38,8 +39,9 @@ _DOMAIN_SPECS: Dict[str, Dict[str, Any]] = {
"default_window": 60,
},
"rejects": {
"filter_column": "CONTAINERID",
"filter_column": "r.CONTAINERID",
"cache_ttl": 300,
"schema_version": 2,
"bucket": "event-rejects",
"max_env": "EVT_REJECTS_RATE_MAX_REQUESTS",
"window_env": "EVT_REJECTS_RATE_WINDOW_SECONDS",
@@ -116,7 +118,8 @@ class EventFetcher:
def _cache_key(domain: str, container_ids: List[str]) -> str:
normalized = sorted(_normalize_ids(container_ids))
digest = hashlib.md5("|".join(normalized).encode("utf-8")).hexdigest()[:12]
return f"evt:{domain}:{digest}"
schema_version = int(_DOMAIN_SPECS.get(domain, {}).get("schema_version", 1))
return f"evt:{domain}:v{schema_version}:{digest}"
@staticmethod
def _replace_container_filter(sql: str, condition_sql: str) -> str:

View File

@@ -4,23 +4,67 @@
-- Parameters:
-- container_id - CONTAINERID to query (16-char hex)
--
-- Note: Uses LOSSREASONNAME (NOT REJECTREASONNAME)
-- Uses TXNDATE (NOT TXNDATETIME)
-- Only has EQUIPMENTNAME, NO EQUIPMENTID field
-- DEFECTQTY = SUM of REJECTQTY + STANDBYQTY + QTYTOPROCESS + INPROCESSQTY + PROCESSEDQTY
-- Note: Aligns output semantics and ordering with Reject History detail:
-- - REJECT_TOTAL_QTY = REJECT + STANDBY + QTYTOPROCESS + INPROCESS + PROCESSED
-- - DEFECT_QTY uses DEFECTQTY (non-charge-off scrap)
-- - Order: TXN_DAY desc, WORKCENTER sequence asc, WORKCENTER asc, REJECT_TOTAL_QTY desc, LOT asc
WITH spec_map AS (
SELECT
SPEC,
MIN(WORK_CENTER) KEEP (
DENSE_RANK FIRST ORDER BY WORKCENTERSEQUENCE_GROUP
) AS 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 SPEC IS NOT NULL
GROUP BY SPEC
)
SELECT
CONTAINERID,
LOSSREASONNAME,
REJECTQTY,
NVL(REJECTQTY, 0) + NVL(STANDBYQTY, 0) + NVL(QTYTOPROCESS, 0)
+ NVL(INPROCESSQTY, 0) + NVL(PROCESSEDQTY, 0) AS DEFECTQTY,
WORKCENTERNAME,
EQUIPMENTNAME,
TXNDATE,
COMMENTS,
REJECTCAUSE,
REJECTCOMMENT
FROM DWH.DW_MES_LOTREJECTHISTORY
WHERE CONTAINERID = :container_id
ORDER BY TXNDATE
r.CONTAINERID,
NVL(TRIM(c.CONTAINERNAME), TRIM(r.CONTAINERID)) AS CONTAINERNAME,
NVL(TRIM(sm.WORK_CENTER), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) AS WORKCENTERNAME,
NVL(TRIM(sm.WORKCENTER_GROUP), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) AS WORKCENTER_GROUP,
NVL(sm.WORKCENTERSEQUENCE_GROUP, 999) AS WORKCENTERSEQUENCE_GROUP,
NVL(TRIM(c.PRODUCTLINENAME), '(NA)') AS PRODUCTLINENAME,
NVL(TRIM(c.PJ_FUNCTION), '(NA)') AS PJ_FUNCTION,
NVL(TRIM(c.PJ_TYPE), '(NA)') AS PJ_TYPE,
NVL(TRIM(c.PRODUCTNAME), '(NA)') AS PRODUCTNAME,
NVL(TRIM(r.SPECNAME), '(NA)') AS SPECNAME,
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') AS LOSSREASONNAME,
NVL(TRIM(r.EQUIPMENTNAME), '(NA)') AS EQUIPMENTNAME,
TRIM(r.REJECTCOMMENT) AS REJECTCOMMENT,
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,
r.TXNDATE AS TXN_TIME,
r.TXNDATE,
TRUNC(r.TXNDATE) AS TXN_DAY
FROM DWH.DW_MES_LOTREJECTHISTORY r
LEFT JOIN DWH.DW_MES_CONTAINER c
ON c.CONTAINERID = r.CONTAINERID
LEFT JOIN spec_map sm
ON sm.SPEC = TRIM(r.SPECNAME)
WHERE r.CONTAINERID = :container_id
ORDER BY
TRUNC(r.TXNDATE) DESC,
NVL(sm.WORKCENTERSEQUENCE_GROUP, 999) ASC,
NVL(TRIM(sm.WORK_CENTER), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) ASC,
NVL(r.REJECTQTY, 0)
+ NVL(r.STANDBYQTY, 0)
+ NVL(r.QTYTOPROCESS, 0)
+ NVL(r.INPROCESSQTY, 0)
+ NVL(r.PROCESSEDQTY, 0) DESC,
NVL(TRIM(c.CONTAINERNAME), TRIM(r.CONTAINERID)) ASC,
r.TXNDATE DESC

View File

@@ -112,3 +112,27 @@ def test_fetch_events_materials_branch_replaces_aliased_container_filter(
assert "m.CONTAINERID = :container_id" not in sql
assert "IN" in sql.upper()
assert params == {"p0": "CID-1", "p1": "CID-2"}
@patch("mes_dashboard.services.event_fetcher.cache_set")
@patch("mes_dashboard.services.event_fetcher.cache_get", return_value=None)
@patch("mes_dashboard.services.event_fetcher.read_sql_df")
@patch("mes_dashboard.services.event_fetcher.SQLLoader.load")
def test_fetch_events_rejects_branch_replaces_aliased_container_filter(
mock_sql_load,
mock_read_sql_df,
_mock_cache_get,
_mock_cache_set,
):
mock_sql_load.return_value = (
"SELECT * FROM t r LEFT JOIN c ON c.CONTAINERID = r.CONTAINERID "
"WHERE r.CONTAINERID = :container_id ORDER BY r.TXNDATE"
)
mock_read_sql_df.return_value = pd.DataFrame([])
EventFetcher.fetch_events(["CID-1", "CID-2"], "rejects")
sql, params = mock_read_sql_df.call_args.args
assert "r.CONTAINERID = :container_id" not in sql
assert "IN" in sql.upper()
assert params == {"p0": "CID-1", "p1": "CID-2"}