* fix(attendance): add --stats-type flag to summary to fix C0002 error The MCP tool get_attendance_summary requires statsType in QueryUserAttendVO at the server-side business layer (DingTalk schema marks it required:[] but service rejects with C0002 'statistics type error' when omitted). Changes: - internal/helpers/attendance.go: add --stats-type flag (week/month); conditionally write statsType into QueryUserAttendVO when provided; update Long description and flag help to mark --stats-type as required - test/cli_compat/attendance_test.go: add execSummaryDryRun helper and 3 new tests verifying statsType is correctly threaded through VO - skills/references/products/attendance.md: document --stats-type flag with warning about server-side mandatory enforcement Verification: - go build ./... PASS - go vet ./internal/helpers/... ./test/cli_compat/... PASS - go test -run TestAttendanceSummary_should_pass_stats_type_* PASS (3/3) - go test -run TestAttendanceSummary_should_not_pass_stats_type_* PASS - Real CLI: dws attendance summary --stats-type month success:true (C0002 fully gone; full attendance data returned) Closes #227 * fix(attendance): enforce --stats-type as required at CLI layer (review feedback) Address review feedback from #228: 1. Fail-fast validation: --stats-type is now required at the CLI layer instead of being conditionally written into VO. Previously the doc said 'required' but RunE silently let omission through, causing the same C0002 the fix was supposed to prevent. 2. Enum validation: only 'week' or 'month' are accepted; any other value is rejected at the CLI layer instead of being forwarded to the server. 3. Cosmetic: split the single-line Long description into multiple lines for readability. Test changes: - Replaced TestAttendanceSummary_should_not_pass_stats_type_when_omitted with two semantically-correct tests: * TestAttendanceSummary_should_error_when_stats_type_missing * TestAttendanceSummary_should_error_when_stats_type_invalid - Updated other TestAttendanceSummary_* tests to pass --stats-type=month to match the new fail-fast contract. - Renamed should_pass_only_user_flag to should_pass_user_id_through_vo (the old name no longer makes sense now that --stats-type is mandatory). Skill doc: - skills/references/products/attendance.md: clarified that the CLI now rejects missing/invalid --stats-type at the client side (won't even reach the server). Verification: - go build ./... PASS - go vet ./internal/helpers/... ./test/cli_compat/ PASS - go test -run 'TestAttendanceSummary_should_(pass_stats_type|error_when_stats_type)' -v PASS (4/4) - dws attendance summary --help new layout shown --------- Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
376 lines
14 KiB
Go
376 lines
14 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
|
||
package helpers
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
func init() {
|
||
RegisterPublic(func() Handler {
|
||
return attendanceHandler{}
|
||
})
|
||
}
|
||
|
||
type attendanceHandler struct{}
|
||
|
||
func (attendanceHandler) Name() string {
|
||
return "attendance"
|
||
}
|
||
|
||
func (attendanceHandler) Command(runner executor.Runner) *cobra.Command {
|
||
root := &cobra.Command{
|
||
Use: "attendance",
|
||
Short: "考勤打卡 / 排班 / 统计",
|
||
Long: `管理钉钉考勤:查询个人考勤详情、批量查询排班、获取考勤统计摘要、查询考勤组与规则。
|
||
|
||
子命令:
|
||
record 考勤记录(个人考勤详情)
|
||
shift 排班管理(批量查询班次 / 排班信息)
|
||
summary 获取考勤统计摘要
|
||
rules 查询考勤组与考勤规则`,
|
||
Args: cobra.NoArgs,
|
||
TraverseChildren: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return cmd.Help()
|
||
},
|
||
}
|
||
|
||
record := &cobra.Command{
|
||
Use: "record",
|
||
Short: "考勤记录",
|
||
Args: cobra.NoArgs,
|
||
TraverseChildren: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return cmd.Help()
|
||
},
|
||
}
|
||
record.AddCommand(
|
||
newAttendanceRecordGetCommand(runner),
|
||
newAttendanceRecordListCommand(runner),
|
||
)
|
||
|
||
shift := &cobra.Command{
|
||
Use: "shift",
|
||
Short: "排班管理",
|
||
Args: cobra.NoArgs,
|
||
TraverseChildren: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return cmd.Help()
|
||
},
|
||
}
|
||
shift.AddCommand(newAttendanceShiftListCommand(runner))
|
||
|
||
root.AddCommand(
|
||
record,
|
||
shift,
|
||
newAttendanceSummaryCommand(runner),
|
||
newAttendanceRulesCommand(runner),
|
||
)
|
||
return root
|
||
}
|
||
|
||
// ── record get ─────────────────────────────────────────────
|
||
|
||
func newAttendanceRecordGetCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "get",
|
||
Short: "查询个人考勤详情",
|
||
Example: " dws attendance record get --user USER_ID --date 2026-03-08",
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
userID, _ := cmd.Flags().GetString("user")
|
||
dateStr, _ := cmd.Flags().GetString("date")
|
||
if userID == "" {
|
||
return apperrors.NewValidation("--user is required")
|
||
}
|
||
if dateStr == "" {
|
||
return apperrors.NewValidation("--date is required (format: YYYY-MM-DD)")
|
||
}
|
||
t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
|
||
if err != nil {
|
||
return apperrors.NewValidation("--date format error, use YYYY-MM-DD, e.g. 2026-03-08")
|
||
}
|
||
params := map[string]any{
|
||
"userId": userID,
|
||
"workDate": t.UnixMilli(),
|
||
}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "attendance", "get_user_attendance_record", params,
|
||
))
|
||
}
|
||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "attendance", "get_user_attendance_record", params,
|
||
))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
},
|
||
}
|
||
cmd.Flags().String("user", "", "钉钉用户 ID (必填)")
|
||
cmd.Flags().String("date", "", "查询日期,格式 YYYY-MM-DD (必填)")
|
||
preferLegacyLeaf(cmd)
|
||
return cmd
|
||
}
|
||
|
||
// ── record list ────────────────────────────────────────────
|
||
|
||
func newAttendanceRecordListCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: "批量查询考勤打卡记录",
|
||
Long: "批量查询多个员工在指定日期范围的考勤打卡记录。",
|
||
Hidden: true,
|
||
Example: " dws attendance record list --users userId1,userId2 --start 2026-03-03 --end 2026-03-07",
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
usersStr, _ := cmd.Flags().GetString("users")
|
||
startStr, _ := cmd.Flags().GetString("start")
|
||
endStr, _ := cmd.Flags().GetString("end")
|
||
if usersStr == "" {
|
||
return apperrors.NewValidation("--users is required")
|
||
}
|
||
if startStr == "" {
|
||
return apperrors.NewValidation("--start is required (format: YYYY-MM-DD)")
|
||
}
|
||
if endStr == "" {
|
||
return apperrors.NewValidation("--end is required (format: YYYY-MM-DD)")
|
||
}
|
||
startT, err := time.ParseInLocation("2006-01-02", startStr, time.Local)
|
||
if err != nil {
|
||
return apperrors.NewValidation("--start date format error, use YYYY-MM-DD")
|
||
}
|
||
endT, err := time.ParseInLocation("2006-01-02", endStr, time.Local)
|
||
if err != nil {
|
||
return apperrors.NewValidation("--end date format error, use YYYY-MM-DD")
|
||
}
|
||
var userIds []any
|
||
for _, u := range strings.Split(usersStr, ",") {
|
||
if s := strings.TrimSpace(u); s != "" {
|
||
userIds = append(userIds, s)
|
||
}
|
||
}
|
||
params := map[string]any{
|
||
"userIds": userIds,
|
||
"fromDateTime": startT.UnixMilli(),
|
||
"toDateTime": endT.UnixMilli(),
|
||
}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "attendance", "get_user_attendance_record", params,
|
||
))
|
||
}
|
||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "attendance", "get_user_attendance_record", params,
|
||
))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
},
|
||
}
|
||
cmd.Flags().String("users", "", "Comma-separated user ID list (required)")
|
||
cmd.Flags().String("start", "", "Start date, format YYYY-MM-DD (required)")
|
||
cmd.Flags().String("end", "", "End date, format YYYY-MM-DD (required)")
|
||
preferLegacyLeaf(cmd)
|
||
return cmd
|
||
}
|
||
|
||
// ── shift list ─────────────────────────────────────────────
|
||
|
||
func newAttendanceShiftListCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: "批量查询员工班次信息",
|
||
Long: "批量查询多个员工在指定日期的班次信息。最多 7 天,最多 50 人。",
|
||
Example: " dws attendance shift list --users userId1,userId2 --start 2026-03-03 --end 2026-03-07",
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
usersStr, _ := cmd.Flags().GetString("users")
|
||
startStr, _ := cmd.Flags().GetString("start")
|
||
endStr, _ := cmd.Flags().GetString("end")
|
||
if usersStr == "" {
|
||
return apperrors.NewValidation("--users is required")
|
||
}
|
||
if startStr == "" {
|
||
return apperrors.NewValidation("--start is required (format: YYYY-MM-DD)")
|
||
}
|
||
if endStr == "" {
|
||
return apperrors.NewValidation("--end is required (format: YYYY-MM-DD)")
|
||
}
|
||
startT, err := time.ParseInLocation("2006-01-02", startStr, time.Local)
|
||
if err != nil {
|
||
return apperrors.NewValidation("--start date format error, use YYYY-MM-DD")
|
||
}
|
||
endT, err := time.ParseInLocation("2006-01-02", endStr, time.Local)
|
||
if err != nil {
|
||
return apperrors.NewValidation("--end date format error, use YYYY-MM-DD")
|
||
}
|
||
var userIds []any
|
||
for _, u := range strings.Split(usersStr, ",") {
|
||
if s := strings.TrimSpace(u); s != "" {
|
||
userIds = append(userIds, s)
|
||
}
|
||
}
|
||
params := map[string]any{
|
||
"userIds": userIds,
|
||
"fromDateTime": startT.UnixMilli(),
|
||
"toDateTime": endT.UnixMilli(),
|
||
}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "attendance", "batch_get_employee_shifts", params,
|
||
))
|
||
}
|
||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "attendance", "batch_get_employee_shifts", params,
|
||
))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
},
|
||
}
|
||
cmd.Flags().String("users", "", "Comma-separated user ID list, max 50 (required)")
|
||
cmd.Flags().String("start", "", "Start date, format YYYY-MM-DD (required)")
|
||
cmd.Flags().String("end", "", "End date, format YYYY-MM-DD (required)")
|
||
preferLegacyLeaf(cmd)
|
||
return cmd
|
||
}
|
||
|
||
// ── summary ────────────────────────────────────────────────
|
||
|
||
func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "summary",
|
||
Short: "查询某个人的考勤统计摘要",
|
||
Long: `查询某个人的考勤统计摘要。
|
||
|
||
--user、--date、--stats-type 均必填。
|
||
钉钉服务端业务层强制要求 --stats-type(week/month),不填会返回 C0002 统计类型错误。`,
|
||
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
|
||
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
userID, _ := cmd.Flags().GetString("user")
|
||
workDateStr, _ := cmd.Flags().GetString("date")
|
||
statsType, _ := cmd.Flags().GetString("stats-type")
|
||
if userID == "" {
|
||
return apperrors.NewValidation("--user is required, provide DingTalk user ID")
|
||
}
|
||
if workDateStr == "" {
|
||
return apperrors.NewValidation("--date is required, format yyyy-MM-dd HH:mm:ss")
|
||
}
|
||
_, err := time.ParseInLocation("2006-01-02 15:04:05", workDateStr, time.Local)
|
||
if err != nil {
|
||
return apperrors.NewValidation("--date format error, use yyyy-MM-dd HH:mm:ss")
|
||
}
|
||
if statsType == "" {
|
||
return apperrors.NewValidation(`--stats-type is required (week|month), enforced by DingTalk server`)
|
||
}
|
||
if statsType != "week" && statsType != "month" {
|
||
return apperrors.NewValidation(`--stats-type must be "week" or "month"`)
|
||
}
|
||
// Build nested structure QueryUserAttendVO
|
||
vo := map[string]any{
|
||
"userId": userID,
|
||
"queryDate": workDateStr,
|
||
"statsType": statsType,
|
||
}
|
||
params := map[string]any{
|
||
"QueryUserAttendVO": vo,
|
||
}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "attendance", "get_attendance_summary", params,
|
||
))
|
||
}
|
||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "attendance", "get_attendance_summary", params,
|
||
))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
},
|
||
}
|
||
cmd.Flags().String("user", "", "钉钉用户 ID(必填)")
|
||
cmd.Flags().String("date", "", "工作日期,格式 yyyy-MM-dd HH:mm:ss,如 2026-03-12 15:00:00(必填)")
|
||
cmd.Flags().String("stats-type", "", "统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求)")
|
||
preferLegacyLeaf(cmd)
|
||
return cmd
|
||
}
|
||
|
||
// ── rules ──────────────────────────────────────────────────
|
||
|
||
func newAttendanceRulesCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "rules",
|
||
Short: "查询考勤组与考勤规则",
|
||
Long: "调用 MCP 工具 query_attendance_group_or_rules 查询考勤组/考勤规则。\n例如:我属于哪个考勤组、打卡范围是什么、弹性工时怎么算。",
|
||
Example: " dws attendance rules --date 2026-03-14\n dws attendance rules --date \"2026-03-14 09:00:00\"",
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
dateStr, _ := cmd.Flags().GetString("date")
|
||
if dateStr == "" {
|
||
return apperrors.NewValidation("--date is required")
|
||
}
|
||
// Support YYYY-MM-DD or yyyy-MM-dd HH:mm:ss, normalize to yyyy-MM-dd HH:mm:ss
|
||
var dateFormatted string
|
||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", dateStr, time.Local); err == nil {
|
||
dateFormatted = t.Format("2006-01-02 15:04:05")
|
||
} else if t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local); err == nil {
|
||
dateFormatted = t.Format("2006-01-02 15:04:05")
|
||
} else {
|
||
return apperrors.NewValidation(fmt.Sprintf("--date format error, use YYYY-MM-DD or yyyy-MM-dd HH:mm:ss: %s", dateStr))
|
||
}
|
||
params := map[string]any{
|
||
"date": dateFormatted,
|
||
}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "attendance", "query_attendance_group_or_rules", params,
|
||
))
|
||
}
|
||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "attendance", "query_attendance_group_or_rules", params,
|
||
))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
},
|
||
}
|
||
cmd.Flags().String("date", "", "考勤日期,格式 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss (必填)")
|
||
preferLegacyLeaf(cmd)
|
||
return cmd
|
||
}
|