🌐新手也懂!後端「只留序號最小的一筆」+前端「不要跨欄合併」的完整排錯指南

 

前言:為什麼你明明「有資料」,畫面卻不對?

很多團隊在做「規劃結果」或「排程明細」的頁面時,會同時碰到兩種常見問題:

  1. 後端資料重複:同一筆業務主鍵可能有多個方案,只想保留「序號(順序)最小」那一筆。

  2. 前端表格合併:為了好看會用 rowspan 自動合併格子,但一不小心就把不同明細「合併到看不到」,誤以為後端沒資料。

這篇文章用完全白話的方式,帶你把後端與前端的兩個坑一次踩完、一次填平。
(文中範例皆為重寫的通用示例,不含任何客製業務資訊與隱私。)


你會學到什麼?

  • 用 EF Core / LINQ 只保留每組重複資料中最小順序的一筆

  • 避免 FirstOrDefault 造成資料「漏掉」

  • 在 Vue + Pinia 逐步檢查資料流

  • 先讓表格「完全不合併」,再決定哪些欄位要合併

  • 一份能快速落地的 10 步驟 Debug 清單


一、後端核心觀念:先正確「分組」、再挑出「順序最小」的那一筆

問題長這樣

  • 同一個「訂單號 + 規劃代碼」可能對應多筆方案(例如不同處理方式)。

  • 我們只想在「完全相同內容」的情況下,保留順序欄位(例如 StepSeq)最小的那一筆。

常見踩雷

  • 直接 GroupBy(...).First()不一定會是最小順序

  • GroupBy 的 Key 裡放了會變動的欄位(例如處理結果、數量),導致根本沒有合併到同一組

  • 明細表用 FirstOrDefault多筆明細只會取第一筆,其它明細直接消失

正確示例(通用改寫)

需求:
  • 主表 Plan(多筆)
  • 明細 NoteANoteB(各自可能一對多)
  • 每個主鍵群組只保留 StepSeq 最小的那筆
  • 先拿到完整主資料,再逐筆補上需要的欄位
// 1) 撈主資料 var plans = await db.Plans .Where(p => p.Version == version && p.Stage == "PhaseA" && p.PlanIndex <= 3) .ToListAsync(); // 2) 撈補助資料(一次取回到記憶體) var noteAs = await db.NoteA.Where(n => n.Version == version).ToListAsync(); var noteBs = await db.NoteB.Where(n => n.Version == version).ToListAsync(); // 3) 先把主資料轉成畫面要的 ViewModel,補助資訊等會再填 var rows = plans.Select(p => new PlanRowVm { OrderNo = p.OrderNo, PlanCode = p.PlanCode, Stage = p.Stage, Site = p.Site, Product = p.Product, // ……(略)…… StepSeq = p.StepSeq ?? 0, // 用整數保存,之後排序方便 // 預留欄位 A_Note = "", B_Note = "" }).ToList(); // 4) 逐筆補齊(保證主資料不會被 join 掉) foreach (var row in rows) { var a = noteAs.FirstOrDefault(x => x.PlanCode == row.PlanCode); if (a != null) row.A_Note = a.Comment ?? ""; var b = noteBs.FirstOrDefault(x => x.PlanCode == row.PlanCode); if (b != null) row.B_Note = b.Comment ?? ""; } // 5) 對「完全相同的內容(不含 StepSeq)」分組,只留 StepSeq 最小 var deduped = rows .GroupBy(r => new { r.OrderNo, r.PlanCode, r.Stage, r.Site, r.Product, r.A_Note, r.B_Note // 👉 把你要拿來定義「相同」的欄位都放這裡,但**不要放** StepSeq }) .Select(g => g.OrderBy(x => x.StepSeq).First()) .ToList(); return deduped;

思考重點

  • GroupBy 的 Key = 你定義的「相同資料」

  • 不要把 StepSeq(要比較大小的欄位)放到 Key 裡

  • 如果明細是多筆要展開列表,請用 from ... from ... 的 flat 方式,而不是 FirstOrDefault(否則會漏)


二、前端核心觀念:先全部顯示,再談合併格子

很多人看到畫面「好像少資料」,其實只是 rowspan 合併太積極 把內容蓋掉了。
最保險的步驟是先「完全不合併」,確認資料沒問題,再一步步加回合併規則。

先關掉合併(最小可行化)

<tr v-for="(row, rIdx) in filteredData" :key="rIdx"> <td v-for="(hdr, cIdx) in tableHeaders" :key="cIdx"> {{ row[hdr] }} </td> </tr>

先驗證

  • 每列是否完整?

  • 筆數是否對得上後端?

  • 排序是否正確(必要時依 PlanCodeOrderNo 再排序)?

想要「只合併少數欄位」怎麼做?

  • 例如你只想合併 PlanCodeOrderNo,其他欄位不合併

  • 就只計算那兩個欄位的 rowspan,其他欄位 rowspan=1

(等你前面的資料完整沒問題後,再來加這層,避免一次改兩處抓不到點)


三、新手友善版:Vue + Pinia 排錯 10 步清單

  1. API 回傳檢查console.log('raw', res.data)

  2. 轉換後資料console.log('rows', rows)

  3. 映射結果console.log('mappedTableData', mappedTableData.value)

  4. 表頭console.log('tableHeaders', tableHeaders.value)(對照資料的 key)

  5. 唯一值與篩選console.log('uniqueValues', uniqueValues.value)console.log('filters', filters.value)

  6. 過濾後結果console.log('filteredData', filteredData.value)

  7. (若有合併)rowSpansconsole.log('rowSpans', rowSpans.value)

  8. 先關合併:把 <td :rowspan="..."> 換成一般 <td>,確保資料量正確

  9. 逐一開啟功能:搜尋 → 篩選 → 合併 → 匯出,逐步驗證

  10. Excel 匯出來源:匯出前 console.log('dataToExport', dataToExport),確認與畫面一致


四、為什麼「只取最小序號」要放在最後?

因為先把資料展開完整,你才知道「哪些是真的不同」、「哪些只是重複」。
把所有內容(除序號外)當作 Group Key,再挑 StepSeq 最小者,就不會誤刪有意義的多筆。


五、FAQ:你可能還會遇到…

Q1:我用 FirstOrDefault 取明細會不會漏?
A:會。多對多要用「展開」或先合併後再拼接,FirstOrDefault 只會拿第一筆。

Q2:GroupBy 要放哪些欄位?
A:定義「相同資料」的所有欄位,但不要放排序比較用的序號

Q3:為什麼畫面看起來像少資料?
A:90% 是 rowspan 合併掉了。先關掉合併確認,再逐步打開。


六、SEO 小抄:這篇文解決的關鍵字

  • EF Core LINQ 去重

  • 取最小序號的一筆

  • Vue 表格 rowSpan 合併

  • Vue Pinia 篩選與資料流

  • 前端表格資料不見

  • 匯出 Excel 與畫面不一致

  • 後端 join 導致資料漏掉


結語

把問題拆成兩半:
後端先分組再挑最小序號前端先完全顯示,再決定哪些欄位要合併
照著這套步驟,你會發現——其實不是資料沒來,而是我們把它「合併到看不見」了。

留言

這個網誌中的熱門文章

🔍Vue.js 專案錯誤排查:解決 numericFields is not defined 與合併儲存格邏輯最佳化

🔎EF Core 連 Oracle 出現 ORA-00600 [kpp_concatq:2] 的完整排錯指南(含 EF Core ToString/CultureInfo 錯誤)

🛠【ASP.NET Core + Oracle】解決 ORA-00904 "FALSE": 無效的 ID 錯誤與資料欄位動態插入顯示問題