From 5c805c7b097fba88b8dbc6d25fd3d0132e50565d Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Thu, 17 Sep 2026 23:37:16 +0800 Subject: [PATCH] feat: complete workbench split admin routes --- internal/httpapi/admin.go | 123 +++++++++++++++++++++++++++++++++--- internal/httpapi/api.go | 57 +++++++++++------ internal/httpapi/payload.go | 17 +++++ internal/store/admin.go | 80 +++++++++++++++++++++++ internal/store/mysql.go | 82 ++++++++++++++++++++++++ internal/store/types.go | 1 + 6 files changed, 334 insertions(+), 26 deletions(-) diff --git a/internal/httpapi/admin.go b/internal/httpapi/admin.go index 9da5dc6..6d12508 100644 --- a/internal/httpapi/admin.go +++ b/internal/httpapi/admin.go @@ -11,6 +11,10 @@ import ( ) func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) { + st, ok := a.internalAdminStore(w, r) + if !ok { + return + } switch r.Method { case http.MethodGet: page := queryInt(r, "page", 1) @@ -21,7 +25,7 @@ func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) { enabled = &value } } - items, total, err := a.store.AdminWorkbenchModules(r.Context(), page, perPage, r.URL.Query().Get("code"), r.URL.Query().Get("name"), enabled) + items, total, err := st.AdminWorkbenchModules(r.Context(), page, perPage, r.URL.Query().Get("code"), r.URL.Query().Get("name"), enabled) if err != nil { writeStoreError(w, r, err) return @@ -32,7 +36,7 @@ func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &body) { return } - item, err := a.store.UpsertAdminWorkbenchModule(r.Context(), store.AdminWorkbenchModuleInput{ + item, err := st.UpsertAdminWorkbenchModule(r.Context(), store.AdminWorkbenchModuleInput{ ID: body.ID, Code: body.Code, Name: body.Name, IconFileID: body.IconFileID, Route: body.Route, SortOrder: body.SortOrder, UnreadKey: body.UnreadKey, Enabled: body.Enabled, Permission: body.Permission, }) @@ -47,6 +51,10 @@ func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) { } func (a *API) adminWorkbenchAppDisable(w http.ResponseWriter, r *http.Request) { + st, ok := a.internalAdminStore(w, r) + if !ok { + return + } if r.Method != http.MethodPost { methodNotAllowed(w, r) return @@ -58,7 +66,7 @@ func (a *API) adminWorkbenchAppDisable(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &body) { return } - if err := a.store.DisableAdminWorkbenchModule(r.Context(), body.ID, body.Code); err != nil { + if err := st.DisableAdminWorkbenchModule(r.Context(), body.ID, body.Code); err != nil { writeStoreError(w, r, err) return } @@ -66,6 +74,10 @@ func (a *API) adminWorkbenchAppDisable(w http.ResponseWriter, r *http.Request) { } func (a *API) adminWorkbenchAppsReorder(w http.ResponseWriter, r *http.Request) { + st, ok := a.internalAdminStore(w, r) + if !ok { + return + } if r.Method != http.MethodPost { methodNotAllowed(w, r) return @@ -83,17 +95,63 @@ func (a *API) adminWorkbenchAppsReorder(w http.ResponseWriter, r *http.Request) for _, item := range body.Items { items = append(items, store.AdminWorkbenchReorderItem{Code: item.Code, SortOrder: item.SortOrder}) } - if err := a.store.ReorderAdminWorkbenchModules(r.Context(), items); err != nil { + if err := st.ReorderAdminWorkbenchModules(r.Context(), items); err != nil { writeStoreError(w, r, err) return } writeSuccess(w, r, map[string]any{"ok": true}) } +func (a *API) adminOrganizationDepartments(w http.ResponseWriter, r *http.Request) { + st, ok := a.internalAdminStore(w, r) + if !ok { + return + } + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + page := queryInt(r, "page", 1) + perPage := queryInt(r, "per_page", 20) + items, total, err := st.AdminOrganizationDepartments(r.Context(), page, perPage, + r.URL.Query().Get("parent_id"), r.URL.Query().Get("name"), r.URL.Query().Get("state")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{"items": adminOrganizationDepartmentPayloads(items), "total": total, "page": page, "per_page": perPage}) +} + +func (a *API) adminOrganizationMembers(w http.ResponseWriter, r *http.Request) { + st, ok := a.internalAdminStore(w, r) + if !ok { + return + } + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + page := queryInt(r, "page", 1) + perPage := queryInt(r, "per_page", 20) + userID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("user_id")), 10, 64) + items, total, err := st.AdminOrganizationMembers(r.Context(), page, perPage, + r.URL.Query().Get("department_id"), firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword")), + r.URL.Query().Get("employment_state"), userID) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{"items": adminOrganizationMemberPayloads(items), "total": total, "page": page, "per_page": perPage}) +} + func (a *API) adminMailboxConfig(w http.ResponseWriter, r *http.Request) { + st, ok := a.internalAdminStore(w, r) + if !ok { + return + } switch r.Method { case http.MethodGet: - cfg, err := a.store.AdminMailboxConfig(r.Context()) + cfg, err := st.AdminMailboxConfig(r.Context()) if err != nil { writeStoreError(w, r, err) return @@ -104,7 +162,7 @@ func (a *API) adminMailboxConfig(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &body) { return } - if err := a.store.UpdateAdminMailboxConfig(r.Context(), store.AdminMailboxConfig{ + if err := st.UpdateAdminMailboxConfig(r.Context(), store.AdminMailboxConfig{ Enabled: body.Enabled, AllowAnonymous: body.AllowAnonymous, RequiredFields: body.RequiredFields, MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy, NoticeTitle: body.NoticeTitle, NoticeContent: body.NoticeContent, }); err != nil { @@ -118,9 +176,13 @@ func (a *API) adminMailboxConfig(w http.ResponseWriter, r *http.Request) { } func (a *API) adminFeedbackConfig(w http.ResponseWriter, r *http.Request) { + st, ok := a.internalAdminStore(w, r) + if !ok { + return + } switch r.Method { case http.MethodGet: - cfg, err := a.store.AdminFeedbackConfig(r.Context()) + cfg, err := st.AdminFeedbackConfig(r.Context()) if err != nil { writeStoreError(w, r, err) return @@ -131,7 +193,7 @@ func (a *API) adminFeedbackConfig(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &body) { return } - if err := a.store.UpdateAdminFeedbackConfig(r.Context(), store.AdminFeedbackConfig{ + if err := st.UpdateAdminFeedbackConfig(r.Context(), store.AdminFeedbackConfig{ Categories: body.Categories, MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy, AllowContact: body.AllowContact, DiagnosticsNotice: body.DiagnosticsNotice, }); err != nil { @@ -144,6 +206,17 @@ func (a *API) adminFeedbackConfig(w http.ResponseWriter, r *http.Request) { } } +func (a *API) internalAdminStore(w http.ResponseWriter, r *http.Request) (*store.Store, bool) { + tenantID, _ := strconv.ParseInt(firstNonEmpty(r.Header.Get("X-Tenant-ID"), r.URL.Query().Get("tenant_id")), 10, 64) + tenantCode := firstNonEmpty(r.Header.Get("X-Tenant-Code"), r.URL.Query().Get("tenant_code"), "default") + st, err := a.store.ForTenant(r.Context(), tenantID, tenantCode) + if err != nil { + writeStoreError(w, r, err) + return nil, false + } + return st, true +} + type adminWorkbenchModuleRequest struct { ID int64 `json:"id"` Code string `json:"code"` @@ -198,6 +271,40 @@ func adminWorkbenchModulePayload(item store.AdminWorkbenchModule) map[string]any } } +func adminOrganizationDepartmentPayloads(items []store.OrganizationDepartment) []map[string]any { + res := make([]map[string]any, 0, len(items)) + for _, item := range items { + res = append(res, map[string]any{ + "department_id": item.DepartmentID, + "name": item.Name, + "parent_id": item.ParentID, + "child_count": item.ChildCount, + "member_count": item.MemberCount, + "version": item.Version, + }) + } + return res +} + +func adminOrganizationMemberPayloads(items []store.OrganizationMember) []map[string]any { + res := make([]map[string]any, 0, len(items)) + for _, item := range items { + res = append(res, map[string]any{ + "user_id": item.UserID, + "department_id": item.DepartmentID, + "display_name": item.DisplayName, + "position": item.Position, + "mobile": item.Mobile, + "email": item.Email, + "avatar_file_id": item.AvatarFileID, + "status": item.Status, + "contactable": item.Contactable, + "version": item.Version, + }) + } + return res +} + func adminMailboxConfigPayload(cfg store.AdminMailboxConfig) map[string]any { return map[string]any{ "id": cfg.ID, diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index 8c42cf6..4986596 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -64,6 +64,8 @@ func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("/internal/admin/workbench/apps", a.withInternalAdminAuth(a.adminWorkbenchApps)) mux.HandleFunc("/internal/admin/workbench/apps/disable", a.withInternalAdminAuth(a.adminWorkbenchAppDisable)) mux.HandleFunc("/internal/admin/workbench/apps/reorder", a.withInternalAdminAuth(a.adminWorkbenchAppsReorder)) + mux.HandleFunc("/internal/admin/workbench/organization/departments", a.withInternalAdminAuth(a.adminOrganizationDepartments)) + mux.HandleFunc("/internal/admin/workbench/organization/members", a.withInternalAdminAuth(a.adminOrganizationMembers)) mux.HandleFunc("/internal/admin/workbench/mailbox-config", a.withInternalAdminAuth(a.adminMailboxConfig)) mux.HandleFunc("/internal/admin/workbench/feedback-config", a.withInternalAdminAuth(a.adminFeedbackConfig)) } @@ -698,11 +700,6 @@ func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *aut writeStoreError(w, r, err) return } - if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.draft.saved", report.ReportID, - []int64{p.UserID}, "日报", report.ReportDate.Format("2006-01-02"), - map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { - return - } writeSuccess(w, r, dailyReportPayload(*report)) case tail == "draft" && r.Method == http.MethodPut: var body struct { @@ -718,8 +715,8 @@ func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *aut writeStoreError(w, r, err) return } - if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.submitted", report.ReportID, - []int64{p.UserID}, "日报已提交", report.ReportDate.Format("2006-01-02"), + if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.draft.saved", report.ReportID, + []int64{p.UserID}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { return } @@ -736,6 +733,11 @@ func (a *API) dailyReportResource(w http.ResponseWriter, r *http.Request, p *aut writeStoreError(w, r, err) return } + if !a.publishWorkbenchEvent(w, r, p, "daily_report", "daily_report.submitted", report.ReportID, + []int64{p.UserID}, "日报已提交", report.ReportDate.Format("2006-01-02"), + map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { + return + } writeSuccess(w, r, dailyReportPayload(*report)) default: methodNotAllowed(w, r) @@ -747,7 +749,17 @@ func (a *API) dailyReportStatistics(w http.ResponseWriter, r *http.Request, p *a methodNotAllowed(w, r) return } - writeSuccess(w, r, map[string]any{"expected_count": 0, "submitted_count": 0, "missing_count": 0, "scope": map[string]any{"department_id": r.URL.Query().Get("department_id")}}) + date, err := parseDate(firstNonEmpty(r.URL.Query().Get("date"), time.Now().Format("2006-01-02"))) + if err != nil { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "date"}) + return + } + stats, err := st.DailyReportStats(r.Context(), date, r.URL.Query().Get("department_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, reportStatsPayload(stats)) } func (a *API) dailyReportReminders(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { @@ -819,11 +831,6 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a writeStoreError(w, r, err) return } - if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.draft.saved", report.ReportID, - []int64{p.UserID}, "月报", report.Month, - map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { - return - } writeSuccess(w, r, monthlyReportPayload(*report)) case tail == "draft" && r.Method == http.MethodPut: var body struct { @@ -840,8 +847,8 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a writeStoreError(w, r, err) return } - if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.submitted", report.ReportID, - []int64{p.UserID}, "月报已提交", report.Month, + if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.draft.saved", report.ReportID, + []int64{p.UserID}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { return } @@ -858,8 +865,8 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a writeStoreError(w, r, err) return } - if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.reopened", report.ReportID, - []int64{p.UserID}, "月报已重开", report.Month, + if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.submitted", report.ReportID, + []int64{p.UserID}, "月报已提交", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { return } @@ -876,6 +883,11 @@ func (a *API) monthlyReportResource(w http.ResponseWriter, r *http.Request, p *a writeStoreError(w, r, err) return } + if !a.publishWorkbenchEvent(w, r, p, "monthly_report", "monthly_report.reopened", report.ReportID, + []int64{p.UserID}, "月报已重开", report.Month, + map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status}) { + return + } writeSuccess(w, r, monthlyReportPayload(*report)) default: methodNotAllowed(w, r) @@ -887,7 +899,16 @@ func (a *API) monthlyReportStatistics(w http.ResponseWriter, r *http.Request, p methodNotAllowed(w, r) return } - writeSuccess(w, r, map[string]any{"expected_count": 0, "submitted_count": 0, "missing_count": 0, "scope": map[string]any{"department_id": r.URL.Query().Get("department_id")}}) + month := strings.TrimSpace(r.URL.Query().Get("month")) + if month == "" { + month = time.Now().Format("2006-01") + } + stats, err := st.MonthlyReportStats(r.Context(), month, r.URL.Query().Get("department_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, reportStatsPayload(stats)) } func (a *API) mailboxConfig(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { diff --git a/internal/httpapi/payload.go b/internal/httpapi/payload.go index 3f2b06c..70ef3f7 100644 --- a/internal/httpapi/payload.go +++ b/internal/httpapi/payload.go @@ -417,6 +417,23 @@ func monthlyReportPayload(report store.MonthlyReport) map[string]any { return data } +func reportStatsPayload(stats *store.ReportStats) map[string]any { + if stats == nil { + return map[string]any{ + "expected_count": 0, + "submitted_count": 0, + "missing_count": 0, + "scope": map[string]any{"department_id": ""}, + } + } + return map[string]any{ + "expected_count": stats.ExpectedCount, + "submitted_count": stats.SubmittedCount, + "missing_count": stats.MissingCount, + "scope": map[string]any{"department_id": stats.DepartmentID}, + } +} + func mailboxConfigPayload(config store.MailboxConfig) map[string]any { return map[string]any{ "enabled": config.Enabled, diff --git a/internal/store/admin.go b/internal/store/admin.go index 7463898..907fb7f 100644 --- a/internal/store/admin.go +++ b/internal/store/admin.go @@ -162,6 +162,73 @@ func (s *Store) ReorderAdminWorkbenchModules(ctx context.Context, items []AdminW return tx.Commit() } +func (s *Store) AdminOrganizationDepartments(ctx context.Context, page, perPage int, parentID, name, state string) ([]OrganizationDepartment, int, error) { + page, perPage = normalizeAdminPage(page, perPage) + where := []string{"1=1"} + args := []any{} + if parentID = strings.TrimSpace(parentID); parentID != "" { + where = append(where, "parent_id=?") + args = append(args, parentID) + } + if name = strings.TrimSpace(name); name != "" { + where = append(where, "name LIKE ?") + args = append(args, "%"+name+"%") + } + if state = strings.TrimSpace(state); state != "" { + where = append(where, "state=?") + args = append(args, state) + } + whereSQL := strings.Join(where, " AND ") + var total int + if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_org_departments WHERE "+whereSQL, args...); err != nil { + return nil, 0, err + } + queryArgs := append([]any{}, args...) + queryArgs = append(queryArgs, (page-1)*perPage, perPage) + var items []OrganizationDepartment + err := s.db.SelectContext(ctx, &items, `SELECT d.department_id,d.name,d.parent_id, + (SELECT COUNT(*) FROM im_org_departments c WHERE c.parent_id=d.department_id AND c.state='active') AS child_count, + (SELECT COUNT(*) FROM im_org_members m WHERE m.department_id=d.department_id AND m.employment_state='active') AS member_count, + d.version + FROM im_org_departments d WHERE `+whereSQL+` ORDER BY d.parent_id ASC,d.version DESC,d.department_id ASC LIMIT ?,?`, queryArgs...) + return items, total, err +} + +func (s *Store) AdminOrganizationMembers(ctx context.Context, page, perPage int, departmentID, keyword, state string, userID int64) ([]OrganizationMember, int, error) { + page, perPage = normalizeAdminPage(page, perPage) + where := []string{"1=1"} + args := []any{} + if departmentID = strings.TrimSpace(departmentID); departmentID != "" { + where = append(where, "department_id=?") + args = append(args, departmentID) + } + if keyword = strings.TrimSpace(keyword); keyword != "" { + where = append(where, "(display_name LIKE ? OR position LIKE ? OR mobile LIKE ? OR email LIKE ?)") + like := "%" + keyword + "%" + args = append(args, like, like, like, like) + } + if state = strings.TrimSpace(state); state != "" { + where = append(where, "employment_state=?") + args = append(args, state) + } + if userID > 0 { + where = append(where, "user_id=?") + args = append(args, userID) + } + whereSQL := strings.Join(where, " AND ") + var total int + if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_org_members WHERE "+whereSQL, args...); err != nil { + return nil, 0, err + } + queryArgs := append([]any{}, args...) + queryArgs = append(queryArgs, (page-1)*perPage, perPage) + var items []OrganizationMember + err := s.db.SelectContext(ctx, &items, `SELECT user_id,department_id,display_name,position,IFNULL(mobile,'') AS mobile,IFNULL(email,'') AS email, + IFNULL(avatar_file_id,'') AS avatar_file_id,employment_state AS status,contactable,version + FROM im_org_members WHERE `+whereSQL+` ORDER BY department_id ASC,user_id ASC LIMIT ?,?`, queryArgs...) + return items, total, err +} + func (s *Store) AdminMailboxConfig(ctx context.Context) (*AdminMailboxConfig, error) { var cfg AdminMailboxConfig if err := s.db.GetContext(ctx, &cfg, `SELECT id,enabled+0 AS enabled,allow_anonymous+0 AS allow_anonymous,CAST(required_fields AS CHAR) AS required_fields,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,notice_title,notice_content,notice_updated_at,updated_at FROM im_mailbox_config WHERE id=1`); err != nil { @@ -224,3 +291,16 @@ func defaultString(value string, fallback string) string { } return value } + +func normalizeAdminPage(page, perPage int) (int, int) { + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + return page, perPage +} diff --git a/internal/store/mysql.go b/internal/store/mysql.go index ba0b9f0..b6e3bc7 100644 --- a/internal/store/mysql.go +++ b/internal/store/mysql.go @@ -783,6 +783,34 @@ func (s *Store) SubmitDaily(ctx context.Context, userID int64, reportID string, return s.DailyReport(ctx, userID, reportID) } +func (s *Store) DailyReportStats(ctx context.Context, date time.Time, departmentID string) (*ReportStats, error) { + stats := &ReportStats{DepartmentID: strings.TrimSpace(departmentID)} + memberWhere, memberArgs := reportMemberScope(stats.DepartmentID) + if err := s.db.GetContext(ctx, &stats.ExpectedCount, + `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE `+strings.Join(memberWhere, " AND "), memberArgs...); err != nil { + if isMissingTable(err) { + return stats, nil + } + return nil, err + } + args := append([]any{date.Format("2006-01-02")}, memberArgs...) + if err := s.db.GetContext(ctx, &stats.SubmittedCount, + `SELECT COUNT(DISTINCT r.author_id) + FROM im_daily_reports AS r + INNER JOIN im_org_members AS m ON m.user_id=r.author_id + WHERE r.report_date=? AND r.status='submitted' AND `+strings.Join(prefixReportMemberScope(memberWhere), " AND "), args...); err != nil { + if isMissingTable(err) { + return stats, nil + } + return nil, err + } + stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount + if stats.MissingCount < 0 { + stats.MissingCount = 0 + } + return stats, nil +} + func (s *Store) MonthlyReports(ctx context.Context, userID int64, cursor string, limit int) ([]MonthlyReport, string, error) { var items []MonthlyReport query := `SELECT report_id,author_id,report_month,work_summary,next_plan,attachments,status,read_state,version,submitted_at,created_at,updated_at FROM im_monthly_reports WHERE author_id=?` @@ -869,6 +897,34 @@ func (s *Store) ReopenMonthly(ctx context.Context, userID int64, reportID string return s.MonthlyReport(ctx, userID, reportID) } +func (s *Store) MonthlyReportStats(ctx context.Context, month, departmentID string) (*ReportStats, error) { + stats := &ReportStats{DepartmentID: strings.TrimSpace(departmentID)} + memberWhere, memberArgs := reportMemberScope(stats.DepartmentID) + if err := s.db.GetContext(ctx, &stats.ExpectedCount, + `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE `+strings.Join(memberWhere, " AND "), memberArgs...); err != nil { + if isMissingTable(err) { + return stats, nil + } + return nil, err + } + args := append([]any{strings.TrimSpace(month)}, memberArgs...) + if err := s.db.GetContext(ctx, &stats.SubmittedCount, + `SELECT COUNT(DISTINCT r.author_id) + FROM im_monthly_reports AS r + INNER JOIN im_org_members AS m ON m.user_id=r.author_id + WHERE r.report_month=? AND r.status='submitted' AND `+strings.Join(prefixReportMemberScope(memberWhere), " AND "), args...); err != nil { + if isMissingTable(err) { + return stats, nil + } + return nil, err + } + stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount + if stats.MissingCount < 0 { + stats.MissingCount = 0 + } + return stats, nil +} + func (s *Store) MailboxConfig(ctx context.Context) (*MailboxConfig, error) { var cfg MailboxConfig if err := s.db.GetContext(ctx, &cfg, `SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,CAST(COALESCE(UNIX_TIMESTAMP(notice_updated_at),1) AS UNSIGNED) AS version FROM im_mailbox_config WHERE id=1`); err != nil { @@ -1138,6 +1194,32 @@ func trimMemberPage(items []OrganizationMember, limit int) ([]OrganizationMember return items, next, nil } +func reportMemberScope(departmentID string) ([]string, []any) { + where := []string{"employment_state='active'"} + args := []any{} + if departmentID = strings.TrimSpace(departmentID); departmentID != "" { + where = append(where, "department_id=?") + args = append(args, departmentID) + } + return where, args +} + +func prefixReportMemberScope(where []string) []string { + out := make([]string, 0, len(where)) + for _, condition := range where { + condition = strings.TrimSpace(condition) + switch { + case strings.HasPrefix(condition, "employment_state"): + out = append(out, "m."+condition) + case strings.HasPrefix(condition, "department_id"): + out = append(out, "m."+condition) + default: + out = append(out, condition) + } + } + return out +} + func isDuplicate(err error) bool { return err != nil && strings.Contains(err.Error(), "Duplicate entry") } diff --git a/internal/store/types.go b/internal/store/types.go index 91f3103..4965268 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -205,6 +205,7 @@ type ReportStats struct { ExpectedCount int SubmittedCount int MissingCount int + DepartmentID string } type MailboxConfig struct {