diff --git a/frontend/src/reject-history/App.vue b/frontend/src/reject-history/App.vue
index 1fea5d9..9494bcd 100644
--- a/frontend/src/reject-history/App.vue
+++ b/frontend/src/reject-history/App.vue
@@ -71,6 +71,7 @@ const loading = reactive({
querying: false,
options: false,
list: false,
+ exporting: false,
});
const errorMessage = ref('');
@@ -267,12 +268,19 @@ function buildCommonParams({ reason = committedFilters.reason } = {}) {
return buildRejectCommonQueryParams(committedFilters, { reason });
}
+function metricFilterParam() {
+ const mode = paretoMetricMode.value;
+ if (mode === 'reject' || mode === 'defect') return mode;
+ return 'all';
+}
+
function buildListParams() {
const effectiveReason = detailReason.value || committedFilters.reason;
const params = {
...buildCommonParams({ reason: effectiveReason }),
page: page.value,
per_page: DEFAULT_PER_PAGE,
+ metric_filter: metricFilterParam(),
};
if (selectedTrendDates.value.length > 0) {
const sorted = [...selectedTrendDates.value].sort();
@@ -561,7 +569,9 @@ function onTrendDateClick(dateStr) {
function onTrendLegendChange(selected) {
trendLegendSelected.value = selected;
+ page.value = 1;
updateUrlState();
+ void loadListOnly();
}
function onParetoClick(reason) {
@@ -625,7 +635,9 @@ async function removeFilterChip(chip) {
await loadDataSections();
}
-function exportCsv() {
+async function exportCsv() {
+ if (loading.exporting) return;
+
const effectiveReason = detailReason.value || committedFilters.reason;
const queryParams = buildRejectCommonQueryParams(committedFilters, { reason: effectiveReason });
const params = new URLSearchParams();
@@ -638,8 +650,33 @@ function exportCsv() {
appendArrayParams(params, 'workcenter_groups', queryParams.workcenter_groups || []);
appendArrayParams(params, 'packages', queryParams.packages || []);
appendArrayParams(params, 'reasons', queryParams.reasons || []);
+ params.set('metric_filter', metricFilterParam());
- window.location.href = `/api/reject-history/export?${params.toString()}`;
+ loading.exporting = true;
+ errorMessage.value = '';
+ try {
+ const response = await fetch(`/api/reject-history/export?${params.toString()}`);
+ if (!response.ok) {
+ throw new Error('匯出 CSV 失敗');
+ }
+ const blob = await response.blob();
+ const disposition = response.headers.get('Content-Disposition') || '';
+ const filenameMatch = disposition.match(/filename=(.+?)(?:;|$)/);
+ const filename = filenameMatch ? filenameMatch[1] : `reject_history_${queryParams.start_date}_to_${queryParams.end_date}.csv`;
+
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ } catch (error) {
+ errorMessage.value = error?.message || '匯出 CSV 失敗';
+ } finally {
+ loading.exporting = false;
+ }
}
const totalScrapQty = computed(() => {
diff --git a/frontend/src/reject-history/components/DetailTable.vue b/frontend/src/reject-history/components/DetailTable.vue
index 04f94c6..648adea 100644
--- a/frontend/src/reject-history/components/DetailTable.vue
+++ b/frontend/src/reject-history/components/DetailTable.vue
@@ -42,6 +42,8 @@ function formatNumber(value) {
TYPE |
PRODUCT |
原因 |
+ EQUIPMENT |
+ COMMENT |
扣帳報廢量 {{ showRejectBreakdown ? '▾' : '▸' }}
|
@@ -65,6 +67,8 @@ function formatNumber(value) {
{{ row.PJ_TYPE }} |
{{ row.PRODUCTNAME || '' }} |
{{ row.LOSSREASONNAME }} |
+ {{ row.EQUIPMENTNAME || '' }} |
+ {{ row.REJECTCOMMENT || '' }} |
{{ formatNumber(row.REJECT_TOTAL_QTY) }} |
{{ formatNumber(row.REJECT_QTY) }} |
@@ -77,7 +81,7 @@ function formatNumber(value) {
{{ row.TXN_TIME || row.TXN_DAY }} |
- | No data |
+ No data |
diff --git a/frontend/src/reject-history/components/FilterPanel.vue b/frontend/src/reject-history/components/FilterPanel.vue
index 1d83d6a..878f339 100644
--- a/frontend/src/reject-history/components/FilterPanel.vue
+++ b/frontend/src/reject-history/components/FilterPanel.vue
@@ -84,7 +84,10 @@ defineEmits(['apply', 'clear', 'export-csv', 'remove-chip', 'pareto-scope-toggle
-
+
diff --git a/frontend/src/reject-history/components/TrendChart.vue b/frontend/src/reject-history/components/TrendChart.vue
index 8f79b95..791e976 100644
--- a/frontend/src/reject-history/components/TrendChart.vue
+++ b/frontend/src/reject-history/components/TrendChart.vue
@@ -31,7 +31,7 @@ const chartOption = computed(() => {
data: ['扣帳報廢量', '不扣帳報廢量'],
bottom: 0,
},
- grid: { left: 48, right: 24, top: 22, bottom: 70 },
+ grid: { left: 48, right: 24, top: 22, bottom: 70, containLabel: false },
xAxis: {
type: 'category',
data: items.map((item) => item.bucket_date || ''),
@@ -97,7 +97,7 @@ function handleLegendChange(params) {
diff --git a/frontend/src/reject-history/style.css b/frontend/src/reject-history/style.css
index 3751c0a..e887449 100644
--- a/frontend/src/reject-history/style.css
+++ b/frontend/src/reject-history/style.css
@@ -166,6 +166,27 @@
background: #0b5e59;
}
+.btn-export:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+.btn-spinner {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 2px solid rgba(255, 255, 255, 0.4);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+ margin-right: 6px;
+ vertical-align: middle;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
.reject-summary-row {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
@@ -191,8 +212,10 @@
.chart-wrap,
.pareto-chart-wrap {
+ width: 100%;
height: 340px;
position: relative;
+ overflow: hidden;
}
.pareto-header {
diff --git a/src/mes_dashboard/routes/reject_history_routes.py b/src/mes_dashboard/routes/reject_history_routes.py
index 2d88e2b..80a3b1e 100644
--- a/src/mes_dashboard/routes/reject_history_routes.py
+++ b/src/mes_dashboard/routes/reject_history_routes.py
@@ -333,6 +333,7 @@ def api_reject_history_list():
page = request.args.get("page", 1, type=int) or 1
per_page = request.args.get("per_page", 50, type=int) or 50
+ metric_filter = request.args.get("metric_filter", "all").strip().lower() or "all"
try:
result = query_list(
@@ -347,6 +348,7 @@ def api_reject_history_list():
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
+ metric_filter=metric_filter,
)
data, meta = _extract_meta(
result,
@@ -372,6 +374,7 @@ def api_reject_history_export():
if bool_error:
return jsonify(bool_error[0]), bool_error[1]
+ metric_filter = request.args.get("metric_filter", "all").strip().lower() or "all"
filename = f"reject_history_{start_date}_to_{end_date}.csv"
try:
return Response(
@@ -385,6 +388,7 @@ def api_reject_history_export():
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
+ metric_filter=metric_filter,
),
mimetype="text/csv",
headers={
diff --git a/src/mes_dashboard/services/reject_history_service.py b/src/mes_dashboard/services/reject_history_service.py
index f10534a..6bf78f9 100644
--- a/src/mes_dashboard/services/reject_history_service.py
+++ b/src/mes_dashboard/services/reject_history_service.py
@@ -296,6 +296,9 @@ def _list_to_csv(
rows: Iterable[dict[str, Any]],
headers: list[str],
) -> Generator[str, None, None]:
+ # BOM for UTF-8 so Excel opens the CSV with correct encoding
+ yield "\ufeff"
+
buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=headers)
writer.writeheader()
@@ -561,6 +564,19 @@ def query_reason_pareto(
}
+def _apply_metric_filter(where_clause: str, metric_filter: str) -> str:
+ """Append metric-type filter (reject / defect) to an existing WHERE clause."""
+ if metric_filter == "reject":
+ cond = "b.REJECT_TOTAL_QTY > 0"
+ elif metric_filter == "defect":
+ cond = "b.DEFECT_QTY > 0"
+ else:
+ return where_clause
+ if where_clause.strip():
+ return f"{where_clause} AND {cond}"
+ return f"WHERE {cond}"
+
+
def query_list(
*,
start_date: str,
@@ -574,6 +590,7 @@ def query_list(
include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
+ metric_filter: str = "all",
) -> dict[str, Any]:
_validate_range(start_date, end_date)
@@ -590,6 +607,7 @@ def query_list(
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
+ where_clause = _apply_metric_filter(where_clause, metric_filter)
sql = _prepare_sql("list", where_clause=where_clause, base_variant="lot")
query_params = _common_params(
start_date,
@@ -615,6 +633,7 @@ def query_list(
"WORKCENTER_GROUP": _normalize_text(row.get("WORKCENTER_GROUP")),
"WORKCENTERNAME": _normalize_text(row.get("WORKCENTERNAME")),
"SPECNAME": _normalize_text(row.get("SPECNAME")),
+ "EQUIPMENTNAME": _normalize_text(row.get("EQUIPMENTNAME")),
"PRODUCTLINENAME": _normalize_text(row.get("PRODUCTLINENAME")),
"PJ_TYPE": _normalize_text(row.get("PJ_TYPE")),
"CONTAINERNAME": _normalize_text(row.get("CONTAINERNAME")),
@@ -622,6 +641,7 @@ def query_list(
"PRODUCTNAME": _normalize_text(row.get("PRODUCTNAME")),
"LOSSREASONNAME": _normalize_text(row.get("LOSSREASONNAME")),
"LOSSREASON_CODE": _normalize_text(row.get("LOSSREASON_CODE")),
+ "REJECTCOMMENT": _normalize_text(row.get("REJECTCOMMENT")),
"MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
"REJECT_QTY": _as_int(row.get("REJECT_QTY")),
"STANDBY_QTY": _as_int(row.get("STANDBY_QTY")),
@@ -661,6 +681,7 @@ def export_csv(
include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
+ metric_filter: str = "all",
) -> Generator[str, None, None]:
_validate_range(start_date, end_date)
@@ -673,7 +694,8 @@ def export_csv(
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
- sql = _prepare_sql("export", where_clause=where_clause)
+ where_clause = _apply_metric_filter(where_clause, metric_filter)
+ sql = _prepare_sql("export", where_clause=where_clause, base_variant="lot")
df = read_sql_df(sql, _common_params(start_date, end_date, params))
rows = []
@@ -681,54 +703,52 @@ def export_csv(
for _, row in df.iterrows():
rows.append(
{
- "TXN_DAY": _to_date_str(row.get("TXN_DAY")),
- "TXN_MONTH": _normalize_text(row.get("TXN_MONTH")),
+ "LOT": _normalize_text(row.get("CONTAINERNAME")),
+ "WORKCENTER": _normalize_text(row.get("WORKCENTERNAME")),
"WORKCENTER_GROUP": _normalize_text(row.get("WORKCENTER_GROUP")),
- "WORKCENTERNAME": _normalize_text(row.get("WORKCENTERNAME")),
- "SPECNAME": _normalize_text(row.get("SPECNAME")),
- "PRODUCTLINENAME": _normalize_text(row.get("PRODUCTLINENAME")),
- "PJ_TYPE": _normalize_text(row.get("PJ_TYPE")),
- "LOSSREASONNAME": _normalize_text(row.get("LOSSREASONNAME")),
- "LOSSREASON_CODE": _normalize_text(row.get("LOSSREASON_CODE")),
- "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
+ "Package": _normalize_text(row.get("PRODUCTLINENAME")),
+ "FUNCTION": _normalize_text(row.get("PJ_FUNCTION")),
+ "TYPE": _normalize_text(row.get("PJ_TYPE")),
+ "PRODUCT": _normalize_text(row.get("PRODUCTNAME")),
+ "原因": _normalize_text(row.get("LOSSREASONNAME")),
+ "EQUIPMENT": _normalize_text(row.get("EQUIPMENTNAME")),
+ "COMMENT": _normalize_text(row.get("REJECTCOMMENT")),
+ "SPEC": _normalize_text(row.get("SPECNAME")),
"REJECT_QTY": _as_int(row.get("REJECT_QTY")),
"STANDBY_QTY": _as_int(row.get("STANDBY_QTY")),
"QTYTOPROCESS_QTY": _as_int(row.get("QTYTOPROCESS_QTY")),
"INPROCESS_QTY": _as_int(row.get("INPROCESS_QTY")),
"PROCESSED_QTY": _as_int(row.get("PROCESSED_QTY")),
- "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")),
- "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")),
- "REJECT_RATE_PCT": round(_as_float(row.get("REJECT_RATE_PCT")), 4),
- "DEFECT_RATE_PCT": round(_as_float(row.get("DEFECT_RATE_PCT")), 4),
- "REJECT_SHARE_PCT": round(_as_float(row.get("REJECT_SHARE_PCT")), 4),
- "AFFECTED_LOT_COUNT": _as_int(row.get("AFFECTED_LOT_COUNT")),
- "AFFECTED_WORKORDER_COUNT": _as_int(row.get("AFFECTED_WORKORDER_COUNT")),
+ "扣帳報廢量": _as_int(row.get("REJECT_TOTAL_QTY")),
+ "不扣帳報廢量": _as_int(row.get("DEFECT_QTY")),
+ "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
+ "報廢時間": _to_datetime_str(row.get("TXN_TIME")),
+ "日期": _to_date_str(row.get("TXN_DAY")),
}
)
headers = [
- "TXN_DAY",
- "TXN_MONTH",
+ "LOT",
+ "WORKCENTER",
"WORKCENTER_GROUP",
- "WORKCENTERNAME",
- "SPECNAME",
- "PRODUCTLINENAME",
- "PJ_TYPE",
- "LOSSREASONNAME",
- "LOSSREASON_CODE",
- "MOVEIN_QTY",
+ "Package",
+ "FUNCTION",
+ "TYPE",
+ "PRODUCT",
+ "原因",
+ "EQUIPMENT",
+ "COMMENT",
+ "SPEC",
"REJECT_QTY",
"STANDBY_QTY",
"QTYTOPROCESS_QTY",
"INPROCESS_QTY",
"PROCESSED_QTY",
- "REJECT_TOTAL_QTY",
- "DEFECT_QTY",
- "REJECT_RATE_PCT",
- "DEFECT_RATE_PCT",
- "REJECT_SHARE_PCT",
- "AFFECTED_LOT_COUNT",
- "AFFECTED_WORKORDER_COUNT",
+ "扣帳報廢量",
+ "不扣帳報廢量",
+ "MOVEIN_QTY",
+ "報廢時間",
+ "日期",
]
return _list_to_csv(rows, headers=headers)
diff --git a/src/mes_dashboard/sql/reject_history/export.sql b/src/mes_dashboard/sql/reject_history/export.sql
index dbadf9c..d538aae 100644
--- a/src/mes_dashboard/sql/reject_history/export.sql
+++ b/src/mes_dashboard/sql/reject_history/export.sql
@@ -1,19 +1,24 @@
--- Reject History Export (Unpaginated)
+-- Reject History Export (Unpaginated, Per-LOT)
-- Template slots:
--- BASE_QUERY (base reject-history daily dataset SQL)
+-- BASE_WITH_CTE (lot-level base SQL via performance_daily_lot)
-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b)
{{ BASE_WITH_CTE }}
SELECT
+ b.TXN_TIME,
b.TXN_DAY,
- b.TXN_MONTH,
+ b.CONTAINERNAME,
b.WORKCENTER_GROUP,
b.WORKCENTERNAME,
b.SPECNAME,
+ b.EQUIPMENTNAME,
b.PRODUCTLINENAME,
+ b.PJ_FUNCTION,
b.PJ_TYPE,
+ b.PRODUCTNAME,
b.LOSSREASONNAME,
b.LOSSREASON_CODE,
+ b.REJECTCOMMENT,
b.MOVEIN_QTY,
b.REJECT_QTY,
b.STANDBY_QTY,
@@ -24,13 +29,12 @@ SELECT
b.DEFECT_QTY,
b.REJECT_RATE_PCT,
b.DEFECT_RATE_PCT,
- b.REJECT_SHARE_PCT,
- b.AFFECTED_LOT_COUNT,
- b.AFFECTED_WORKORDER_COUNT
+ b.REJECT_SHARE_PCT
FROM base b
{{ WHERE_CLAUSE }}
ORDER BY
b.TXN_DAY DESC,
b.WORKCENTERSEQUENCE_GROUP ASC,
b.WORKCENTERNAME ASC,
- b.REJECT_TOTAL_QTY DESC
+ b.REJECT_TOTAL_QTY DESC,
+ b.CONTAINERNAME ASC
diff --git a/src/mes_dashboard/sql/reject_history/list.sql b/src/mes_dashboard/sql/reject_history/list.sql
index 4450b8c..950992e 100644
--- a/src/mes_dashboard/sql/reject_history/list.sql
+++ b/src/mes_dashboard/sql/reject_history/list.sql
@@ -39,6 +39,7 @@ SELECT
p.PRODUCTNAME,
p.LOSSREASONNAME,
p.LOSSREASON_CODE,
+ p.REJECTCOMMENT,
p.REJECT_EVENT_ROWS,
p.AFFECTED_WORKORDER_COUNT,
p.MOVEIN_QTY,
diff --git a/src/mes_dashboard/sql/reject_history/performance_daily_lot.sql b/src/mes_dashboard/sql/reject_history/performance_daily_lot.sql
index ae9ffc3..034f8bb 100644
--- a/src/mes_dashboard/sql/reject_history/performance_daily_lot.sql
+++ b/src/mes_dashboard/sql/reject_history/performance_daily_lot.sql
@@ -44,6 +44,7 @@ reject_raw AS (
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)')
) AS LOSSREASON_CODE,
NVL(TRIM(r.REJECTCATEGORYNAME), '(未填寫)') AS REJECTCATEGORYNAME,
+ TRIM(r.REJECTCOMMENT) AS REJECTCOMMENT,
NVL(r.MOVEINQTY, 0) AS MOVEINQTY,
NVL(r.REJECTQTY, 0) AS REJECT_QTY,
NVL(r.STANDBYQTY, 0) AS STANDBY_QTY,
@@ -91,6 +92,7 @@ daily_agg AS (
LOSSREASONNAME,
LOSSREASON_CODE,
REJECTCATEGORYNAME,
+ MAX(REJECTCOMMENT) AS REJECTCOMMENT,
MIN(TXNDATE) AS TXN_TIME,
COUNT(*) AS REJECT_EVENT_ROWS,
COUNT(DISTINCT PJ_WORKORDER) AS AFFECTED_WORKORDER_COUNT,
@@ -143,6 +145,7 @@ SELECT
LOSSREASONNAME,
LOSSREASON_CODE,
REJECTCATEGORYNAME,
+ REJECTCOMMENT,
REJECT_EVENT_ROWS,
AFFECTED_WORKORDER_COUNT,
MOVEIN_QTY,