diff --git a/data/查詢範例.xlsx b/data/查詢範例.xlsx new file mode 100644 index 0000000..91a4678 Binary files /dev/null and b/data/查詢範例.xlsx differ diff --git a/web/App_Code/Model/ViewModel/bed.cs b/web/App_Code/Model/ViewModel/bed.cs index 7832f2d..59c565c 100644 --- a/web/App_Code/Model/ViewModel/bed.cs +++ b/web/App_Code/Model/ViewModel/bed.cs @@ -1,73 +1,38 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Web; -using System.Data; -using System.Configuration; -using System.Collections; -using Newtonsoft.Json; -using System.ComponentModel; -using System.Globalization; -using System.Web.UI; - -using System; using System.Collections; using System.Collections.Generic; -using System.Data; -using System.IO; -using System.IO.Compression; -using System.Net.Mail; -using System.Configuration; -using System.Drawing; -using System.Drawing.Imaging; -using System.Web; -using System.Web.UI; -using System.Web.UI.WebControls; -using System.Data.OleDb; -using Microsoft.VisualBasic; -using System.Text; -using System.Text.RegularExpressions; -using System.Web.Security; -using System.Security.Cryptography; -using System.Web.UI; -using System.Collections; using System.Collections.Specialized; using System.ComponentModel; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.Design; using System.ComponentModel.Design.Serialization; +using System.Configuration; +using System.Data; +using System.Data.OleDb; +using System.Drawing; +using System.Drawing.Imaging; +using System.Globalization; using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Mail; +using System.Reflection; +using System.Security.Cryptography; using System.Security.Principal; +using System.Text; +using System.Text.RegularExpressions; +using System.Web; using System.Web.Caching; using System.Web.ModelBinding; using System.Web.Routing; +using System.Web.Security; using System.Web.SessionState; +using System.Web.UI; using System.Web.UI.Adapters; using System.Web.UI.HtmlControls; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Web; -using System.Collections; -using System.Data; -using System.Data.OleDb; -using System.Configuration; -using System.Web.UI; using System.Web.UI.WebControls; using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.IO; -using System.Web; -using System.Web.UI; -using System.Text; -using System.Text.RegularExpressions; -using System.Net; -using System.Reflection; -using System.ComponentModel; -using System.Web.UI.WebControls; -using System.Collections.Generic; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; namespace Model.ViewModel diff --git a/web/App_Code/api/pivot01Controller.cs b/web/App_Code/api/pivot01Controller.cs new file mode 100644 index 0000000..5131171 --- /dev/null +++ b/web/App_Code/api/pivot01Controller.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlClient; +using System.Linq; +using System.Web.Http; +using System.Configuration; + +/// +/// pivot01Controller - 法會報名統計分析 API +/// 設計理念:直接查詢 SQL VIEW,保持彈性與簡潔 +/// +[ezAuthorize] +public class pivot01Controller : ApiController +{ + // 連線字串 + private readonly string _connectionString; + + public pivot01Controller() + { + // 優先使用 shopConn 連線字串(純 SQL Server 連線字串) + var shopConnectionString = ConfigurationManager.ConnectionStrings["shopConn"]?.ConnectionString; + if (!string.IsNullOrEmpty(shopConnectionString)) + { + // 移除不相容的 Provider 參數 + _connectionString = shopConnectionString.Replace("Provider=SQLOLEDB;", ""); + } + else + { + // 備用方案:從 Entity Framework 連線字串中提取 SQL Server 連線字串 + var efConnectionString = ConfigurationManager.ConnectionStrings["ezEntities"]?.ConnectionString; + if (!string.IsNullOrEmpty(efConnectionString)) + { + // 解析 EF 連線字串,提取 provider connection string 部分 + var startIndex = efConnectionString.IndexOf("provider connection string="") + "provider connection string="".Length; + var endIndex = efConnectionString.LastIndexOf("""); + if (startIndex > 0 && endIndex > startIndex) + { + _connectionString = efConnectionString.Substring(startIndex, endIndex - startIndex); + } + else + { + throw new InvalidOperationException("無法解析 Entity Framework 連線字串"); + } + } + else + { + throw new InvalidOperationException("找不到可用的資料庫連線字串"); + } + } + } + + #region 通用 VIEW 查詢方法 + + /// + /// 執行 SQL 查詢並回傳 DataTable + /// + /// SQL 查詢語句 + /// 參數陣列 + /// 查詢結果 DataTable + private DataTable ExecuteSqlQuery(string sql, SqlParameter[] parameters = null) + { + var dataTable = new DataTable(); + + try + { + using (var connection = new SqlConnection(_connectionString)) + using (var command = new SqlCommand(sql, connection)) + { + // 設定逾時時間為 60 秒 + command.CommandTimeout = 60; + + // 加入參數 + if (parameters != null && parameters.Length > 0) + { + command.Parameters.AddRange(parameters); + } + + // 開啟連線並執行查詢 + connection.Open(); + using (var adapter = new SqlDataAdapter(command)) + { + adapter.Fill(dataTable); + } + } + } + catch (SqlException ex) + { + throw new Exception($"SQL 查詢錯誤: {ex.Message}", ex); + } + catch (Exception ex) + { + throw new Exception($"執行查詢時發生錯誤: {ex.Message}", ex); + } + + return dataTable; + } + + /// + /// DataTable 轉換為動態物件列表(保留中文欄位名) + /// + /// DataTable + /// Dictionary 列表 + private List> DataTableToDictionary(DataTable dt) + { + var list = new List>(); + + foreach (DataRow row in dt.Rows) + { + var dict = new Dictionary(); + foreach (DataColumn col in dt.Columns) + { + // 保留原始欄位名稱(包含中文) + dict[col.ColumnName] = row[col] == DBNull.Value ? null : row[col]; + } + list.Add(dict); + } + + return list; + } + + #endregion + + #region API 端點 + + /// + /// GET /api/pivot01/activity_stats + /// 查詢法會統計(對應「法會統計」VIEW) + /// + /// 查詢年份 + /// 法會統計資料 + [HttpGet] + [Route("api/pivot01/activity_stats")] + public IHttpActionResult GetActivityStats(int year) + { + try + { + // 驗證年份參數 + if (year < 1900 || year > 2100) + { + return BadRequest("年份參數不正確,請輸入 1900 ~ 2100 之間的年份"); + } + + // 建立 SQL 查詢(包含當年度及無日期的法會) + string sql = @" + SELECT * FROM [法會統計] + WHERE (YEAR(開始日期) = @year OR 開始日期 IS NULL) + ORDER BY + CASE WHEN 開始日期 IS NULL THEN 1 ELSE 0 END, + 開始日期 DESC, + 結束日期 DESC + "; + + // 建立參數 + var parameters = new[] + { + new SqlParameter("@year", SqlDbType.Int) { Value = year } + }; + + // 執行查詢 + var dataTable = ExecuteSqlQuery(sql, parameters); + var data = DataTableToDictionary(dataTable); + + // 回應結果 + var result = new + { + success = true, + data = data, + message = "查詢成功", + rowCount = data.Count + }; + + return Ok(result); + } + catch (Exception ex) + { + var errorResponse = new + { + success = false, + message = $"查詢失敗:{ex.Message}", + error = ex.ToString() + }; + return Content(System.Net.HttpStatusCode.BadRequest, errorResponse); + } + } + + /// + /// GET /api/pivot01/registration_details + /// 查詢報名明細(對應「報名明細查詢」VIEW) + /// + /// 法會編號(必填) + /// 報名明細資料 + [HttpGet] + [Route("api/pivot01/registration_details")] + public IHttpActionResult GetRegistrationDetails(int? activityNum = null) + { + try + { + // 驗證參數 + if (!activityNum.HasValue || activityNum.Value <= 0) + { + return BadRequest("請提供有效的法會編號(activityNum)"); + } + + // 建立查詢 SQL + string sql = @" + SELECT * FROM [報名明細查詢] + WHERE 法會ID = @activityNum + ORDER BY 報名日期 DESC, 報名編號 DESC + "; + + // 建立參數 + var parameters = new[] + { + new SqlParameter("@activityNum", SqlDbType.Int) { Value = activityNum.Value } + }; + + // 執行查詢 + var dataTable = ExecuteSqlQuery(sql, parameters); + var data = DataTableToDictionary(dataTable); + + // 回應結果 + var result = new + { + success = true, + data = data, + message = "查詢成功", + rowCount = data.Count + }; + + return Ok(result); + } + catch (Exception ex) + { + var errorResponse = new + { + success = false, + message = $"查詢失敗:{ex.Message}", + error = ex.ToString() + }; + return Content(System.Net.HttpStatusCode.BadRequest, errorResponse); + } + } + + #endregion +} diff --git a/web/App_Code/api/pivotController.cs b/web/App_Code/api/pivotController.cs new file mode 100644 index 0000000..78ede99 --- /dev/null +++ b/web/App_Code/api/pivotController.cs @@ -0,0 +1,1142 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Web.Http; +using PagedList; +using Newtonsoft.Json; +using System.Collections; +using System.Data.Entity; + +// api/pivot +[ezAuthorize] +public class pivotController : ApiController +{ + private Model.ezEntities _db = new Model.ezEntities(); + + #region 報表查詢 API + + /// + /// 報名明細查詢 - 對應 Excel 中的視圖結構 + /// GET api/pivot/registration_details + /// + /// 開始日期 + /// 結束日期 + /// 法會編號 + /// 信眾編號 + /// 頁碼 + /// 每頁筆數 + /// + [HttpGet] + [Route("api/pivot/registration_details")] + public IHttpActionResult GetRegistrationDetails(string startDate = null, string endDate = null, + int? activityNum = null, int? followerNum = null, int page = 1, int pageSize = 1000) + { + try + { + // 對應 README.md 中的視圖查詢結構,按顏色分類欄位 + var query = from pod in _db.pro_order_detail + join po in _db.pro_order on pod.order_no equals po.order_no + join ai in _db.actItems on pod.actItem_num equals ai.num + join act in _db.activities on po.activity_num equals act.num + join f in _db.followers on po.f_num equals f.num + join aik in _db.actItem_kind on ai.kind equals aik.num + select new + { + // 🟠 橘色欄位 - 法會基礎資料 (法會相關的直接來源) + 法會ID = act.num, + 法會名稱 = act.subject, + 開始日期 = act.startDate_solar, + 結束日期 = act.endDate_solar, + + // 🔵 藍色欄位 - 信眾基礎資料 (信眾相關的直接來源) + 信眾編號 = f.f_number, + 信眾姓名 = f.u_name, + 報名編號 = po.order_no, + 報名日期 = po.up_time, + + // 🟢綠色欄位 - 功德資訊欄位 (功德相關資訊) + 功德主 = pod.parent_num != null ? "是" : "否", + 功德類型 = aik.kind, + 功德名稱 = ai.subject, + + // 🟣 紫色欄位 - 計算欄位 (需要計算) + 數量 = pod.qty, + 金額 = pod.price, + 已收 = 0, // 業務邏輯:固定為 0 + 未收 = pod.price * pod.qty - 0, // 計算未收金額 + + // 額外提供原始資料供進階計算 + _metadata = new + { + parent_num = pod.parent_num, + unit_price = pod.price, + quantity = pod.qty, + total_amount = pod.price * pod.qty, + kind_num = aik.num, + item_num = ai.num, + activity_num = act.num, + follower_num = f.num + } + }; + + // 日期篩選 + if (!string.IsNullOrEmpty(startDate) && DateTime.TryParse(startDate, out DateTime start)) + { + query = query.Where(x => x.開始日期 >= start); + } + if (!string.IsNullOrEmpty(endDate) && DateTime.TryParse(endDate, out DateTime end)) + { + query = query.Where(x => x.結束日期 <= end); + } + + // 法會篩選 + if (activityNum.HasValue) + { + query = query.Where(x => x.法會ID == activityNum.Value); + } + + // 信眾篩選 + if (followerNum.HasValue) + { + query = query.Where(x => x.信眾編號 == followerNum.ToString()); + } + + // 分頁處理 + var totalCount = query.Count(); + var pagedQuery = query.OrderByDescending(x => x.報名日期) + .ThenByDescending(x => x.法會ID) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToList(); + + var result = new + { + success = true, + data = new + { + list = pagedQuery, + total = totalCount, + page = page, + pageSize = pageSize, + totalPages = (int)Math.Ceiling((double)totalCount / pageSize) + }, + message = "查詢成功" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"查詢失敗:{ex.Message}"); + } + } + + /// + /// 報名明細查詢 - Excel 匯出格式 + /// GET api/pivot/registration_details_export + /// + /// 開始日期 + /// 結束日期 + /// 法會編號 + /// 信眾編號 + /// + [HttpGet] + [Route("api/pivot/registration_details_export")] + public IHttpActionResult GetRegistrationDetailsForExport(string startDate = null, string endDate = null, + int? activityNum = null, int? followerNum = null) + { + try + { + // 完全對應 Excel 中的欄位結構 + var query = from pod in _db.pro_order_detail + join po in _db.pro_order on pod.order_no equals po.order_no + join ai in _db.actItems on pod.actItem_num equals ai.num + join act in _db.activities on po.activity_num equals act.num + join f in _db.followers on po.f_num equals f.num + join aik in _db.actItem_kind on ai.kind equals aik.num + select new + { + // 完全對應 Excel 欄位名稱 + 法會ID = act.num, + 法會名稱 = act.subject, + 開始日期 = act.startDate_solar, + 結束日期 = act.endDate_solar, + 信眾編號 = f.f_number, + 信眾姓名 = f.u_name, + 報名編號 = po.order_no, + 報名日期 = po.up_time, + 功德主 = pod.parent_num != null ? "是" : "否", + 功德類型 = aik.kind, + 功德名稱 = ai.subject, + 數量 = pod.qty, + 金額 = pod.price, + 已收 = 0, + 未收 = pod.price * pod.qty + }; + + // 套用篩選條件 + if (!string.IsNullOrEmpty(startDate) && DateTime.TryParse(startDate, out DateTime start)) + { + query = query.Where(x => x.開始日期 >= start); + } + if (!string.IsNullOrEmpty(endDate) && DateTime.TryParse(endDate, out DateTime end)) + { + query = query.Where(x => x.結束日期 <= end); + } + if (activityNum.HasValue) + { + query = query.Where(x => x.法會ID == activityNum.Value); + } + if (followerNum.HasValue) + { + query = query.Where(x => x.信眾編號 == followerNum.ToString()); + } + + var exportData = query.OrderByDescending(x => x.報名日期) + .ThenByDescending(x => x.法會ID) + .ToList() + .Select(x => new + { + x.法會ID, + x.法會名稱, + 開始日期 = x.開始日期.HasValue ? x.開始日期.Value.ToString("yyyy/MM/dd") : "", + 結束日期 = x.結束日期.HasValue ? x.結束日期.Value.ToString("yyyy/MM/dd") : "", + x.信眾編號, + x.信眾姓名, + x.報名編號, + 報名日期 = x.報名日期.HasValue ? x.報名日期.Value.ToString("yyyy/MM/dd HH:mm") : "", + x.功德主, + x.功德類型, + x.功德名稱, + x.數量, + x.金額, + x.已收, + x.未收 + }) + .ToList(); + + // 統計資訊 + var summary = new + { + 總筆數 = exportData.Count, + 總金額 = exportData.Sum(x => x.金額 * x.數量), + 參與法會數 = exportData.Select(x => x.法會ID).Distinct().Count(), + 參與信眾數 = exportData.Select(x => x.信眾編號).Distinct().Count(), + 功德主數量 = exportData.Count(x => x.功德主 == "是") + }; + + var result = new + { + success = true, + data = new + { + details = exportData, + summary = summary, + export_time = DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss"), + filters = new + { + startDate, + endDate, + activityNum, + followerNum + } + }, + message = "匯出資料準備完成" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"匯出失敗:{ex.Message}"); + } + } + + /// + /// 取得法會報名統計 + /// GET api/pivot/activity_stats + /// + /// 開始日期 + /// 結束日期 + /// 法會編號 + /// + [HttpGet] + [Route("api/pivot/activity_stats")] + public IHttpActionResult GetActivityStats(string startDate = null, string endDate = null, int? activityNum = null) + { + try + { + var query = from pod in _db.pro_order_detail + join po in _db.pro_order on pod.order_no equals po.order_no + join act in _db.activities on po.activity_num equals act.num + join ai in _db.actItems on pod.actItem_num equals ai.num + join aik in _db.actItem_kind on ai.kind equals aik.num + join f in _db.followers on po.f_num equals f.num + select new + { + ActivityNum = act.num, + ActivityName = act.subject, + StartDate = act.startDate_solar, + EndDate = act.endDate_solar, + FollowerNum = f.num, + FollowerName = f.u_name, + OrderNo = po.order_no, + OrderDate = po.up_time, + KindName = aik.kind, + ItemName = ai.subject, + Qty = pod.qty, + Price = pod.price, + Amount = pod.qty * pod.price + }; + + // 日期篩選 + if (!string.IsNullOrEmpty(startDate) && DateTime.TryParse(startDate, out DateTime start)) + { + query = query.Where(x => x.StartDate >= start); + } + if (!string.IsNullOrEmpty(endDate) && DateTime.TryParse(endDate, out DateTime end)) + { + query = query.Where(x => x.EndDate <= end); + } + + // 法會篩選 + if (activityNum.HasValue) + { + query = query.Where(x => x.ActivityNum == activityNum.Value); + } + + // 統計資料 + var stats = query.GroupBy(x => new { x.ActivityNum, x.ActivityName, x.StartDate, x.EndDate }) + .Select(g => new + { + activity_num = g.Key.ActivityNum, + activity_name = g.Key.ActivityName, + start_date = g.Key.StartDate, + end_date = g.Key.EndDate, + total_orders = g.Select(x => x.OrderNo).Distinct().Count(), + total_followers = g.Select(x => x.FollowerNum).Distinct().Count(), + total_amount = g.Sum(x => x.Amount), + total_qty = g.Sum(x => x.Qty), + avg_amount_per_follower = g.GroupBy(x => x.FollowerNum).Average(fg => fg.Sum(x => x.Amount)) + }) + .OrderByDescending(x => x.start_date) + .ToList(); + + var result = new + { + success = true, + data = stats, + message = "查詢成功" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"查詢失敗:{ex.Message}"); + } + } + + /// + /// 取得信眾參與分析 + /// GET api/pivot/follower_analysis + /// + /// 開始日期 + /// 結束日期 + /// 信眾編號 + /// + [HttpGet] + [Route("api/pivot/follower_analysis")] + public IHttpActionResult GetFollowerAnalysis(string startDate = null, string endDate = null, int? followerNum = null) + { + try + { + var query = from pod in _db.pro_order_detail + join po in _db.pro_order on pod.order_no equals po.order_no + join act in _db.activities on po.activity_num equals act.num + join ai in _db.actItems on pod.actItem_num equals ai.num + join aik in _db.actItem_kind on ai.kind equals aik.num + join f in _db.followers on po.f_num equals f.num + select new + { + FollowerNum = f.num, + FollowerName = f.u_name, + FollowerCode = f.f_number, + ActivityNum = act.num, + ActivityName = act.subject, + StartDate = act.startDate_solar, + EndDate = act.endDate_solar, + OrderDate = po.up_time, + KindName = aik.kind, + ItemName = ai.subject, + Amount = pod.qty * pod.price, + HasParent = pod.parent_num != null + }; + + // 日期篩選 + if (!string.IsNullOrEmpty(startDate) && DateTime.TryParse(startDate, out DateTime start)) + { + query = query.Where(x => x.StartDate >= start); + } + if (!string.IsNullOrEmpty(endDate) && DateTime.TryParse(endDate, out DateTime end)) + { + query = query.Where(x => x.EndDate <= end); + } + + // 信眾篩選 + if (followerNum.HasValue) + { + query = query.Where(x => x.FollowerNum == followerNum.Value); + } + + // 信眾統計分析 + var followerStats = query.GroupBy(x => new { x.FollowerNum, x.FollowerName, x.FollowerCode }) + .Select(g => new + { + follower_num = g.Key.FollowerNum, + follower_name = g.Key.FollowerName, + follower_code = g.Key.FollowerCode, + total_activities = g.Select(x => x.ActivityNum).Distinct().Count(), + total_amount = g.Sum(x => x.Amount), + total_orders = g.Select(x => x.OrderDate).Count(), + avg_amount_per_activity = g.GroupBy(x => x.ActivityNum).Average(ag => ag.Sum(x => x.Amount)), + is_patron_count = g.Count(x => x.HasParent), + participation_rate = Math.Round((double)g.Select(x => x.ActivityNum).Distinct().Count() / _db.activities.Count() * 100, 2), + favorite_kinds = g.GroupBy(x => x.KindName) + .OrderByDescending(kg => kg.Sum(x => x.Amount)) + .Take(3) + .Select(kg => new { kind = kg.Key, amount = kg.Sum(x => x.Amount) }) + .ToList(), + recent_activities = g.OrderByDescending(x => x.OrderDate) + .Take(5) + .Select(x => new { + activity_name = x.ActivityName, + order_date = x.OrderDate, + amount = x.Amount + }) + .ToList() + }) + .OrderByDescending(x => x.total_amount) + .ToList(); + + var result = new + { + success = true, + data = followerStats, + message = "查詢成功" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"查詢失敗:{ex.Message}"); + } + } + + /// + /// 取得收入統計 + /// GET api/pivot/income_stats + /// + /// 開始日期 + /// 結束日期 + /// 分組方式 (monthly/yearly/activity) + /// + [HttpGet] + [Route("api/pivot/income_stats")] + public IHttpActionResult GetIncomeStats(string startDate = null, string endDate = null, string groupBy = "monthly") + { + try + { + var query = from pod in _db.pro_order_detail + join po in _db.pro_order on pod.order_no equals po.order_no + join act in _db.activities on po.activity_num equals act.num + join ai in _db.actItems on pod.actItem_num equals ai.num + join aik in _db.actItem_kind on ai.kind equals aik.num + select new + { + ActivityNum = act.num, + ActivityName = act.subject, + StartDate = act.startDate_solar, + OrderDate = po.up_time, + KindNum = aik.num, + KindName = aik.kind, + ItemName = ai.subject, + Amount = pod.qty * pod.price + }; + + // 日期篩選 + if (!string.IsNullOrEmpty(startDate) && DateTime.TryParse(startDate, out DateTime start)) + { + query = query.Where(x => x.StartDate >= start); + } + if (!string.IsNullOrEmpty(endDate) && DateTime.TryParse(endDate, out DateTime end)) + { + query = query.Where(x => x.StartDate <= end); + } + + object stats = null; + + switch (groupBy?.ToLower()) + { + case "monthly": + stats = query.GroupBy(x => new { + Year = x.StartDate.Value.Year, + Month = x.StartDate.Value.Month + }) + .Select(g => new + { + period = $"{g.Key.Year}/{g.Key.Month:00}", + year = g.Key.Year, + month = g.Key.Month, + total_amount = g.Sum(x => x.Amount), + total_activities = g.Select(x => x.ActivityNum).Distinct().Count(), + kind_breakdown = g.GroupBy(x => x.KindName) + .Select(kg => new { + kind = kg.Key, + amount = kg.Sum(x => x.Amount) + }).ToList() + }) + .OrderBy(x => x.year).ThenBy(x => x.month) + .ToList(); + break; + + case "yearly": + stats = query.GroupBy(x => x.StartDate.Value.Year) + .Select(g => new + { + period = g.Key.ToString(), + year = g.Key, + total_amount = g.Sum(x => x.Amount), + total_activities = g.Select(x => x.ActivityNum).Distinct().Count(), + kind_breakdown = g.GroupBy(x => x.KindName) + .Select(kg => new { + kind = kg.Key, + amount = kg.Sum(x => x.Amount) + }).ToList(), + monthly_trend = g.GroupBy(x => x.StartDate.Value.Month) + .Select(mg => new { + month = mg.Key, + amount = mg.Sum(x => x.Amount) + }).OrderBy(x => x.month).ToList() + }) + .OrderBy(x => x.year) + .ToList(); + break; + + case "activity": + stats = query.GroupBy(x => new { x.ActivityNum, x.ActivityName, x.StartDate }) + .Select(g => new + { + activity_num = g.Key.ActivityNum, + activity_name = g.Key.ActivityName, + start_date = g.Key.StartDate, + total_amount = g.Sum(x => x.Amount) + }) + .OrderByDescending(x => x.start_date) + .ToList() + .Select(g => new + { + g.activity_num, + g.activity_name, + g.start_date, + g.total_amount, + kind_breakdown = query.Where(x => x.ActivityNum == g.activity_num) + .GroupBy(x => x.KindName) + .Select(kg => new + { + kind = kg.Key, + amount = kg.Sum(x => x.Amount), + percentage = g.total_amount.HasValue && g.total_amount.Value > 0 + ? Math.Round((double)(kg.Sum(x => x.Amount) ?? 0) / (double)g.total_amount.Value * 100, 2) + : 0 + }) + .OrderByDescending(x => x.amount) + .ToList() + }) + .ToList(); + break; + + default: + return BadRequest("不支援的分組方式,請使用 monthly, yearly 或 activity"); + } + + var result = new + { + success = true, + data = stats, + groupBy = groupBy, + message = "查詢成功" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"查詢失敗:{ex.Message}"); + } + } + + #endregion + + #region 數據分析 API + + /// + /// 樞紐分析 + /// POST api/pivot/pivot_analysis + /// + /// 分析請求參數 + /// + [HttpPost] + [Route("api/pivot/pivot_analysis")] + public IHttpActionResult PostPivotAnalysis(dynamic request) + { + try + { + // TODO: 實作樞紐分析邏輯 + var result = new + { + success = true, + data = new + { + rows = new List(), + columns = new List(), + values = new List() + }, + message = "分析完成" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"分析失敗:{ex.Message}"); + } + } + + /// + /// 趨勢分析 + /// GET api/pivot/trend_analysis + /// + /// 指標名稱 + /// 開始日期 + /// 結束日期 + /// 時間間隔 + /// + [HttpGet] + [Route("api/pivot/trend_analysis")] + public IHttpActionResult GetTrendAnalysis(string metric, string startDate = null, string endDate = null, string interval = "monthly") + { + try + { + if (string.IsNullOrEmpty(metric)) + { + return BadRequest("請指定分析指標"); + } + + var query = from pod in _db.pro_order_detail + join po in _db.pro_order on pod.order_no equals po.order_no + join act in _db.activities on po.activity_num equals act.num + join f in _db.followers on po.f_num equals f.num + select new + { + ActivityDate = act.startDate_solar, + OrderDate = po.up_time, + Amount = pod.qty * pod.price, + FollowerNum = f.num, + ActivityNum = act.num + }; + + // 日期篩選 + if (!string.IsNullOrEmpty(startDate) && DateTime.TryParse(startDate, out DateTime start)) + { + query = query.Where(x => x.ActivityDate >= start); + } + if (!string.IsNullOrEmpty(endDate) && DateTime.TryParse(endDate, out DateTime end)) + { + query = query.Where(x => x.ActivityDate <= end); + } + + object trendData = null; + string trendDirection = "stable"; + + switch (metric?.ToLower()) + { + case "income": // 收入趨勢 + if (interval == "monthly") + { + var monthlyIncome = query.GroupBy(x => new { + Year = x.ActivityDate.Value.Year, + Month = x.ActivityDate.Value.Month + }) + .Select(g => new + { + period = $"{g.Key.Year}/{g.Key.Month:00}", + value = g.Sum(x => x.Amount), + date = new DateTime(g.Key.Year, g.Key.Month, 1) + }) + .OrderBy(x => x.date) + .ToList(); + + // 計算趨勢方向 + if (monthlyIncome.Count >= 2) + { + var firstHalf = monthlyIncome.Take(monthlyIncome.Count / 2).Average(x => (double)x.value); + var secondHalf = monthlyIncome.Skip(monthlyIncome.Count / 2).Average(x => (double)x.value); + trendDirection = secondHalf > firstHalf * 1.05 ? "up" : (secondHalf < firstHalf * 0.95 ? "down" : "stable"); + } + + trendData = new + { + labels = monthlyIncome.Select(x => x.period).ToList(), + values = monthlyIncome.Select(x => x.value).ToList(), + growth_rates = monthlyIncome.Select((x, i) => i == 0 ? 0.0 : + Math.Round((double)(x.value - monthlyIncome[i-1].value) / (double)monthlyIncome[i-1].value * 100, 2)) + .ToList() + }; + } + break; + + case "followers": // 參與信眾數趨勢 + if (interval == "monthly") + { + var monthlyFollowers = query.GroupBy(x => new { + Year = x.ActivityDate.Value.Year, + Month = x.ActivityDate.Value.Month + }) + .Select(g => new + { + period = $"{g.Key.Year}/{g.Key.Month:00}", + value = g.Select(x => x.FollowerNum).Distinct().Count(), + date = new DateTime(g.Key.Year, g.Key.Month, 1) + }) + .OrderBy(x => x.date) + .ToList(); + + // 計算趨勢方向 + if (monthlyFollowers.Count >= 2) + { + var firstHalf = monthlyFollowers.Take(monthlyFollowers.Count / 2).Average(x => (double)x.value); + var secondHalf = monthlyFollowers.Skip(monthlyFollowers.Count / 2).Average(x => (double)x.value); + trendDirection = secondHalf > firstHalf * 1.05 ? "up" : (secondHalf < firstHalf * 0.95 ? "down" : "stable"); + } + + trendData = new + { + labels = monthlyFollowers.Select(x => x.period).ToList(), + values = monthlyFollowers.Select(x => x.value).ToList(), + growth_rates = monthlyFollowers.Select((x, i) => i == 0 ? 0.0 : + Math.Round((double)(x.value - monthlyFollowers[i-1].value) / (double)monthlyFollowers[i-1].value * 100, 2)) + .ToList() + }; + } + break; + + case "activities": // 法會數量趨勢 + var monthlyActivities = query.GroupBy(x => new { + Year = x.ActivityDate.Value.Year, + Month = x.ActivityDate.Value.Month + }) + .Select(g => new + { + period = $"{g.Key.Year}/{g.Key.Month:00}", + value = g.Select(x => x.ActivityNum).Distinct().Count(), + date = new DateTime(g.Key.Year, g.Key.Month, 1) + }) + .OrderBy(x => x.date) + .ToList(); + + trendData = new + { + labels = monthlyActivities.Select(x => x.period).ToList(), + values = monthlyActivities.Select(x => x.value).ToList() + }; + break; + + default: + return BadRequest("不支援的指標,請使用 income, followers 或 activities"); + } + + var result = new + { + success = true, + data = new + { + metric = metric, + interval = interval, + trend = trendDirection, + chart_data = trendData + }, + message = "分析完成" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"分析失敗:{ex.Message}"); + } + } + + /// + /// 對比分析 + /// GET api/pivot/comparative_analysis + /// + /// 對比類型 + /// 期間1 + /// 期間2 + /// + [HttpGet] + [Route("api/pivot/comparative_analysis")] + public IHttpActionResult GetComparativeAnalysis(string compareType, string period1, string period2) + { + try + { + // TODO: 實作對比分析邏輯 + var result = new + { + success = true, + data = new + { + period1_data = new List(), + period2_data = new List(), + comparison = new + { + growth_rate = 0.0, + difference = 0.0 + } + }, + message = "對比完成" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"對比失敗:{ex.Message}"); + } + } + + #endregion + + #region 報表管理 API + + /// + /// 取得自訂報表清單 + /// GET api/pivot/custom_reports + /// + /// 頁碼 + /// 每頁筆數 + /// + [HttpGet] + [Route("api/pivot/custom_reports")] + public IHttpActionResult GetCustomReports(int page = 1, int pageSize = 10) + { + try + { + // TODO: 實作自訂報表查詢邏輯 + var result = new + { + success = true, + data = new + { + list = new List(), + total = 0, + page = page, + pageSize = pageSize + }, + message = "查詢成功" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"查詢失敗:{ex.Message}"); + } + } + + /// + /// 建立自訂報表 + /// POST api/pivot/custom_reports + /// + /// 報表設定 + /// + [HttpPost] + [Route("api/pivot/custom_reports")] + public IHttpActionResult PostCustomReport(dynamic request) + { + try + { + // TODO: 實作自訂報表建立邏輯 + var result = new + { + success = true, + data = new { id = 1 }, + message = "報表建立成功" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"建立失敗:{ex.Message}"); + } + } + + /// + /// 匯出報表 + /// GET api/pivot/export/{reportId} + /// + /// 報表編號 + /// 匯出格式 (excel/pdf/csv) + /// + [HttpGet] + [Route("api/pivot/export/{reportId}")] + public IHttpActionResult GetReportExport(int reportId, string format = "excel") + { + try + { + // TODO: 實作報表匯出邏輯 + var result = new + { + success = true, + data = new + { + download_url = $"/admin/pivot/downloads/report_{reportId}.{format}", + file_name = $"report_{reportId}_{DateTime.Now:yyyyMMdd}.{format}" + }, + message = "匯出成功" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"匯出失敗:{ex.Message}"); + } + } + + #endregion + + #region Excel 數據連接 API + + /// + /// Excel 數據連接專用 - 簡化格式 + /// GET api/pivot/excel_data + /// + /// 回傳格式 (json/csv) + /// 限制筆數 + /// + [HttpGet] + [Route("api/pivot/excel_data")] + [AllowAnonymous] // 允許 Excel 直接呼叫 + public IHttpActionResult GetExcelData(string format = "json", int limit = 5000) + { + try + { + // 直接查詢,對應視圖結構 + var query = from pod in _db.pro_order_detail + join po in _db.pro_order on pod.order_no equals po.order_no + join ai in _db.actItems on pod.actItem_num equals ai.num + join act in _db.activities on po.activity_num equals act.num + join f in _db.followers on po.f_num equals f.num + join aik in _db.actItem_kind on ai.kind equals aik.num + orderby po.up_time descending, act.num descending + select new + { + ActivityId = act.num, + ActivityName = act.subject, + StartDate = act.startDate_solar, + EndDate = act.endDate_solar, + FollowerCode = f.f_number, + FollowerName = f.u_name, + OrderNo = po.order_no, + OrderDate = po.up_time, + IsPatron = pod.parent_num != null ? "是" : "否", + KindName = aik.kind, + ItemName = ai.subject, + Quantity = pod.qty, + Price = pod.price, + Received = 0, + Outstanding = pod.price * pod.qty + }; + + var data = query.Take(limit).ToList(); + + if (format?.ToLower() == "csv") + { + // CSV 格式回傳 + var csv = "法會ID,法會名稱,開始日期,結束日期,信眾編號,信眾姓名,報名編號,報名日期,功德主,功德類型,功德名稱,數量,金額,已收,未收\n"; + foreach (var item in data) + { + csv += $"{item.ActivityId},{item.ActivityName},{item.StartDate:yyyy/MM/dd},{item.EndDate:yyyy/MM/dd}," + + $"{item.FollowerCode},{item.FollowerName},{item.OrderNo},{item.OrderDate:yyyy/MM/dd HH:mm}," + + $"{item.IsPatron},{item.KindName},{item.ItemName},{item.Quantity},{item.Price},{item.Received},{item.Outstanding}\n"; + } + + return Ok(csv); + } + + // JSON 格式回傳 (預設) + var result = new + { + success = true, + data = data, + count = data.Count, + generated_at = DateTime.Now, + message = "資料查詢成功" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"資料查詢失敗:{ex.Message}"); + } + } + + /// + /// Excel 數據連接專用 - 按欄位顏色分類格式 + /// GET api/pivot/excel_data_structured + /// + /// 是否包含元數據 + /// 限制筆數 + /// + [HttpGet] + [Route("api/pivot/excel_data_structured")] + [AllowAnonymous] + public IHttpActionResult GetExcelDataStructured(bool includeMetadata = false, int limit = 5000) + { + try + { + var query = from pod in _db.pro_order_detail + join po in _db.pro_order on pod.order_no equals po.order_no + join ai in _db.actItems on pod.actItem_num equals ai.num + join act in _db.activities on po.activity_num equals act.num + join f in _db.followers on po.f_num equals f.num + join aik in _db.actItem_kind on ai.kind equals aik.num + orderby po.up_time descending, act.num descending + select new + { + // 🟠 橘色欄位 - 法會基礎資料 + activity_fields = new + { + 法會ID = act.num, + 法會名稱 = act.subject, + 開始日期 = act.startDate_solar, + 結束日期 = act.endDate_solar + }, + + // 🔵 藍色欄位 - 信眾基礎資料 + follower_fields = new + { + 信眾編號 = f.f_number, + 信眾姓名 = f.u_name, + 報名編號 = po.order_no, + 報名日期 = po.up_time + }, + + // 🟢 綠色欄位 - 功德資訊欄位 + merit_info_fields = new + { + 功德主 = pod.parent_num != null ? "是" : "否", + 功德類型 = aik.kind, + 功德名稱 = ai.subject + }, + + // 🟣 紫色欄位 - 計算欄位 + calculated_fields = new + { + 數量 = pod.qty, + 金額 = pod.price, + 已收 = 0, + 未收 = pod.price * pod.qty, + 小計 = pod.price * pod.qty + }, + + // 📊 元數據 (可選) + metadata = includeMetadata ? new + { + 原始資料 = new + { + parent_num = pod.parent_num, + kind_num = aik.num, + item_num = ai.num, + activity_num = act.num, + follower_num = f.num + }, + 欄位分類說明 = new + { + 橘色欄位_法會資料 = "法會相關的基礎資料,用於法會維度分析", + 藍色欄位_信眾資料 = "信眾相關的基礎資料,用於信眾維度分析", + 綠色欄位_功德資訊 = "功德相關的資訊欄位,包含功德主、功德類型和名稱", + 紫色欄位_計算欄位 = "需要數學計算或統計的欄位" + } + } : null + }; + + var data = query.Take(limit).ToList(); + + var result = new + { + success = true, + data = new + { + records = data, + field_categories = new + { + 橘色_法會基礎資料 = new[] { "法會ID", "法會名稱", "開始日期", "結束日期" }, + 藍色_信眾基礎資料 = new[] { "信眾編號", "信眾姓名", "報名編號", "報名日期" }, + 綠色_功德資訊欄位 = new[] { "功德主", "功德類型", "功德名稱" }, + 紫色_計算欄位 = new[] { "數量", "金額", "已收", "未收" } + }, + usage_notes = new + { + 橘色欄位說明 = "法會相關基礎資料,適合用於法會維度的篩選和分組", + 藍色欄位說明 = "信眾相關基礎資料,適合用於信眾維度的篩選和分組", + 綠色欄位說明 = "功德資訊欄位,包含功德主判斷、功德類型和名稱,適合用於功德維度的篩選和分組", + 紫色欄位說明 = "計算統計欄位,適合用於樞紐分析的值區域進行加總", + Excel樞紐分析建議 = new + { + 篩選器 = "橘色(法會名稱) + 藍色(報名日期) + 綠色(功德類型)", + 列標籤 = "藍色(信眾姓名) + 綠色(功德主)", + 欄標籤 = "橘色(法會名稱) 或時間維度", + 值區域 = "紫色(金額加總、數量計數)" + } + } + }, + count = data.Count, + generated_at = DateTime.Now, + message = "結構化資料查詢成功" + }; + + return Ok(result); + } + catch (Exception ex) + { + return BadRequest($"查詢失敗:{ex.Message}"); + } + } + + #endregion + + #region 工具方法 + + /// + /// 釋放資源 + /// + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _db?.Dispose(); + } + base.Dispose(disposing); + } + + #endregion +} \ No newline at end of file diff --git a/web/admin/pivot/README-pivot-01.md b/web/admin/pivot/README-pivot-01.md new file mode 100644 index 0000000..005f749 --- /dev/null +++ b/web/admin/pivot/README-pivot-01.md @@ -0,0 +1,1401 @@ +# 法會報名統計 + +## 相關檢視VIEW + +### 法會統計 +```SQL +CREATE VIEW [dbo].[法會統計] +AS +SELECT TOP (100) PERCENT dbo.activity.num, dbo.activity.category_kind, dbo.activity_category_kind.kind AS 活動主類型, dbo.activity.kind, + dbo.activity_kind.kind AS 活動詳細分類, dbo.activity.subject AS 活動名稱, dbo.activity.startDate_solar AS 開始日期, + dbo.activity.endDate_solar AS 結束日期, dbo.activity.dueDate AS 報名截止日, SUM(dbo.pro_order_detail.qty) AS 功德數量, + SUM(dbo.pro_order_detail.price) AS 合計 +FROM dbo.pro_order_detail LEFT OUTER JOIN + dbo.pro_order ON dbo.pro_order_detail.order_no = dbo.pro_order.order_no RIGHT OUTER JOIN + dbo.activity ON dbo.pro_order.activity_num = dbo.activity.num LEFT OUTER JOIN + dbo.activity_kind ON dbo.activity.kind = dbo.activity_kind.num LEFT OUTER JOIN + dbo.activity_category_kind ON dbo.activity.category_kind = dbo.activity_category_kind.num +GROUP BY dbo.activity.num, dbo.activity.category_kind, dbo.activity_category_kind.kind, dbo.activity.kind, dbo.activity_kind.kind, dbo.activity.subject, + dbo.activity.startDate_solar, dbo.activity.endDate_solar, dbo.activity.dueDate +GO +``` + +### 報名明細查詢 +```SQL +CREATE VIEW [dbo].[報名明細查詢] +AS +SELECT dbo.activity.num AS 法會ID, dbo.activity.subject AS 法會名稱, dbo.activity.startDate_solar AS 開始日期, dbo.activity.endDate_solar AS 結束日期, + dbo.followers.f_number AS 信眾編號, dbo.followers.u_name AS 信眾姓名, dbo.pro_order.order_no AS 報名編號, dbo.pro_order.up_time AS 報名日期, + CASE WHEN parent_num IS NOT NULL THEN '是' ELSE '否' END AS 功德主, dbo.actItem_kind.kind AS 功德類型, dbo.actItem.subject AS 功德名稱, + dbo.pro_order_detail.qty AS 數量, dbo.pro_order_detail.price AS 金額, 0 AS 已收, dbo.pro_order_detail.price * dbo.pro_order_detail.qty - 0 AS 未收 +FROM dbo.pro_order_detail INNER JOIN + dbo.pro_order ON dbo.pro_order_detail.order_no = dbo.pro_order.order_no INNER JOIN + dbo.actItem ON dbo.pro_order_detail.actItem_num = dbo.actItem.num INNER JOIN + dbo.activity ON dbo.pro_order.activity_num = dbo.activity.num INNER JOIN + dbo.followers ON dbo.pro_order.f_num = dbo.followers.num INNER JOIN + dbo.actItem_kind ON dbo.actItem.kind = dbo.actItem_kind.num +GO +``` +#### 欄位分類 +``` +// 🟠 橘色欄位 - 法會基礎資料 (法會相關的直接來源) +法會ID = act.num, +法會名稱 = act.subject, +開始日期 = act.startDate_solar, +結束日期 = act.endDate_solar, + +// 🔵 藍色欄位 - 信眾基礎資料 (信眾相關的直接來源) +信眾編號 = f.f_number, +信眾姓名 = f.u_name, +報名編號 = po.order_no, +報名日期 = po.up_time, + +// 🟢綠色欄位 - 功德資訊欄位 (功德相關資訊) +功德主 = pod.parent_num != null ? "是" : "否", +功德類型 = aik.kind, +功德名稱 = ai.subject, + +// 🟣 紫色欄位 - 計算欄位 (需要計算) +數量 = pod.qty, +金額 = pod.price, +已收 = 0, // 業務邏輯:固定為 0 +未收 = pod.price * pod.qty - 0, // 計算未收金額 +``` + +## API設計 +- pivot01Controller +- 查詢: 法會統計, 條件參數: 年份 +- 查詢: 報名明細查詢, 條件參數: 法會ID +- 因為: 希望能隨時加上新的檢視, 不需要動到EF-MODEL +- 所以: 直接用SQL SELECT指令查詢, VIEW名稱也以字串命名 +- 各檢視的查詢, 都用很固定的模式. +- 例: SELECT * FROM {VIEW名稱} WHERE {條件欄位} = {條件值(參數傳入)} { AND/OR 其他固定條件} ORDER BY {欄位, 欄位, ...} +- 傳回欄位: 都以中文名稱為主, 故不用定義對照表 +- 其它統計, 都以VUE/JS, 在瀏覽器端操作 + + +## 頁面設計需求 +- 檔名: pivot/pivot01.aspx + - .cs只是個空架子, 參考: D:\dev\ez\17168erp\git_17888\web\admin\pivot\query.aspx.cs +- 頁籤式操作介面 +- Tab1 : 法會選擇頁面 + - 相關檢視: 法會統計 + - 一開始只有Tab1, 其它隱藏 + - 年份選擇, 預設今年, 切換年份時重新查詢 + - 表格顯示資料條件: + - 當年度(日期範圍涵蓋), 及無日期範圍(NULL) + - 排序: 日期 + - 表格按鈕: "選擇", 點選後, 以該場法會活動, 為查詢條件 + - 查詢: 報名明細查詢, where: 指定法會的所有資料 + - 按"選擇"後, 才出現Tab2~5 +- Tab2 : 牌位分析 + - PIVOT TABLE, 詳見說明 +- Tab3 : 信眾報名分析 + - PIVOT TABLE, 詳見說明 +- Tab4 : 功德主查詢分析 + - PIVOT TABLE, 詳見說明 +- Tab5 : 報名明細資料 + - 相關檢視: 報名明細查詢 + - 以表格呈現資料 + - Tab1所選擇法會的所有明細資料 + +### Tab1 : 法會選擇頁面 + +- 查詢列: + - 下拉:年份選擇, + - 今年起前後5年, 預設今年 + - 可自行輸入, 也可輸入前後5年以外的年份 + - "查看"按鈕, + - 按下即查詢所選年份的所有活動 + - 更新下方表格 +- 法會表格: + - 頁面載入時, 依下拉預設查詢當年度所有法會(包含) + - 直接列出所有欄位 +### Tab2 : 牌位分析 +- 參考的EXCEL樞紐分析表 + - 篩選: 法會名稱(不用處理, 因為我們的查詢, 已經只限一場法會了) + - 列: 功德類型, 功德名稱 + - 欄: 無 + - 值: 數量:加總, 金額加總 +- UI呈現: + - 以vuetify Table呈現樞紐分析表 + - 用JS把該法會明細資料變成PIVOT TABLE的型式 + - 可以2層收合表格列(功德類型, 功德名稱) + - 分別顯示: 該類型, 或該功德項目名稱的: 加總數量, 加總金額 + - 若無法2層, 就直接呈現4個欄位: 功德類型, 功德名稱, 數量, 金額 + - 上下層, 樣式要有明顯區隔 + - 最後一列, 要有全部總計, 若vuetify table有footer也可以 +### Tab3 : 信眾報名分析 +- 搜尋欄位: 關鍵字: 姓名過濾 +- 也是先做PIVOT運算, 欄位如下: + - 信眾編號 + - 信眾姓名 + - 功德主(功德主=是)數量 + - 功德主(功德主=是)金額 + - 功德數量(功德主=否) + - 功德金額(功德主=否) +### Tab4 : 功德主查詢分析 +- 搜尋欄位: + - 關鍵字: 姓名過濾 + - 功德類型下拉: 全部或單一分類 + - RADIO : 切換值: 金額, 或:數量 + - CHECK : 功德主: 是/否 +- PIVOT TABLE + - 第一欄: 信眾姓名, FIXED + - 第二欄: 總計(金額或數量) + - 第三欄以後: + - 標題: 所選功德類型(或全部)的功德項目 + - 資料列: (依選取) 金額或數量 (該信眾/該功德) + +### Tab5 : 報名明細資料 +(細節待補充) +- 表格: 欄位: 報名明細查詢的所有欄位 +- 各欄位表頭顏色標示為前述分組 + +## 實作計劃 + +### Phase 1 : implement controller + +#### 目標 +創建 `pivot01Controller.cs`,提供簡單直觀的 API 端點 + +#### 設計原則 +- **VIEW 導向**: 直接使用 SQL 查詢 VIEW,不依賴 EF Model +- **動態查詢**: VIEW 名稱以字串參數傳遞,便於新增 VIEW 而不需改程式 +- **固定查詢模式**: `SELECT * FROM {VIEW名稱} WHERE {條件欄位} = {參數值} ORDER BY {排序欄位}` +- **中文欄位名**: 查詢結果保留 VIEW 的中文欄位名,前端直接使用 + +#### API 端點設計 + +##### 1. GET /api/pivot01/activity_stats +**功能**: 查詢法會統計(對應「法會統計」VIEW) + +**參數**: +- `year` (int, required): 查詢年份 +- `includeNoDate` (bool, optional, default=true): 是否包含無日期的法會 + +**查詢邏輯**: +```sql +SELECT * FROM [法會統計] +WHERE (YEAR(開始日期) = @year OR YEAR(結束日期) = @year) + OR (@includeNoDate = 1 AND (開始日期 IS NULL OR 結束日期 IS NULL)) +ORDER BY 開始日期 DESC, 結束日期 DESC +``` + +**回應格式**: +```json +{ + "success": true, + "data": [ + { + "num": 123, + "category_kind": 1, + "活動主類型": "法會", + "kind": 5, + "活動詳細分類": "法會-梁皇寶懺", + "活動名稱": "2025年梁皇寶懺", + "開始日期": "2025-03-01", + "結束日期": "2025-03-10", + "報名截止日": "2025-02-25", + "功德數量": 150, + "合計": 500000 + } + ], + "message": "查詢成功", + "rowCount": 10 +} +``` + +##### 2. GET /api/pivot01/registration_details +**功能**: 查詢報名明細(對應「報名明細查詢」VIEW) + +**參數**: +- `activityNum` (int, required): 法會編號(法會ID) +- `page` (int, optional, default=1): 頁碼 +- `pageSize` (int, optional, default=50): 每頁筆數(最大 10000) + +**查詢邏輯**: +```sql +SELECT * FROM [報名明細查詢] +WHERE 法會ID = @activityNum +ORDER BY 報名日期 DESC, 報名編號 DESC +OFFSET (@page - 1) * @pageSize ROWS +FETCH NEXT @pageSize ROWS ONLY +``` + +**回應格式**: +```json +{ + "success": true, + "data": { + "list": [ + { + "法會ID": 123, + "法會名稱": "2025年梁皇寶懺", + "開始日期": "2025-03-01", + "結束日期": "2025-03-10", + "信眾編號": "F12345", + "信眾姓名": "王大明", + "報名編號": "ORD20250101001", + "報名日期": "2025-01-15T10:30:00", + "功德主": "是", + "功德類型": "牌位", + "功德名稱": "消災大牌位", + "數量": 1, + "金額": 3000, + "已收": 0, + "未收": 3000 + } + ], + "pagination": { + "currentPage": 1, + "pageSize": 50, + "totalRows": 523, + "totalPages": 11 + } + }, + "message": "查詢成功" +} +``` + +#### 實作檔案 +``` +D:\dev\ez\17168erp\git_17888\web\App_Code\api\pivot01Controller.cs +``` + +#### 程式架構 +```csharp +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlClient; +using System.Linq; +using System.Web.Http; +using System.Configuration; + +[ezAuthorize] +public class pivot01Controller : ApiController +{ + private string _connectionString = ConfigurationManager.ConnectionStrings["ezEntities"].ConnectionString; + + // 通用 VIEW 查詢方法 + private DataTable ExecuteSqlQuery(string sql, SqlParameter[] parameters = null); + private List> DataTableToDictionary(DataTable dt); + + // API 端點 + [HttpGet] + [Route("api/pivot01/activity_stats")] + public IHttpActionResult GetActivityStats(int year, bool includeNoDate = true); + + [HttpGet] + [Route("api/pivot01/registration_details")] + public IHttpActionResult GetRegistrationDetails(int activityNum, int page = 1, int pageSize = 50); +} +``` + +#### 開發步驟 +- [x] 確認資料庫中 VIEW 已建立 +- [x] 建立 `pivot01Controller.cs` +- [x] 實作通用 SQL 查詢方法 +- [x] 實作 `GetActivityStats` 端點 +- [x] 實作 `GetRegistrationDetails` 端點 +- [ ] 使用 Postman/瀏覽器測試 API +- [ ] 調整錯誤處理和回應格式 + +--- + +### Phase 2 : implement aspx/cs, basic + +#### 目標 +建立前端頁面 `pivot-01.aspx`,實作 Tab1(法會選擇)和 Tab5(報名明細資料) + +#### 實作檔案 +``` +D:\dev\ez\17168erp\git_17888\web\admin\pivot\pivot-01.aspx +D:\dev\ez\17168erp\git_17888\web\admin\pivot\pivot-01.aspx.cs +``` + +#### 設計原則 +- **頁籤式介面**: 使用 Vuetify Tabs 元件 +- **動態顯示**: 只在選擇法會後才顯示 Tab2-56 +- **響應式設計**: 適配桌面和平板 +- **資料快取**: 選擇法會後一次載入全部明細,供所有 Tab 使用 + +#### Tab1: 法會選擇頁面 + +##### UI 結構 +``` +┌─────────────────────────────────────────────────┐ +│ 📊 法會報名統計分析 │ +├─────────────────────────────────────────────────┤ +│ Tab1: 法會選擇 │ Tab2 │ Tab3 │ Tab4 │ Tab5 │ │ +├─────────────────────────────────────────────────┤ +│ 查詢條件 │ +│ ┌──────────┐ ┌─────────┐ │ +│ │ 年份: 2025 ▼│ │ [查看] │ │ +│ └──────────┘ └─────────┘ │ +│ │ +│ 法會清單 (共 15 場) │ +│ ┌─────────────────────────────────────────────┐│ +│ │編號│法會名稱│日期│報名數│金額│動作│ ││ +│ ├─────────────────────────────────────────────┤│ +│ │123│2025梁皇...│3/1-3/10│150│50萬│[選擇]│ ││ +│ │124│2025地藏...│4/5-4/12│200│60萬│[選擇]│ ││ +│ └─────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────┘ +``` + +##### 功能規格 +1. **年份選擇器** + - 下拉選單,範圍:今年 ± 5 年 + - 預設值:當前年份(2025) + - 可手動輸入其他年份 + +2. **查看按鈕** + - 點擊後呼叫 API: `GET /api/pivot01/activity_stats?year={year}` + - 顯示 Loading 動畫 + - 更新法會清單表格 + +3. **法會清單表格** + - 欄位:編號、法會名稱、日期範圍、報名數、總金額、操作按鈕 + - 排序:日期降序 + - 可搜尋、可排序 + +4. **選擇按鈕** + - 點擊後呼叫 API: `GET /api/pivot01/registration_details?activityNum={num}&pageSize=10000` + - 儲存完整資料到 Vue data (`rawData`) + - 顯示 Tab2-6 + - 自動切換到 Tab5 + +#### Tab5: 報名明細資料 + +##### UI 結構 +``` +┌─────────────────────────────────────────────────┐ +│ Tab1 │ Tab2 │ Tab3 │ Tab4 │ Tab5: 報名明細資料 │ │ +├─────────────────────────────────────────────────┤ +│ 📌 當前法會: 2025年梁皇寶懺 (3/1-3/10) │ +│ 📊 總筆數: 523 筆 │ 總金額: NT$ 1,569,000 │ +│ │ +│ 🔍 搜尋: [____________________] [匯出Excel] │ +│ │ +│ 報名明細表格 │ +│ ┌─────────────────────────────────────────────┐│ +│ │法會│信眾│姓名│日期│類型│名稱│主│量│金額│收│ ││ +│ ├─────────────────────────────────────────────┤│ +│ │梁皇│F001│王大│1/15│牌位│消災│是│1│3000│3K│ ││ +│ │梁皇│F002│李小│1/16│供養│供僧│否│2│1000│1K│ ││ +│ └─────────────────────────────────────────────┘│ +│ 顯示 1-50 / 共 523 筆 [1][2][3]...[11] │ +└─────────────────────────────────────────────────┘ +``` + +##### 功能規格 +1. **資訊列** + - 顯示當前法會名稱和日期 + - 顯示總筆數和總金額(從 `rawData` 計算) + +2. **搜尋框** + - 可搜尋:信眾編號、信眾姓名、功德類型、功德名稱 + - 即時過濾(前端) + +3. **匯出 Excel 按鈕** + - 匯出當前過濾後的資料 + - 使用 CSV 格式(UTF-8 BOM) + - 檔名:`報名明細_{法會名稱}_{時間戳記}.csv` + +4. **報名明細表格** + - 欄位顏色標示: + - 🟠 橘色:法會相關(法會名稱、日期) + - 🔵 藍色:信眾相關(編號、姓名、報名日期) + - 🟢 綠色:功德相關(類型、名稱、功德主) + - 🟣 紫色:計算欄位(數量、金額、已收、未收) + - 可排序所有欄位 + - 分頁顯示(前端分頁) + +##### CSS 樣式 +```css +/* 欄位色彩標記 */ +.field-activity { border-left: 4px solid #ff9800; } /* 橘色 */ +.field-follower { border-left: 4px solid #2196f3; } /* 藍色 */ +.field-merit { border-left: 4px solid #4caf50; } /* 綠色 */ +.field-calculated { border-left: 4px solid #9c27b0; } /* 紫色 */ +``` + +#### Vue 資料結構 +```javascript +data() { + return { + activeTab: 0, // 當前頁籤 + + // Tab1: 法會選擇 + tab1: { + year: 2025, // 預設今年 + yearOptions: [2020, 2021, ..., 2030], + loading: false, + activities: [], // 法會清單 + selectedActivity: null + }, + + // 全域資料(所有 Tab 共用) + rawData: [], // 完整報名明細資料 + + // Tab5: 報名明細 + tab5: { + headers: [ + { text: '信眾姓名', value: '信眾姓名', class: 'field-follower' }, + { text: '報名日期', value: '報名日期', class: 'field-follower' }, + { text: '功德類型', value: '功德類型', class: 'field-merit' }, + { text: '功德名稱', value: '功德名稱', class: 'field-merit' }, + { text: '功德主', value: '功德主', class: 'field-merit' }, + { text: '數量', value: '數量', class: 'field-calculated' }, + { text: '金額', value: '金額', class: 'field-calculated' }, + { text: '已收', value: '已收', class: 'field-calculated' }, + { text: '未收', value: '未收', class: 'field-calculated' } + ], + search: '' + } + } +}, +computed: { + hasData() { return this.rawData && this.rawData.length > 0; }, + filteredData() { /* 搜尋過濾邏輯 */ }, + totalAmount() { /* 總金額計算 */ } +}, +methods: { + async loadActivities() { /* 載入法會清單 */ }, + async selectActivity(activity) { /* 選擇法會並載入明細 */ }, + exportToExcel() { /* 匯出 CSV */ } +} +``` + +#### 開發步驟 +- [x] 建立 `pivot-01.aspx.cs`(空架子,參考 query.aspx.cs) +- [x] 建立 `pivot-01.aspx` 基本框架(MasterPage、Content) +- [x] 實作 Tab1 UI(年份選擇、法會表格) +- [x] 實作 Tab1 邏輯(API 呼叫、資料綁定) +- [x] 實作 Tab5 UI(資訊列、搜尋框、明細表格) +- [x] 實作 Tab5 邏輯(過濾、排序、分頁) +- [x] 實作匯出 Excel 功能 +- [x] 調整 CSS 樣式(欄位顏色標示) +- [ ] 測試所有功能 + +#### 資料流程 +``` +使用者選擇年份 → API查詢法會列表 → 顯示法會表格 + ↓ +使用者點選法會 → API載入完整明細 → 儲存到rawData + ↓ + 切換到Tab5 → 顯示表格 → 可搜尋/排序/匯出 +``` + +--- + +### Phase 3 : Tab2 牌位分析實作計劃 + +##### 目標 +實作 Tab2 牌位分析功能,將 `rawData` 轉換為樞紐分析表格式,提供功德類型和功德名稱的分層統計 + +##### 設計規格 + +###### 資料結構分析 +- **來源資料**: `rawData`(從 Tab1 選擇法會後載入的完整報名明細) +- **分組維度**: + - 第一層:功德類型 (`功德類型` 欄位) + - 第二層:功德名稱 (`功德名稱` 欄位) +- **統計值**: + - 數量總計:sum(`數量`) + - 金額總計:sum(`金額`) + +###### Vue 資料結構設計 +```javascript +// Tab2: 牌位分析 +tab2: { + expanded: [], // 展開的功德類型 ID 陣列 + pivotData: [], // 處理後的樞紐資料 + loading: false, + headers: [ + { text: '功德項目', value: 'name', width: 300 }, + { text: '數量總計', value: 'totalQty', width: 120, align: 'end' }, + { text: '金額總計', value: 'totalAmount', width: 150, align: 'end' }, + { text: '', value: 'data-table-expand', width: 50 } // 展開按鈕欄位 + ] +} +``` + +###### 樞紐資料轉換演算法 +```javascript +computed: { + tab2PivotData() { + if (!this.rawData || this.rawData.length === 0) return []; + + // Step 1: 按功德類型分組 + const typeGroups = {}; + this.rawData.forEach(row => { + const type = row.功德類型 || '未分類'; + const name = row.功德名稱 || '未命名'; + const qty = parseInt(row.數量) || 0; + const amount = parseFloat(row.金額) || 0; + + if (!typeGroups[type]) { + typeGroups[type] = { + name: type, + isGroup: true, + totalQty: 0, + totalAmount: 0, + children: {} + }; + } + + // Step 2: 按功德名稱分組 + if (!typeGroups[type].children[name]) { + typeGroups[type].children[name] = { + name: name, + isGroup: false, + totalQty: 0, + totalAmount: 0, + parentType: type + }; + } + + // Step 3: 累計統計 + typeGroups[type].children[name].totalQty += qty; + typeGroups[type].children[name].totalAmount += amount; + typeGroups[type].totalQty += qty; + typeGroups[type].totalAmount += amount; + }); + + // Step 4: 轉換為表格資料 + const result = []; + Object.values(typeGroups).forEach(group => { + result.push(group); + Object.values(group.children).forEach(child => { + result.push(child); + }); + }); + + return result; + } +} +``` + +###### UI 實作規格 + +**表格結構**: +```html + + + + + + + + + + + + + +``` + +**樣式設計**: +```css +/* Tab2 樞紐分析表樣式 */ +.pivot-group-row { + background-color: #f5f5f5 !important; + border-left: 4px solid #ff9800; +} + +.pivot-detail-row { + border-left: 4px solid #4caf50; +} + +.pivot-total-row { + background-color: #e3f2fd !important; + border-left: 4px solid #2196f3; + font-weight: bold !important; +} +``` + +##### 實作步驟 + +1. **需求分析與設計** (1 小時) + - 確認樞紐分析表的確切需求 + - 設計資料轉換演算法 + - 規劃 UI 元件結構 + +2. **資料處理邏輯** (2 小時) + - 實作 `tab2PivotData` computed 屬性 + - 實作資料分組和統計演算法 + - 添加總計計算邏輯 + +3. **UI 元件實作** (2 小時) + - 在 `pivot-01.aspx` 中添加 Tab2 內容 + - 實作樞紐分析表格元件 + - 添加展開/收合功能 + +4. **樣式與視覺化** (1 小時) + - 設計分層樣式(群組 vs 明細) + - 添加圖示區分功德類型和名稱 + - 實作總計列樣式 + +5. **測試與優化** (1 小時) + - 測試不同資料量的效能 + - 驗證統計數字正確性 + - 調整使用者體驗 + +##### 預期成果 + +**功能完成標準**: +- ✅ 可正確按功德類型和功德名稱分組顯示 +- ✅ 統計數字正確(數量總計、金額總計) +- ✅ 支援展開/收合功德類型 +- ✅ 有明顯的視覺層級區分 +- ✅ 表格底部顯示全部總計 +- ✅ 響應式設計適配不同螢幕 + +**資料範例展示**: +``` +┌─────────────────────────────────────────────────┐ +│ 📊 牌位分析 │ +├─────────────────────────────────────────────────┤ +│ ▼ 🏷️ 牌位 │ 150 │ NT$ 450,000 │ │ +│ ● 消災大牌位 │ 80 │ NT$ 240,000 │ │ +│ ● 祈福小牌位 │ 70 │ NT$ 210,000 │ │ +│ ▼ 🏷️ 供養 │ 95 │ NT$ 285,000 │ │ +│ ● 供僧 │ 45 │ NT$ 135,000 │ │ +│ ● 供燈 │ 50 │ NT$ 150,000 │ │ +├─────────────────────────────────────────────────┤ +│ 總計 │ 245 │ NT$ 735,000 │ │ +└─────────────────────────────────────────────────┘ +``` + +### Phase 4 : Tab3 信眾報名分析實作計劃 + +##### 目標 +實作 Tab3 信眾報名分析功能,統計每位信眾的報名情況,區分功德主和一般信眾的數量與金額 + +##### 設計規格 + +###### 需求分析 +根據文件說明: +- **搜尋欄位**: 關鍵字(姓名過濾) +- **PIVOT 運算欄位**: + - 信眾編號 + - 信眾姓名 + - 功德主數量(功德主=是) + - 功德主金額(功德主=是) + - 功德數量(功德主=否) + - 功德金額(功德主=否) + +###### 資料結構分析 +- **來源資料**: `rawData` +- **分組維度**: 信眾(`信眾編號` + `信眾姓名`) +- **統計維度**: + - 按 `功德主` 欄位分類("是" / "否") + - 計算數量和金額 + +###### Vue 資料結構設計 +```javascript +// Tab3: 信眾報名分析 +tab3: { + search: '', // 搜尋關鍵字 + headers: [ + { text: '信眾編號', value: '信眾編號', width: 120, class: 'field-follower' }, + { text: '信眾姓名', value: '信眾姓名', width: 120, class: 'field-follower' }, + { text: '功德主數量', value: '功德主數量', width: 120, align: 'end', class: 'field-calculated' }, + { text: '功德主金額', value: '功德主金額', width: 150, align: 'end', class: 'field-calculated' }, + { text: '功德數量', value: '功德數量', width: 120, align: 'end', class: 'field-calculated' }, + { text: '功德金額', value: '功德金額', width: 150, align: 'end', class: 'field-calculated' } + ] +} +``` + +###### 樞紐資料轉換演算法 +```javascript +computed: { + tab3PivotData() { + if (!this.rawData || this.rawData.length === 0) return []; + + // Step 1: 按信眾分組 + const followerGroups = {}; + this.rawData.forEach(row => { + const fNumber = row.信眾編號 || ''; + const fName = row.信眾姓名 || ''; + const key = fNumber + '_' + fName; + const qty = parseInt(row.數量) || 0; + const amount = parseFloat(row.金額) || 0; + const isMaster = row.功德主 === '是'; + + if (!followerGroups[key]) { + followerGroups[key] = { + 信眾編號: fNumber, + 信眾姓名: fName, + 功德主數量: 0, + 功德主金額: 0, + 功德數量: 0, + 功德金額: 0 + }; + } + + // Step 2: 累計統計 + if (isMaster) { + followerGroups[key].功德主數量 += qty; + followerGroups[key].功德主金額 += amount; + } else { + followerGroups[key].功德數量 += qty; + followerGroups[key].功德金額 += amount; + } + }); + + // Step 3: 轉換為陣列並排序 + return Object.values(followerGroups) + .sort((a, b) => { + // 依功德主金額降序,再依姓名升序 + const amountDiff = (b.功德主金額 + b.功德金額) - (a.功德主金額 + a.功德金額); + return amountDiff !== 0 ? amountDiff : a.信眾姓名.localeCompare(b.信眾姓名); + }); + }, + + // 搜尋過濾 + tab3FilteredData() { + if (!this.tab3.search) return this.tab3PivotData; + + const keyword = this.tab3.search.toLowerCase(); + return this.tab3PivotData.filter(row => { + return row.信眾編號.toLowerCase().includes(keyword) || + row.信眾姓名.toLowerCase().includes(keyword); + }); + }, + + // 總計統計 + tab3Summary() { + const data = this.tab3FilteredData; + return { + 總人數: data.length, + 功德主數量合計: data.reduce((sum, row) => sum + row.功德主數量, 0), + 功德主金額合計: data.reduce((sum, row) => sum + row.功德主金額, 0), + 功德數量合計: data.reduce((sum, row) => sum + row.功德數量, 0), + 功德金額合計: data.reduce((sum, row) => sum + row.功德金額, 0) + }; + } +} +``` + +###### UI 實作規格 + +**表格結構**: +```html + + + mdi-account-multiple + 信眾報名分析 + + + {{ tab1.selectedActivity.法會名稱 }} + + + + + + + + + + + + mdi-download + 匯出 Excel + + + + + +
+ + +
總人數
+
{{ tab3Summary.總人數 }} 人
+
+ +
功德主數量
+
{{ tab3Summary.功德主數量合計.toLocaleString() }}
+
+ +
功德主金額
+
{{ formatCurrency(tab3Summary.功德主金額合計) }}
+
+ +
總金額
+
+ {{ formatCurrency(tab3Summary.功德主金額合計 + tab3Summary.功德金額合計) }} +
+
+
+
+ + + + + + + + + + + +
+
+``` + +**樣式設計**: +```css +/* Tab3 信眾分析樣式 */ +.info-bar { + background-color: #f5f5f5; + border-radius: 8px; + padding: 16px; +} + +.info-bar .text-h6 { + color: #1976d2; + margin-top: 4px; +} + +/* 功德主高亮 */ +.follower-master-highlight { + background-color: #fff3e0 !important; +} +``` + +##### 實作步驟 + +1. **資料處理邏輯** (2 小時) + - 實作 `tab3PivotData` computed 屬性 + - 實作信眾分組演算法 + - 區分功德主和一般功德統計 + - 實作搜尋過濾邏輯 + +2. **UI 元件實作** (2 小時) + - 在 `pivot-01.aspx` 中添加 Tab3 內容 + - 實作搜尋框和過濾功能 + - 實作統計資訊列 + - 實作信眾統計表格 + +3. **匯出功能** (1 小時) + - 實作 `exportTab3ToCSV()` 方法 + - 處理 CSV 格式和編碼 + +4. **測試與優化** (1 小時) + - 測試不同搜尋條件 + - 驗證統計數字正確性 + - 測試匯出功能 + +##### 預期成果 + +**功能完成標準**: +- ✅ 可正確按信眾分組並統計 +- ✅ 區分功德主和一般功德的數量/金額 +- ✅ 搜尋功能正常運作 +- ✅ 統計資訊列顯示正確 +- ✅ 支援匯出 CSV +- ✅ 資料排序合理(按金額降序) + +**資料範例展示**: +``` +┌────────────────────────────────────────────────────────────────┐ +│ 👥 信眾報名分析 114年法會報名統計分析 │ +├────────────────────────────────────────────────────────────────┤ +│ 🔍 [搜尋信眾... ] [匯出Excel] │ +│ │ +│ 📊 總人數: 85 人 │ 功德主數量: 150 │ 總金額: NT$ 1,569,000 │ +├────────────────────────────────────────────────────────────────┤ +│ 編號 │ 姓名 │ 功德主數量 │ 功德主金額 │ 功德數量 │ 功德金額 │ +│ F001 │ 王大明 │ 5 │ NT$ 15,000 │ 3 │ NT$ 9,000│ +│ F002 │ 李小華 │ 3 │ NT$ 9,000 │ 5 │ NT$ 5,000│ +│ F003 │ 張美麗 │ 0 │ NT$ 0 │ 2 │ NT$ 6,000│ +└────────────────────────────────────────────────────────────────┘ +``` + +--- + +### Phase 5 : Tab4 功德主查詢分析實作計劃 + +##### 目標 +實作 Tab4 功德主查詢分析功能,提供動態樞紐分析表,可按功德類型和功德項目查看每位信眾的數量或金額 + +##### 設計規格 + +###### 需求分析 +根據文件說明: +- **搜尋欄位**: + - 關鍵字:姓名過濾 + - 功德類型下拉:全部或單一分類 + - RADIO:切換值:金額 或 數量 + - CHECK:功德主:是/否 +- **PIVOT TABLE**: + - 第一欄:信眾姓名(FIXED) + - 第二欄:總計(金額或數量) + - 第三欄以後:所選功德類型(或全部)的功德項目(橫向展開) + - 資料列:該信眾在該功德項目的金額或數量 + +###### 資料結構分析 +- **來源資料**: `rawData` +- **動態維度**: + - 列:信眾姓名 + - 欄:功德項目(依選擇的功德類型過濾) + - 值:數量 或 金額(可切換) +- **過濾條件**: + - 姓名關鍵字 + - 功德類型 + - 功德主狀態 + +###### Vue 資料結構設計 +```javascript +// Tab4: 功德主查詢分析 +tab4: { + search: '', // 姓名關鍵字 + meritType: '全部', // 功德類型選擇 + meritTypeOptions: [], // 功德類型下拉選項 + valueType: 'amount', // 'amount' 或 'quantity' + showMasterOnly: false, // 只顯示功德主 + loading: false +} +``` + +###### 樞紐資料轉換演算法 +```javascript +computed: { + // 功德類型選項(從 rawData 動態產生) + tab4MeritTypeOptions() { + if (!this.rawData || this.rawData.length === 0) return ['全部']; + + const types = new Set(this.rawData.map(row => row.功德類型).filter(Boolean)); + return ['全部', ...Array.from(types).sort()]; + }, + + // 過濾後的原始資料 + tab4FilteredRawData() { + let data = this.rawData; + + // 功德主過濾 + if (this.tab4.showMasterOnly) { + data = data.filter(row => row.功德主 === '是'); + } + + // 功德類型過濾 + if (this.tab4.meritType !== '全部') { + data = data.filter(row => row.功德類型 === this.tab4.meritType); + } + + // 姓名過濾 + if (this.tab4.search) { + const keyword = this.tab4.search.toLowerCase(); + data = data.filter(row => + row.信眾姓名.toLowerCase().includes(keyword) + ); + } + + return data; + }, + + // 取得功德項目清單(橫向欄位) + tab4MeritItems() { + const items = new Set( + this.tab4FilteredRawData.map(row => row.功德名稱).filter(Boolean) + ); + return Array.from(items).sort(); + }, + + // 動態表頭 + tab4Headers() { + const headers = [ + { text: '信眾姓名', value: '信眾姓名', width: 150, fixed: true, class: 'field-follower' } + ]; + + // 總計欄 + const valueLabel = this.tab4.valueType === 'amount' ? '總金額' : '總數量'; + headers.push({ + text: valueLabel, + value: '總計', + width: 120, + align: 'end', + class: 'field-calculated font-weight-bold' + }); + + // 功德項目欄(動態) + this.tab4MeritItems.forEach(item => { + headers.push({ + text: item, + value: item, + width: 100, + align: 'end', + class: 'field-merit' + }); + }); + + return headers; + }, + + // 樞紐分析資料 + tab4PivotData() { + if (!this.tab4FilteredRawData || this.tab4FilteredRawData.length === 0) return []; + + // Step 1: 按信眾和功德項目分組 + const followerMeritMap = {}; + this.tab4FilteredRawData.forEach(row => { + const fName = row.信眾姓名; + const mName = row.功德名稱; + const qty = parseInt(row.數量) || 0; + const amount = parseFloat(row.金額) || 0; + + if (!followerMeritMap[fName]) { + followerMeritMap[fName] = { 信眾姓名: fName }; + } + + if (!followerMeritMap[fName][mName]) { + followerMeritMap[fName][mName] = { qty: 0, amount: 0 }; + } + + followerMeritMap[fName][mName].qty += qty; + followerMeritMap[fName][mName].amount += amount; + }); + + // Step 2: 轉換為表格格式 + const result = Object.values(followerMeritMap).map(follower => { + const row = { 信眾姓名: follower.信眾姓名 }; + let total = 0; + + // 計算每個功德項目的值 + this.tab4MeritItems.forEach(item => { + if (follower[item]) { + const value = this.tab4.valueType === 'amount' + ? follower[item].amount + : follower[item].qty; + row[item] = value; + total += value; + } else { + row[item] = 0; + } + }); + + row.總計 = total; + return row; + }); + + // Step 3: 依總計降序排列 + return result.sort((a, b) => b.總計 - a.總計); + } +} +``` + +###### UI 實作規格 + +**表格結構**: +```html + + + mdi-table-pivot + 功德主查詢分析 + + + {{ tab1.selectedActivity.法會名稱 }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + 信眾數: {{ tab4PivotData.length }} 人 + + + 功德項目: {{ tab4MeritItems.length }} 項 + + + + mdi-download + 匯出 Excel + + + +
+ + + + + + + + + + + + +
+
+``` + +**樣式設計**: +```css +/* Tab4 橫向樞紐表樣式 */ +.pivot-table-horizontal { + overflow-x: auto; +} + +.pivot-table-horizontal .v-data-table__wrapper { + overflow-x: auto; + max-height: 600px; +} + +/* 固定第一欄 */ +.pivot-table-horizontal th:first-child, +.pivot-table-horizontal td:first-child { + position: sticky; + left: 0; + z-index: 2; + background-color: #fff; + box-shadow: 2px 0 4px rgba(0,0,0,0.1); +} + +/* 總計欄樣式 */ +.pivot-table-horizontal th:nth-child(2), +.pivot-table-horizontal td:nth-child(2) { + background-color: #f5f5f5; + font-weight: bold; +} + +/* 零值淡化 */ +.grey--text { + color: #9e9e9e !important; +} +``` + +##### 實作步驟 + +1. **資料處理邏輯** (3 小時) + - 實作過濾條件邏輯 + - 實作動態表頭生成 + - 實作橫向樞紐分析演算法 + - 處理金額/數量切換 + +2. **UI 元件實作** (3 小時) + - 實作搜尋與過濾區塊 + - 實作動態表頭的 v-data-table + - 實作固定第一欄(信眾姓名) + - 處理橫向滾動 + +3. **互動功能** (1 小時) + - 實作即時過濾更新 + - 實作值類型切換 + - 實作功德主過濾 + +4. **匯出功能** (1 小時) + - 實作 `exportTab4ToCSV()` 方法 + - 處理動態欄位的 CSV 匯出 + +5. **測試與優化** (1 小時) + - 測試各種過濾組合 + - 測試橫向滾動和固定欄 + - 驗證統計數字正確性 + - 優化大量欄位時的效能 + +##### 預期成果 + +**功能完成標準**: +- ✅ 動態產生功德項目欄位 +- ✅ 支援姓名、功德類型、功德主過濾 +- ✅ 可切換金額/數量顯示 +- ✅ 第一欄(姓名)固定,橫向滾動 +- ✅ 總計欄醒目顯示 +- ✅ 支援匯出 CSV(包含動態欄位) +- ✅ 零值適當淡化顯示 + +**資料範例展示**: +``` +┌───────────────────────────────────────────────────────────────┐ +│ 📊 功德主查詢分析 114年法會報名統計分析 │ +├───────────────────────────────────────────────────────────────┤ +│ 🔍[姓名] [功德類型:全部▼] ⚪金額 ⚪數量 ☑只顯示功德主 │ +│ │ +│ 信眾數: 35 人 │ 功德項目: 8 項 │ [匯出Excel] │ +├───────────────────────────────────────────────────────────────┤ +│ 姓名 │ 總金額 │ 消災大牌位│ 祈福小牌位│ 供僧 │ 供燈 │...│ +│ 王大明 │ 50,000 │ 15,000 │ 10,000 │ 15K │ 10K │... │ +│ 李小華 │ 35,000 │ 20,000 │ 5,000 │ 5K │ 5K │... │ +│ 張美麗 │ 28,000 │ 0 │ 18,000 │ 5K │ 5K │... │ +└───────────────────────────────────────────────────────────────┘ + ↑固定欄 ↑ 橫向滾動區域 → +``` + +--- + +## 測試計劃 + +### Phase 1 測試 +1. **API 端點測試**(使用 Postman) + - 測試 `/api/pivot01/activity_stats?year=2025` + - 測試不同年份參數 + - 測試 `includeNoDate` 參數 + - 測試 `/api/pivot01/registration_details?activityNum=123` + - 測試分頁參數 + - 驗證回應格式和資料正確性 + +### Phase 2 測試 +1. **UI 功能測試** + - 測試年份選擇器 + - 測試法會清單載入 + - 測試選擇法會功能 + - 測試 Tab 切換(Tab2-6 在選擇前應隱藏) + +2. **資料顯示測試** + - 驗證 Tab5 資料正確顯示 + - 測試搜尋功能(信眾、功德類型等) + - 測試排序功能 + - 測試分頁功能 + +3. **匯出測試** + - 測試匯出 CSV 檔案 + - 驗證檔案格式正確(UTF-8 BOM) + - 測試特殊字元處理 + - 測試中文編碼 + +4. **瀏覽器相容性** + - Chrome + - Edge + - Firefox + +--- + +## 預期成果 + +### Phase 1 完成後 +✅ `pivot01Controller.cs` 正常運作 +✅ API 端點可正確查詢資料 +✅ 回應格式符合前端需求 +✅ 錯誤處理完善 + +### Phase 2 完成後 +✅ `pivot-01.aspx` 頁面正常運作 +✅ Tab1 可正常查詢和選擇法會 +✅ Tab5 可正常顯示報名明細 +✅ 搜尋、排序、分頁功能正常 +✅ 匯出 Excel 功能正常 +✅ 使用者體驗良好 \ No newline at end of file diff --git a/web/admin/pivot/README.md b/web/admin/pivot/README.md new file mode 100644 index 0000000..d134cdf --- /dev/null +++ b/web/admin/pivot/README.md @@ -0,0 +1,768 @@ +# Pivot Module 執行計劃 + +## 概述 +建立多頁籤數據透視查詢模組,參考 transfer 模組的設計架構,使用相同的技術棧與 UI/UX 模式,實現法會報名資料的多維度分析與展示。 + +--- + +## 技術架構 + +### 前端技術 +- **框架**: Vue.js 2.x + Vuetify 2.x(與 transfer 模組一致) +- **表格元件**: v-data-table(Vuetify 內建表格元件) +- **頁籤元件**: v-tabs / v-tab / v-tab-item(Vuetify 頁籤) +- **UI 框架**: Bootstrap 5 + Bootstrap Icons(MasterPage) +- **樣式**: 與 transfer 模組保持一致的視覺風格 + +### 後端 API +- **框架**: ASP.NET Web API(C#) +- **ORM**: Entity Framework + LINQ +- **控制器**: `App_Code/api/pivotController.cs`(已建立) +- **資料庫視圖**: `報名明細查詢`(SQL View) + +### 資料流架構(重要)★ +``` +查詢流程: +┌─────────────┐ +│ Tab 1 │ → 選擇法會 → API 查詢一次 → +│ 查詢條件 │ (完整資料集) +└─────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Vue Data (存於前端 this.rawData) │ +│ - 完整報名明細 │ +│ - 一次性載入,不重複查詢 │ +└─────────────────────────────────────────┘ + ↓ +┌──────┬──────┬──────┬──────┬──────┐ +│Tab 2 │Tab 3 │Tab 4 │Tab 5 │Tab 6 │ +│明細 │信眾 │收入 │趨勢 │對比 │ +│ │ │ │ │ │ +│ 純前端計算 / 過濾 / 分組 / 統計 │ +│ 使用 computed / methods / filters │ +└──────────────────────────────────────┘ +``` + +**優點**: +1. ✅ **效能優化**: API 只查詢一次,減少伺服器負載 +2. ✅ **即時響應**: 切換頁籤無延遲,使用者體驗佳 +3. ✅ **離線分析**: 資料載入後可離線操作(過濾、排序、統計) +4. ✅ **減少流量**: 不重複傳輸相同資料 +5. ✅ **一致性**: 所有頁籤基於同一份資料,確保一致性 + +**技術實現**: +- `this.rawData`: 原始完整資料(Tab 1 查詢後存入) +- `computed properties`: 各頁籤的資料來源(動態計算) +- `methods`: 過濾、分組、統計邏輯 +- `watch`: 監聽過濾條件變化 + +### 元件規格 +1. **日期選擇器**: `v-date-picker` 或 `` +2. **下拉選單**: `v-select`(年份、月份、法會選擇) +3. **資料表格**: `v-data-table`(分頁、排序、過濾) +4. **頁籤切換**: `v-tabs`(Tab 1~N) +5. **按鈕群組**: `v-btn`(查詢、匯出、重設) +6. **載入狀態**: `:loading="loading"` +7. **視覺標記**: Bootstrap Badge(橙、藍、綠、紫色標籤) + +--- + +## 頁籤設計 + +### Tab 1: 查詢條件設定 +**功能目標**: 提供查詢條件,篩選法會並選擇目標法會 + +#### UI 布局 +``` ++--------------------------------------------------------------+ +| [查詢條件] | +|--------------------------------------------------------------| +| 時間範圍: [年份 ▼] [月份 ▼] [查詢法會] | +|--------------------------------------------------------------| +| 法會清單: | +| +----------------------------------------------------------+ | +| | 序號 | 法會名稱 | 開始日期 | 結束日期 | 報名人數 | 操作 | | +| +----------------------------------------------------------+ | +| | 1 | 2025春季法會 | 2025-03-01 | 2025-03-15 | 120 | [選擇] | | +| | 2 | 2025夏季法會 | 2025-06-01 | 2025-06-20 | 85 | [選擇] | | +| +----------------------------------------------------------+ | ++--------------------------------------------------------------+ +``` + +#### 查詢流程 +1. 選擇年份(2020~2025)或月份(1~12) +2. 點擊「查詢法會」按鈕 +3. API 回傳該期間的法會清單(`api/pivot/activity_stats`) +4. 表格呈現法會清單(法會名稱、日期、統計) +5. 點擊「選擇」按鈕,載入該法會的詳細資料 +6. 自動切換到 Tab 2(詳細資料頁籤) + +#### API 整合 +- **端點**: `GET api/pivot/activity_stats?startDate={start}&endDate={end}` +- **回傳**: 法會清單(含報名統計) + +#### 資料查詢策略 +```javascript +// 選擇法會後,一次性載入完整資料 +selectActivity(item) { + this.loading = true; + this.selectedActivity = item; + + // 一次性查詢完整報名明細(不分頁) + axios.get('/api/pivot/registration_details', { + params: { + activityNum: item.法會ID, + pageSize: 9999 // 取得全部資料 + } + }).then(response => { + // 存入原始資料(供所有頁籤使用) + this.rawData = response.data.data.list; + + // 自動切換到 Tab 2 + this.activeTab = 1; + this.loading = false; + }); +} +``` + +#### 元件範例 +```html + + +查詢法會 + + + + +``` + +--- + +### Tab 2: 報名明細資料 +**功能目標**: 完整呈現該場法會的所有報名明細 + +#### UI 布局 +``` ++--------------------------------------------------------------+ +| [報名明細] 法會: 2025春季法會 (2025-03-01 ~ 2025-03-15) | +|--------------------------------------------------------------| +| 過濾: [信眾姓名] [功德類型 ▼] [狀態 ▼] [查詢] [匯出Excel] | +|--------------------------------------------------------------| +| +----------------------------------------------------------+ | +| | 報名編號 | 報名日期 | 信眾姓名 | 功德名稱 | 數量 | 金額 | | +| +----------------------------------------------------------+ | +| | 20250301001 | 2025-03-01 | 張三 | 點燈 | 1 | 500 | | +| | 20250301002 | 2025-03-01 | 李四 | 安太歲 | 1 | 300 | | +| +----------------------------------------------------------+ | +| 第 1 頁,共 10 頁(共 200 筆) | ++--------------------------------------------------------------+ +``` + +#### 功能特性 +1. **欄位顯示**: 報名編號、報名日期、信眾姓名、功德名稱、數量、金額、已收、未收 +2. **過濾條件**: 信眾姓名(模糊搜尋)、功德類型(下拉選單)、功德主(是/否) +3. **排序**: 可依任意欄位排序(升序/降序) +4. **分頁**: 預設每頁 50 筆,可調整(10/20/50/100) +5. **匯出**: 匯出 Excel/CSV + +#### 欄位色彩標記(參考 Excel 視圖) +- **橙色(法會資料)**: 法會ID、法會名稱、開始日期、結束日期 +- **藍色(信眾資料)**: 信眾編號、信眾姓名 +- **綠色(功德資訊)**: 報名編號、報名日期、功德主、功德類型、功德名稱 +- **紫色(計算欄位)**: 數量、金額、已收、未收 + +使用 Bootstrap Badge 或背景色區隔: +```html +法會 +信眾 +功德 +計算 +``` + +#### 資料來源(前端計算) +```javascript +computed: { + // Tab 2: 報名明細(前端分頁、過濾) + filteredRegistrations() { + let data = this.rawData; + + // 過濾:信眾姓名 + if (this.filter.followerName) { + data = data.filter(x => x.信眾姓名.includes(this.filter.followerName)); + } + + // 過濾:功德類型 + if (this.filter.itemKind) { + data = data.filter(x => x.功德類型 === this.filter.itemKind); + } + + // 過濾:功德主 + if (this.filter.isParent !== null) { + data = data.filter(x => x.功德主 === (this.filter.isParent ? '是' : '否')); + } + + return data; + }, + + // 前端分頁 + paginatedRegistrations() { + const start = (this.currentPage - 1) * this.pageSize; + const end = start + this.pageSize; + return this.filteredRegistrations.slice(start, end); + } +} +``` + +#### 元件範例 +```html + + + + +``` + +--- + +### Tab 3: 信眾參與分析 +**功能目標**: 統計信眾的參與情況(參與次數、金額、最近參與) + +#### UI 布局 +``` ++--------------------------------------------------------------+ +| [信眾參與分析] | +|--------------------------------------------------------------| +| 過濾: [信眾編號] [參與次數 ≥] [總金額 ≥] [查詢] | +|--------------------------------------------------------------| +| +----------------------------------------------------------+ | +| | 信眾編號 | 姓名 | 參與次數 | 總金額 | 最近參與日期 | | +| +----------------------------------------------------------+ | +| | F001 | 張三 | 5 | 2500 | 2025-03-01 | | +| | F002 | 李四 | 3 | 1500 | 2025-02-15 | | +| +----------------------------------------------------------+ | ++--------------------------------------------------------------+ +``` + +#### 功能特性 +1. **統計欄位**: 參與次數、總金額、平均金額、最近參與日期 +2. **過濾條件**: 信眾編號、參與次數閾值、總金額閾值 +3. **排序**: 預設依參與次數降序 +4. **分頁**: 預設每頁 50 筆 + +#### 資料來源(前端計算) +```javascript +computed: { + // Tab 3: 信眾參與分析(從 rawData 計算) + followerAnalysis() { + const followerMap = {}; + + this.rawData.forEach(item => { + const fNum = item.信眾編號; + if (!followerMap[fNum]) { + followerMap[fNum] = { + 信眾編號: fNum, + 姓名: item.信眾姓名, + 參與次數: 0, + 總金額: 0, + 最近參與日期: item.報名日期 + }; + } + + followerMap[fNum].參與次數++; + followerMap[fNum].總金額 += (item.金額 * item.數量); + + // 更新最近參與日期 + if (new Date(item.報名日期) > new Date(followerMap[fNum].最近參與日期)) { + followerMap[fNum].最近參與日期 = item.報名日期; + } + }); + + // 轉換為陣列,並計算平均金額 + return Object.values(followerMap).map(f => ({ + ...f, + 平均金額: Math.round(f.總金額 / f.參與次數) + })).sort((a, b) => b.參與次數 - a.參與次數); // 依參與次數降序 + } +} +``` + +--- + +### Tab 4: 收入統計分析 +**功能目標**: 依時間、法會、功德類型統計收入 + +#### UI 布局 +``` ++--------------------------------------------------------------+ +| [收入統計分析] | +|--------------------------------------------------------------| +| 分組方式: ( ) 月份 ( ) 年度 (•) 法會 ( ) 功德類型 | +|--------------------------------------------------------------| +| +----------------------------------------------------------+ | +| | 分組名稱 | 報名人數 | 總金額 | 已收 | 未收 | 收款率 | | +| +----------------------------------------------------------+ | +| | 2025春季法會 | 120 | 60000 | 50000 | 10000 | 83.3% | | +| | 2025夏季法會 | 85 | 45000 | 40000 | 5000 | 88.9% | | +| +----------------------------------------------------------+ | ++--------------------------------------------------------------+ +``` + +#### 功能特性 +1. **分組方式**: 月份、年度、法會、功德類型 +2. **統計欄位**: 報名人數、總金額、已收、未收、收款率 +3. **圖表呈現**: 可加入 CanvasJS 長條圖/圓餅圖(選配) +4. **匯出**: 支援 Excel/CSV + +#### 資料來源(前端計算) +```javascript +computed: { + // Tab 4: 收入統計分析(從 rawData 計算) + incomeStats() { + const statsMap = {}; + const groupBy = this.groupBy; // 'monthly', 'yearly', 'activity', 'itemKind' + + this.rawData.forEach(item => { + let key; + switch(groupBy) { + case 'monthly': + key = item.報名日期.substring(0, 7); // YYYY-MM + break; + case 'yearly': + key = item.報名日期.substring(0, 4); // YYYY + break; + case 'activity': + key = item.法會名稱; + break; + case 'itemKind': + key = item.功德類型; + break; + } + + if (!statsMap[key]) { + statsMap[key] = { + 分組名稱: key, + 報名人數: 0, + 總金額: 0, + 已收: 0, + 未收: 0 + }; + } + + statsMap[key].報名人數++; + const amount = item.金額 * item.數量; + statsMap[key].總金額 += amount; + statsMap[key].已收 += item.已收 || 0; + statsMap[key].未收 += item.未收 || amount; + }); + + // 計算收款率 + return Object.values(statsMap).map(s => ({ + ...s, + 收款率: s.總金額 > 0 ? ((s.已收 / s.總金額) * 100).toFixed(1) + '%' : '0%' + })); + } +} +``` + +--- + +### Tab 5: 趨勢分析 +**功能目標**: 顯示時間序列趨勢(收入、參與人數、法會數量) + +#### UI 布局 +``` ++--------------------------------------------------------------+ +| [趨勢分析] | +|--------------------------------------------------------------| +| 指標: ( ) 收入 (•) 參與人數 ( ) 法會數量 | +| 時間間隔: ( ) 月份 (•) 季度 ( ) 年度 | +|--------------------------------------------------------------| +| [折線圖] | +| | +| +----------------------------------------------------------+ | +| | 時間 | 數值 | 成長率 | | +| +----------------------------------------------------------+ | +| | 2025-01 | 120 | +10% | | +| | 2025-02 | 132 | +10% | | +| +----------------------------------------------------------+ | ++--------------------------------------------------------------+ +``` + +#### 功能特性 +1. **指標選擇**: 收入、參與人數、法會數量 +2. **時間間隔**: 月份、季度、年度 +3. **成長率計算**: 較前期成長率 +4. **圖表**: CanvasJS 折線圖(選配) + +#### 資料來源(前端計算) +```javascript +computed: { + // Tab 5: 趨勢分析(從 rawData 計算) + trendAnalysis() { + const metric = this.trendMetric; // 'income', 'followers', 'count' + const interval = this.trendInterval; // 'monthly', 'quarterly', 'yearly' + const trendMap = {}; + + this.rawData.forEach(item => { + let key; + switch(interval) { + case 'monthly': + key = item.報名日期.substring(0, 7); // YYYY-MM + break; + case 'quarterly': + const month = parseInt(item.報名日期.substring(5, 7)); + const quarter = Math.ceil(month / 3); + key = `${item.報名日期.substring(0, 4)}-Q${quarter}`; + break; + case 'yearly': + key = item.報名日期.substring(0, 4); // YYYY + break; + } + + if (!trendMap[key]) { + trendMap[key] = { 時間: key, 收入: 0, 人數: 0, 次數: 0 }; + } + + trendMap[key].收入 += (item.金額 * item.數量); + trendMap[key].人數++; // 簡化計算,實際可用 Set 去重 + trendMap[key].次數++; + }); + + // 轉換為陣列並排序 + const result = Object.values(trendMap).sort((a, b) => a.時間.localeCompare(b.時間)); + + // 計算成長率 + return result.map((item, index) => { + if (index === 0) { + return { ...item, 數值: item[metric === 'income' ? '收入' : metric === 'followers' ? '人數' : '次數'], 成長率: '-' }; + } + const prev = result[index - 1]; + const currentValue = item[metric === 'income' ? '收入' : metric === 'followers' ? '人數' : '次數']; + const prevValue = prev[metric === 'income' ? '收入' : metric === 'followers' ? '人數' : '次數']; + const growthRate = prevValue > 0 ? (((currentValue - prevValue) / prevValue) * 100).toFixed(1) : '0'; + return { ...item, 數值: currentValue, 成長率: growthRate + '%' }; + }); + } +} +``` + +--- + +### Tab 6: 對比分析(選配) +**功能目標**: 不同時期、法會的對比分析 + +#### UI 布局 +``` ++--------------------------------------------------------------+ +| [對比分析] | +|--------------------------------------------------------------| +| 對比類型: (•) 法會對比 ( ) 年度對比 | +| 期間1: [2025春季法會 ▼] 期間2: [2024春季法會 ▼] | +|--------------------------------------------------------------| +| +----------------------------------------------------------+ | +| | 指標 | 期間1 | 期間2 | 差異 | 差異率 | | +| +----------------------------------------------------------+ | +| | 報名人數 | 120 | 100 | +20 | +20% | | +| | 總金額 | 60000 | 50000 | +10000 | +20% | | +| +----------------------------------------------------------+ | ++--------------------------------------------------------------+ +``` + +#### 資料來源(前端計算) +```javascript +computed: { + // Tab 6: 對比分析(從 rawData 計算) + // 注意:對比分析可能需要跨法會資料,如需要可額外查詢 + comparativeAnalysis() { + // 如果只在單一法會內對比(如月份對比),可用 rawData + // 如果需要跨法會對比,建議另外查詢或在 Tab 1 時一併載入多場法會資料 + const period1Data = this.rawData.filter(x => { + // 依 period1 條件過濾 + return this.isPeriod1(x); + }); + + const period2Data = this.rawData.filter(x => { + // 依 period2 條件過濾 + return this.isPeriod2(x); + }); + + const stats1 = this.calculateStats(period1Data); + const stats2 = this.calculateStats(period2Data); + + return [ + { 指標: '報名人數', 期間1: stats1.count, 期間2: stats2.count, 差異: stats1.count - stats2.count }, + { 指標: '總金額', 期間1: stats1.amount, 期間2: stats2.amount, 差異: stats1.amount - stats2.amount }, + // ... 其他指標 + ]; + } +} +``` + +--- + +## 視覺設計規範 + +### 色彩系統(參考 transfer) +- **主色調**: Bootstrap 5 預設配色 +- **成功/確認**: `bg-success` / `text-success` +- **警告**: `bg-warning` / `text-warning` +- **危險/錯誤**: `bg-danger` / `text-danger` +- **資訊**: `bg-info` / `text-info` +- **次要**: `bg-secondary` / `text-muted` + +### 欄位色彩標記(對應 Excel) +```html + + + + + + + +功德 + + + +``` + +### 表格樣式 +```css +/* 與 transfer 保持一致 */ +.v-data-table th { + background-color: #f5f5f5 !important; + font-weight: bold; +} + +.v-data-table tbody tr:hover { + background-color: #f0f8ff !important; +} + +/* 欄位色彩標記 */ +.field-activity { border-left: 3px solid #ffc107; } /* 橙 */ +.field-follower { border-left: 3px solid #17a2b8; } /* 藍 */ +.field-merit { border-left: 3px solid #28a745; } /* 綠 */ +.field-calculated { border-left: 3px solid #6c757d; } /* 紫 */ +``` + +--- + +## API 端點總覽 + +| 端點 | 方法 | 功能 | 對應頁籤 | 使用時機 | +|------|------|------|----------|----------| +| `/api/pivot/activity_stats` | GET | 查詢法會清單與統計 | Tab 1 | 選擇查詢條件時 | +| `/api/pivot/registration_details` | GET | 報名明細查詢(**完整資料**) | Tab 1 | **選擇法會後一次性載入** ★ | +| `/api/pivot/registration_details_export` | GET | 報名明細匯出 | Tab 2 | 匯出 Excel 時(選配) | +| `/api/pivot/excel_data_structured` | GET | Excel 數據連接 | 外部 | Power Query/Power Pivot | + +**重要說明**: +- ✅ **Tab 2~6 不呼叫 API**,全部使用前端 `computed` 計算 +- ✅ `registration_details` 查詢時使用 `pageSize=9999` 取得完整資料 +- ✅ 原 `follower_analysis`, `income_stats`, `trend_analysis`, `comparative_analysis` 端點**保留備用**,但前端優先使用 computed +- ✅ 如資料量過大(>5000 筆),可調整策略改用分頁查詢 + +--- + +## 檔案結構 + +``` +admin/pivot/ +├── index.aspx # 首頁(已完成) +├── index.aspx.cs # 首頁邏輯(已完成) +├── query.aspx # 多頁籤查詢頁面(待建立)★ +├── query.aspx.cs # 查詢頁面邏輯(待建立)★ +└── README.md # 本文件 + +App_Code/api/ +└── pivotController.cs # API 控制器(已完成) +``` + +--- + +## 實作步驟 + +### Phase 1: 建立查詢頁面骨架 +1. ✅ 建立 `query.aspx`(參考 transfer/verify.aspx) +2. ✅ 建立 `query.aspx.cs`(參考 transfer/verify.aspx.cs) +3. ✅ 引入 Vue.js + Vuetify +4. ✅ 建立頁籤結構(v-tabs) + +### Phase 2: 實作 Tab 1(查詢條件) +1. ✅ 年份/月份選擇器 +2. ✅ 查詢法會按鈕與 API 整合(`activity_stats`) +3. ✅ 法會清單表格(v-data-table) +4. ✅ 選擇法會邏輯(一次性載入完整資料) +5. ✅ `this.rawData` 資料結構設計 + +### Phase 3: 實作 Tab 2(報名明細) +1. ✅ 報名明細表格(前端分頁、排序、過濾) +2. ✅ `computed: filteredRegistrations` 實作 +3. ✅ 欄位色彩標記(badge) +4. ✅ 匯出功能(選配) + +### Phase 4: 實作 Tab 3~6(分析頁籤) +1. ✅ 信眾參與分析(Tab 3)- `computed: followerAnalysis` +2. ✅ 收入統計分析(Tab 4)- `computed: incomeStats` +3. ✅ 趨勢分析(Tab 5)- `computed: trendAnalysis` +4. ✅ 對比分析(Tab 6,選配)- `computed: comparativeAnalysis` + +### Phase 5: 優化與測試 +1. ✅ UI/UX 調整(與 transfer 保持一致) +2. ✅ 效能優化(分頁、快取) +3. ✅ 瀏覽器相容性測試 +4. ✅ 權限控制(ezAuthorize) + +--- + +## 參考範例 + +### 1. transfer/verify.aspx +- 多階段流程設計(程序1、程序2) +- v-data-table 表格元件使用 +- 信眾選擇對話框(v-dialog) +- 狀態選擇(v-select) + +### 2. transfer/verify1.aspx +- 單階段流程設計 +- 簡潔的查詢與確認流程 + +### 3. transfer/index.aspx +- 功能入口頁面設計 +- Bootstrap Icons + Badge +- 三欄式布局 + +--- + +## 注意事項 + +1. **元件一致性**: 所有元件、樣式、命名均與 transfer 模組保持一致 +2. **API 規範**: 遵循 RESTful 設計,統一回傳格式 +3. **權限控制**: 所有 API 加上 `[ezAuthorize]` +4. **效能考量**: + - ✅ **前端計算策略**: Tab 1 查詢一次,Tab 2~6 使用 Vue computed 計算 + - ✅ **資料量控制**: 單一法會報名明細預估 <5000 筆,適合前端處理 + - ⚠️ **大資料處理**: 若單場法會 >5000 筆,可改用 API 分頁查詢 + - ✅ **記憶體管理**: 切換法會時清除舊資料(`this.rawData = []`) +5. **視覺區隔**: 使用 Badge、邊框色、背景色標記欄位類型 +6. **使用者體驗**: 載入狀態(loading)、錯誤提示(alert)、空資料提示 +7. **前端效能優化**: + - 使用 `computed` 而非 `methods`(自動快取) + - 大型陣列操作使用 `Object.freeze()` 凍結原始資料 + - v-data-table 啟用虛擬滾動(`:virtual-scroll="true"`,資料量 >1000 時) + +--- + +## SQL View 參考 + +```SQL +CREATE VIEW [dbo].[報名明細查詢] +AS +SELECT dbo.activity.num AS 法會ID, dbo.activity.subject AS 法會名稱, dbo.activity.startDate_solar AS 開始日期, dbo.activity.endDate_solar AS 結束日期, + dbo.followers.f_number AS 信眾編號, dbo.followers.u_name AS 信眾姓名, dbo.pro_order.order_no AS 報名編號, dbo.pro_order.up_time AS 報名日期, + CASE WHEN parent_num IS NOT NULL THEN '是' ELSE '否' END AS 功德主, dbo.actItem_kind.kind AS 功德類型, dbo.actItem.subject AS 功德名稱, + dbo.pro_order_detail.qty AS 數量, dbo.pro_order_detail.price AS 金額, 0 AS 已收, dbo.pro_order_detail.price * dbo.pro_order_detail.qty - 0 AS 未收 +FROM dbo.pro_order_detail INNER JOIN + dbo.pro_order ON dbo.pro_order_detail.order_no = dbo.pro_order.order_no INNER JOIN + dbo.actItem ON dbo.pro_order_detail.actItem_num = dbo.actItem.num INNER JOIN + dbo.activity ON dbo.pro_order.activity_num = dbo.activity.num INNER JOIN + dbo.followers ON dbo.pro_order.f_num = dbo.followers.num INNER JOIN + dbo.actItem_kind ON dbo.actItem.kind = dbo.actItem_kind.num +GO +``` + +--- + +## 資料結構範例 + +```javascript +// Vue data 結構 +data() { + return { + // 原始完整資料(Tab 1 查詢後存入,供所有頁籤使用) + rawData: [], // Array<報名明細物件> + + // 選中的法會 + selectedActivity: null, + + // 當前頁籤索引 + activeTab: 0, // 0=Tab1, 1=Tab2, 2=Tab3... + + // Tab 2 過濾條件 + filter: { + followerName: '', + itemKind: null, + isParent: null + }, + + // Tab 4 分組方式 + groupBy: 'activity', // 'monthly', 'yearly', 'activity', 'itemKind' + + // Tab 5 趨勢設定 + trendMetric: 'income', // 'income', 'followers', 'count' + trendInterval: 'monthly', // 'monthly', 'quarterly', 'yearly' + + // 載入狀態 + loading: false + } +} +``` + +## Vue Computed 效能考量 + +```javascript +computed: { + // 使用 Object.freeze() 凍結大型陣列,提升效能 + frozenRawData() { + return Object.freeze(this.rawData); + }, + + // 各頁籤 computed 基於 frozenRawData + filteredRegistrations() { + return this.frozenRawData.filter(/* 過濾邏輯 */); + } +} +``` + +## 執行確認 + +請檢視以上計劃,確認以下事項: + +1. ✅ 頁籤設計(Tab 1~6)符合需求 +2. ✅ 查詢流程(年/月 → 法會清單 → 明細資料)清晰 +3. ✅ 視覺區隔(色彩標記、Badge)符合 Excel 概念 +4. ✅ 技術架構(Vue + Vuetify + API)與 transfer 一致 +5. ✅ 功能完整性(查詢、過濾、排序、匯出) +6. ✅ **資料流架構(一次查詢 + 前端計算)**合理且高效 ★ + +**確認後即開始實作 Phase 1(建立查詢頁面骨架)。** \ No newline at end of file diff --git a/web/admin/pivot/index.aspx b/web/admin/pivot/index.aspx new file mode 100644 index 0000000..776832a --- /dev/null +++ b/web/admin/pivot/index.aspx @@ -0,0 +1,185 @@ +<%@ Page Title="數據透視管理" Language="C#" MasterPageFile="~/admin/Templates/TBS5ADM001/MasterPage.master" AutoEventWireup="true" EnableEventValidation="false" CodeFile="index.aspx.cs" Inherits="admin_pivot_index" %> + + + + + +

數據透視管理

+
+ + + \ No newline at end of file diff --git a/web/admin/pivot/index.aspx.cs b/web/admin/pivot/index.aspx.cs new file mode 100644 index 0000000..9dd5b99 --- /dev/null +++ b/web/admin/pivot/index.aspx.cs @@ -0,0 +1,10 @@ +using System; +using System.Web.UI; + +public partial class admin_pivot_index : MyWeb.config +{ + protected void Page_Load(object sender, EventArgs e) + { + // 頁面初始化(暫無邏輯) + } +} \ No newline at end of file diff --git a/web/admin/pivot/pivot-01.aspx b/web/admin/pivot/pivot-01.aspx new file mode 100644 index 0000000..842e83b --- /dev/null +++ b/web/admin/pivot/pivot-01.aspx @@ -0,0 +1,1374 @@ +<%@ Page Title="法會報名統計分析" Language="C#" MasterPageFile="~/admin/Templates/TBS5ADM001/MasterPage.master" AutoEventWireup="true" EnableEventValidation="false" CodeFile="pivot-01.aspx.cs" Inherits="admin_pivot_pivot01" %> + + + + + + +
+ 法會報名統計分析 +
+
+ + +
+ +
+ +
+ + + + + + mdi-calendar-search + 法會選擇 + + + mdi-tablet-dashboard + 牌位分析 + + + mdi-account-group + 信眾報名分析 + + + mdi-account-star + 功德主查詢分析 + + + mdi-table-large + 報名明細資料 + {{ rawData.length }} + + + + + + + + + + mdi-filter + 查詢條件 + + +
+ + + + + + + mdi-magnify + 查看 + + + + + 共查詢到 {{ tab1.activities.length }} 場法會活動 + + + +
+ + + + + + + + + + + +
+
+
+ + + + + + mdi-tablet-dashboard + 牌位分析 + + + {{ tab1.selectedActivity.活動名稱 }} + + + + +
+ + +
+ mdi-chart-pie + 樞紐分析表 +
+
+ 按功德類型和功德名稱分組統計 +
+
+ +
+ 項目總數: {{ tab2PivotData.filter(item => !item.isGroup).length }} 項 +
+
+ 類型總數: {{ tab2PivotData.filter(item => item.isGroup).length }} 類 +
+
+
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + mdi-account-multiple + 信眾報名分析 + + + {{ tab1.selectedActivity.活動名稱 }} + + + + + + + + + + + +
+ + +
總人數
+
{{ tab3Summary.總人數 }} 人
+
+ +
功德主數量
+
{{ tab3Summary.功德主數量合計.toLocaleString() }}
+
+ +
功德主金額
+
{{ formatCurrency(tab3Summary.功德主金額合計) }}
+
+ +
總金額
+
+ {{ formatCurrency(tab3Summary.功德主金額合計 + tab3Summary.功德金額合計) }} +
+
+
+
+ + + + + + + + + + + + +
+
+
+ + + + + mdi-table-pivot + 功德主查詢分析 + + + {{ tab1.selectedActivity.活動名稱 }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + 信眾數: {{ tab4PivotData.length }} 人 + + + 功德項目: {{ tab4MeritItems.length }} 項 + + +
+ + +
+ + + + + + + + + + +
+
+
+
+ + + + + + mdi-table-large + 報名明細資料 + + + {{ tab1.selectedActivity.活動名稱 }} + + + + +
+ + +
+ mdi-calendar-range + {{ tab1.selectedActivity.活動名稱 }} +
+
+ + {{ formatDate(tab1.selectedActivity.開始日期) }} ~ + {{ formatDate(tab1.selectedActivity.結束日期) }} + +
+
+ +
+ 總筆數: {{ filteredData.length }} 筆 +
+
+ 總金額: {{ formatCurrency(totalAmount) }} +
+
+
+
+ + + + + + + + + mdi-file-excel + 匯出 Excel + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ + + + + + diff --git a/web/admin/pivot/pivot-01.aspx.cs b/web/admin/pivot/pivot-01.aspx.cs new file mode 100644 index 0000000..765b00f --- /dev/null +++ b/web/admin/pivot/pivot-01.aspx.cs @@ -0,0 +1,11 @@ +using System; +using System.Web.UI; + +public partial class admin_pivot_pivot01 : MyWeb.config +{ + protected void Page_Load(object sender, EventArgs e) + { + // 頁面初始化(暫無邏輯) + // 前端直接呼叫 API + } +} diff --git a/web/admin/pivot/query.aspx b/web/admin/pivot/query.aspx new file mode 100644 index 0000000..43a9afa --- /dev/null +++ b/web/admin/pivot/query.aspx @@ -0,0 +1,1128 @@ +<%@ Page Title="數據透視查詢" Language="C#" MasterPageFile="~/admin/Templates/TBS5ADM001/MasterPage.master" AutoEventWireup="true" EnableEventValidation="false" CodeFile="query.aspx.cs" Inherits="admin_pivot_query" %> + + + + + + +
+ 數據透視查詢 +
+
+ + +
+ + +
+ +
+ + + + + + mdi-magnify + 查詢條件 + + + mdi-table + 報名明細 + {{ rawData.length }} + + + mdi-account-group + 信眾分析 + + + mdi-currency-usd + 收入統計 + + + mdi-chart-line + 趨勢分析 + + + mdi-compare + 對比分析 + + + + + + + + + + mdi-filter + 查詢條件設定 + + + + + + + + + + + + mdi-magnify + 查詢法會 + + + + + mdi-refresh + 重設 + + + + + + +
+ mdi-format-list-bulleted + 法會清單 + + 共 {{ activities.length }} 場 + +
+ + + + + + + + +
+
+
+ + + + + + mdi-table + 報名明細 + + + {{ selectedActivity ? selectedActivity.activity_name : '未選擇' }} + + + + +
+ + + + + + + + + + + + + mdi-download + 匯出 Excel + + + +
+ + + + + + +
{{ filteredRegistrations.length }}
+
報名筆數
+
+
+
+ + + +
{{ uniqueFollowersCount }}
+
報名人數
+
+
+
+ + + +
{{ formatCurrency(totalAmount) }}
+
總金額
+
+
+
+ + + +
{{ formatCurrency(averageAmount) }}
+
平均金額
+
+
+
+
+ + + + + + + + + + + +
+
+
+ + + + + + mdi-account-group + 信眾參與分析 + + + 共 {{ followerAnalysis.length }} 位信眾 + + + + +
+ + + + + + + + + + + + + + +
+ + + + + + + + + +
+
+
+ + + + + + mdi-currency-usd + 收入統計分析 + + + + + + + + mdi-calendar-month + 月份 + + + mdi-calendar + 年度 + + + mdi-star + 法會 + + + mdi-tag + 功德類型 + + + + + + + + + + + + + + + + + + + + + + mdi-chart-line + 趨勢分析 + + + + + 趨勢分析功能開發中... + + + + + + + + + + mdi-compare + 對比分析 + + + + + 對比分析功能開發中... + + + + +
+
+
+
+
+ + + + + + + + + + + diff --git a/web/admin/pivot/query.aspx.cs b/web/admin/pivot/query.aspx.cs new file mode 100644 index 0000000..62057d4 --- /dev/null +++ b/web/admin/pivot/query.aspx.cs @@ -0,0 +1,10 @@ +using System; +using System.Web.UI; + +public partial class admin_pivot_query : MyWeb.config +{ + protected void Page_Load(object sender, EventArgs e) + { + // 頁面初始化(暫無邏輯) + } +}