mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
1 Commits
feat/batch
...
feat/atten
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55fd9e26e7 |
315
shortcuts/attendance/attendance_records.go
Normal file
315
shortcuts/attendance/attendance_records.go
Normal file
@@ -0,0 +1,315 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package attendance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const userTasksQueryPath = "/open-apis/attendance/v1/user_tasks/query"
|
||||
|
||||
type userTasksQueryReq struct {
|
||||
UserIDs []string `json:"user_ids"` // empty (non-nil) array = self-query
|
||||
CheckDateFrom int `json:"check_date_from"`
|
||||
CheckDateTo int `json:"check_date_to"`
|
||||
NeedOvertimeResult bool `json:"need_overtime_result"` // always true: surface overtime segments via task_shift_type
|
||||
}
|
||||
|
||||
// userFlow is the punch flow nested under check_in_record / check_out_record.
|
||||
// The device-fingerprint fields (ssid/bssid/device_id/photo_urls/...) are left
|
||||
// undeclared on purpose so they never reach output — zero agent value, and a
|
||||
// surveillance/privacy surface.
|
||||
type userFlow struct {
|
||||
CheckTime string `json:"check_time"`
|
||||
LocationName string `json:"location_name"`
|
||||
}
|
||||
|
||||
type shiftRecord struct {
|
||||
CheckInResult string `json:"check_in_result"`
|
||||
CheckInResultSupplement string `json:"check_in_result_supplement"`
|
||||
CheckInShiftTime string `json:"check_in_shift_time"`
|
||||
CheckInRecord *userFlow `json:"check_in_record"`
|
||||
CheckInRecordID string `json:"check_in_record_id"`
|
||||
CheckOutResult string `json:"check_out_result"`
|
||||
CheckOutResultSupplement string `json:"check_out_result_supplement"`
|
||||
CheckOutShiftTime string `json:"check_out_shift_time"`
|
||||
CheckOutRecord *userFlow `json:"check_out_record"`
|
||||
CheckOutRecordID string `json:"check_out_record_id"`
|
||||
TaskShiftType int `json:"task_shift_type"`
|
||||
}
|
||||
|
||||
type userTaskResult struct {
|
||||
EmployeeName string `json:"employee_name"`
|
||||
UserID string `json:"user_id"`
|
||||
GroupID string `json:"group_id"`
|
||||
ShiftID string `json:"shift_id"`
|
||||
Day int `json:"day"`
|
||||
Records []shiftRecord `json:"records"`
|
||||
}
|
||||
|
||||
type userTasksQueryData struct {
|
||||
UserTaskResults []userTaskResult `json:"user_task_results"`
|
||||
}
|
||||
|
||||
type userTasksQueryResp struct {
|
||||
Data *userTasksQueryData `json:"data"`
|
||||
}
|
||||
|
||||
type punchRow struct {
|
||||
Date string `json:"date"`
|
||||
ShiftType string `json:"shift_type"`
|
||||
CheckIn string `json:"check_in"`
|
||||
CheckInSupp string `json:"check_in_supplement,omitempty"`
|
||||
CheckOut string `json:"check_out"`
|
||||
CheckOutSupp string `json:"check_out_supplement,omitempty"`
|
||||
}
|
||||
|
||||
// punchDetail is the single projection target; the default view derives from it
|
||||
// via compact(). Time fields are raw second-level unix timestamps — no
|
||||
// rendering, no timezone arithmetic.
|
||||
type punchDetail struct {
|
||||
Date string `json:"date"`
|
||||
ShiftType string `json:"shift_type"`
|
||||
EmployeeName string `json:"employee_name,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
ShiftID string `json:"shift_id,omitempty"`
|
||||
|
||||
CheckIn string `json:"check_in"`
|
||||
CheckInSupp string `json:"check_in_supplement,omitempty"`
|
||||
CheckInScheduled int64 `json:"check_in_scheduled_at,omitempty"`
|
||||
CheckInPunchAt int64 `json:"check_in_punch_at,omitempty"`
|
||||
CheckInLocation string `json:"check_in_location,omitempty"`
|
||||
CheckInRecordID string `json:"check_in_record_id,omitempty"`
|
||||
|
||||
CheckOut string `json:"check_out"`
|
||||
CheckOutSupp string `json:"check_out_supplement,omitempty"`
|
||||
CheckOutScheduled int64 `json:"check_out_scheduled_at,omitempty"`
|
||||
CheckOutPunchAt int64 `json:"check_out_punch_at,omitempty"`
|
||||
CheckOutLocation string `json:"check_out_location,omitempty"`
|
||||
CheckOutRecordID string `json:"check_out_record_id,omitempty"`
|
||||
}
|
||||
|
||||
func (d punchDetail) compact() punchRow {
|
||||
return punchRow{
|
||||
Date: d.Date,
|
||||
ShiftType: d.ShiftType,
|
||||
CheckIn: d.CheckIn,
|
||||
CheckInSupp: d.CheckInSupp,
|
||||
CheckOut: d.CheckOut,
|
||||
CheckOutSupp: d.CheckOutSupp,
|
||||
}
|
||||
}
|
||||
|
||||
// parseWorkday converts a strict YYYY-MM-DD date into a yyyyMMdd integer. The
|
||||
// round-trip guard rejects non-canonical widths (e.g. "2026-6-1") that
|
||||
// time.Parse would otherwise accept, so the caller and the API mean the same day.
|
||||
func parseWorkday(s string) (int, error) {
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("expected a calendar date in YYYY-MM-DD form, got %q", s)
|
||||
}
|
||||
if t.Format("2006-01-02") != s {
|
||||
return 0, fmt.Errorf("expected a calendar date in YYYY-MM-DD form, got %q", s)
|
||||
}
|
||||
return t.Year()*10000 + int(t.Month())*100 + t.Day(), nil
|
||||
}
|
||||
|
||||
func formatWorkday(d int) string {
|
||||
return fmt.Sprintf("%04d-%02d-%02d", d/10000, (d/100)%100, d%100)
|
||||
}
|
||||
|
||||
func shiftTypeLabel(t int) string {
|
||||
if t == 1 {
|
||||
return "overtime"
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
|
||||
// omitNone collapses the API's "None" sentinel to "" so omitempty drops it.
|
||||
func omitNone(s string) string {
|
||||
if s == "None" {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// atoiTS returns 0 for empty/"None"/unparseable input so omitempty drops the
|
||||
// field rather than emitting a misleading 0.
|
||||
func atoiTS(s string) int64 {
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// flowTime / flowLoc tolerate a nil record (the API omits it for shifts that
|
||||
// never required a punch, e.g. NoNeedCheck).
|
||||
func flowTime(f *userFlow) int64 {
|
||||
if f == nil {
|
||||
return 0
|
||||
}
|
||||
return atoiTS(f.CheckTime)
|
||||
}
|
||||
|
||||
func flowLoc(f *userFlow) string {
|
||||
if f == nil {
|
||||
return ""
|
||||
}
|
||||
return f.LocationName
|
||||
}
|
||||
|
||||
// projectDetails is the single typed boundary: it flattens the nested results
|
||||
// into one punchDetail per shift segment so nothing downstream touches the raw maps.
|
||||
func projectDetails(d *userTasksQueryData) []punchDetail {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
var rows []punchDetail
|
||||
for _, t := range d.UserTaskResults {
|
||||
date := formatWorkday(t.Day)
|
||||
for _, r := range t.Records {
|
||||
rows = append(rows, punchDetail{
|
||||
Date: date, ShiftType: shiftTypeLabel(r.TaskShiftType),
|
||||
EmployeeName: t.EmployeeName, UserID: t.UserID, GroupID: t.GroupID, ShiftID: t.ShiftID,
|
||||
CheckIn: r.CheckInResult, CheckInSupp: omitNone(r.CheckInResultSupplement),
|
||||
CheckInScheduled: atoiTS(r.CheckInShiftTime), CheckInPunchAt: flowTime(r.CheckInRecord),
|
||||
CheckInLocation: flowLoc(r.CheckInRecord), CheckInRecordID: r.CheckInRecordID,
|
||||
CheckOut: r.CheckOutResult, CheckOutSupp: omitNone(r.CheckOutResultSupplement),
|
||||
CheckOutScheduled: atoiTS(r.CheckOutShiftTime), CheckOutPunchAt: flowTime(r.CheckOutRecord),
|
||||
CheckOutLocation: flowLoc(r.CheckOutRecord), CheckOutRecordID: r.CheckOutRecordID,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// parseRange is the single source of truth for the date contract, shared by
|
||||
// Validate, DryRun and Execute so the rules can't drift. --from is required;
|
||||
// --to defaults to --from (single-day query).
|
||||
func parseRange(runtime *common.RuntimeContext) (from, to int, err error) {
|
||||
fromStr := runtime.Str("from")
|
||||
if fromStr == "" {
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--from is required, e.g. --from 2026-06-01 (a YYYY-MM-DD workday)").WithParam("--from")
|
||||
}
|
||||
from, perr := parseWorkday(fromStr)
|
||||
if perr != nil {
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--from: %v", perr).WithParam("--from")
|
||||
}
|
||||
|
||||
toStr := runtime.Str("to")
|
||||
if toStr == "" {
|
||||
return from, from, nil
|
||||
}
|
||||
to, perr = parseWorkday(toStr)
|
||||
if perr != nil {
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--to: %v", perr).WithParam("--to")
|
||||
}
|
||||
if to < from {
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--to (%s) must not be earlier than --from (%s)", toStr, fromStr).WithParam("--to")
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
// queryBody fixes the boilerplate the meta API made callers supply by hand —
|
||||
// employee_type (a query param) and an always-empty user_ids — so a self query
|
||||
// never asks for it.
|
||||
func queryBody(from, to int) *userTasksQueryReq {
|
||||
return &userTasksQueryReq{
|
||||
UserIDs: []string{},
|
||||
CheckDateFrom: from,
|
||||
CheckDateTo: to,
|
||||
NeedOvertimeResult: true,
|
||||
}
|
||||
}
|
||||
|
||||
var AttendanceRecords = common.Shortcut{
|
||||
Service: "attendance",
|
||||
Command: "+records",
|
||||
Description: "Query your own attendance punch results over a workday range",
|
||||
Risk: "read",
|
||||
Scopes: []string{"attendance:task:readonly"},
|
||||
AuthTypes: []string{"user"}, // self-query needs a user token; a bot has no "self"
|
||||
Flags: []common.Flag{
|
||||
{Name: "from", Desc: "start workday YYYY-MM-DD (required)"},
|
||||
{Name: "to", Desc: "end workday YYYY-MM-DD (defaults to --from, i.e. a single day)"},
|
||||
{Name: "detail", Type: "bool", Desc: "return full detail (punch time, location, scheduled time, group/shift/employee); default returns result enums only"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Single day: lark-cli attendance +records --from 2026-06-01",
|
||||
"Date range: lark-cli attendance +records --from 2026-06-01 --to 2026-06-08",
|
||||
"For punch time / location / group detail: add --detail",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
from, to, err := parseRange(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(userTasksQueryPath).
|
||||
Params(map[string]interface{}{"employee_type": "employee_no"}).
|
||||
Body(queryBody(from, to))
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := parseRange(runtime)
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
from, to, err := parseRange(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: "POST",
|
||||
ApiPath: userTasksQueryPath,
|
||||
QueryParams: larkcore.QueryParams{"employee_type": []string{"employee_no"}},
|
||||
Body: queryBody(from, to),
|
||||
})
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.WrapInternal(err)
|
||||
}
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var resp userTasksQueryResp
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal attendance response failed").WithCause(err)
|
||||
}
|
||||
|
||||
// Default derives the compact view; --detail keeps the full rows. items[]
|
||||
// and meta.count stay consistent across both.
|
||||
details := projectDetails(resp.Data)
|
||||
var items interface{}
|
||||
if runtime.Bool("detail") {
|
||||
items = details
|
||||
} else {
|
||||
rows := make([]punchRow, 0, len(details))
|
||||
for _, d := range details {
|
||||
rows = append(rows, d.compact())
|
||||
}
|
||||
items = rows
|
||||
}
|
||||
|
||||
// prettyFn=nil → --format pretty falls back to JSON; table/csv/ndjson still render the flattened rows.
|
||||
runtime.OutFormat(map[string]interface{}{"items": items}, &output.Meta{Count: len(details)}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
397
shortcuts/attendance/attendance_records_test.go
Normal file
397
shortcuts/attendance/attendance_records_test.go
Normal file
@@ -0,0 +1,397 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package attendance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const queryURL = "/open-apis/attendance/v1/user_tasks/query"
|
||||
|
||||
func defaultConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser", DefaultAs: "user",
|
||||
}
|
||||
}
|
||||
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// okResponse builds a successful user_tasks/query envelope with the given results.
|
||||
func okResponse(results []interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"user_task_results": results},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtoiTS(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int64
|
||||
}{
|
||||
{"1609722000", 1609722000},
|
||||
{"", 0},
|
||||
{"None", 0},
|
||||
{"abc", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := atoiTS(c.in); got != c.want {
|
||||
t.Errorf("atoiTS(%q) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWorkday_Valid(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"2026-06-01", 20260601},
|
||||
{"2026-06-08", 20260608},
|
||||
{"2019-08-17", 20190817},
|
||||
{"2026-12-31", 20261231},
|
||||
{"2026-01-01", 20260101},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := parseWorkday(tt.in)
|
||||
if err != nil {
|
||||
t.Errorf("parseWorkday(%q) unexpected error: %v", tt.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("parseWorkday(%q) = %d, want %d", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWorkday_Rejects(t *testing.T) {
|
||||
bad := []string{
|
||||
"", // empty
|
||||
"2026-6-1", // not zero-padded / not canonical
|
||||
"20260601", // no separators
|
||||
"2026/06/01", // wrong separator
|
||||
"2026-13-01", // impossible month
|
||||
"2026-02-30", // impossible day
|
||||
"2026-06-01T00:00:00+08:00", // carries a time + offset (instant, not a calendar day)
|
||||
"2026-06-01 ", // trailing space
|
||||
"June 1, 2026", // free text
|
||||
}
|
||||
for _, in := range bad {
|
||||
if _, err := parseWorkday(in); err == nil {
|
||||
t.Errorf("parseWorkday(%q) = nil error, want rejection", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWorkday(t *testing.T) {
|
||||
tests := []struct {
|
||||
in int
|
||||
want string
|
||||
}{
|
||||
{20260601, "2026-06-01"},
|
||||
{20190817, "2019-08-17"},
|
||||
{20261231, "2026-12-31"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := formatWorkday(tt.in); got != tt.want {
|
||||
t.Errorf("formatWorkday(%d) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShiftTypeLabel(t *testing.T) {
|
||||
if got := shiftTypeLabel(0); got != "normal" {
|
||||
t.Errorf("shiftTypeLabel(0) = %q, want normal", got)
|
||||
}
|
||||
if got := shiftTypeLabel(1); got != "overtime" {
|
||||
t.Errorf("shiftTypeLabel(1) = %q, want overtime", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOmitNone(t *testing.T) {
|
||||
if got := omitNone("None"); got != "" {
|
||||
t.Errorf("omitNone(None) = %q, want empty", got)
|
||||
}
|
||||
if got := omitNone("Leave"); got != "Leave" {
|
||||
t.Errorf("omitNone(Leave) = %q, want Leave", got)
|
||||
}
|
||||
if got := omitNone(""); got != "" {
|
||||
t.Errorf("omitNone(\"\") = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDetails_FlattensAllFields(t *testing.T) {
|
||||
data := &userTasksQueryData{
|
||||
UserTaskResults: []userTaskResult{
|
||||
{
|
||||
EmployeeName: "test-employee", UserID: "test-user-id", GroupID: "g1", ShiftID: "s1",
|
||||
Day: 20260601,
|
||||
Records: []shiftRecord{
|
||||
{
|
||||
CheckInResult: "Normal", CheckInResultSupplement: "None",
|
||||
CheckInShiftTime: "1609722000",
|
||||
CheckInRecord: &userFlow{CheckTime: "1609722123", LocationName: "test-location"},
|
||||
CheckInRecordID: "rid-in",
|
||||
CheckOutResult: "Late", CheckOutResultSupplement: "None",
|
||||
CheckOutShiftTime: "1609754400",
|
||||
CheckOutRecord: &userFlow{CheckTime: "1609755000", LocationName: "test-location"},
|
||||
CheckOutRecordID: "rid-out",
|
||||
TaskShiftType: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := projectDetails(data)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want 1 detail row, got %d", len(got))
|
||||
}
|
||||
d := got[0]
|
||||
want := punchDetail{
|
||||
Date: "2026-06-01", ShiftType: "normal",
|
||||
EmployeeName: "test-employee", UserID: "test-user-id", GroupID: "g1", ShiftID: "s1",
|
||||
CheckIn: "Normal", CheckInScheduled: 1609722000, CheckInPunchAt: 1609722123,
|
||||
CheckInLocation: "test-location", CheckInRecordID: "rid-in",
|
||||
CheckOut: "Late", CheckOutScheduled: 1609754400, CheckOutPunchAt: 1609755000,
|
||||
CheckOutLocation: "test-location", CheckOutRecordID: "rid-out",
|
||||
}
|
||||
if !reflect.DeepEqual(d, want) {
|
||||
t.Errorf("projectDetails mismatch:\n got: %+v\nwant: %+v", d, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPunchDetail_Compact(t *testing.T) {
|
||||
d := punchDetail{
|
||||
Date: "2026-06-01", ShiftType: "overtime",
|
||||
EmployeeName: "test-employee", GroupID: "g1",
|
||||
CheckIn: "Normal", CheckInSupp: "ManagerModification", CheckInPunchAt: 123, CheckInLocation: "test-location",
|
||||
CheckOut: "Late", CheckOutPunchAt: 456,
|
||||
}
|
||||
got := d.compact()
|
||||
want := punchRow{
|
||||
Date: "2026-06-01", ShiftType: "overtime",
|
||||
CheckIn: "Normal", CheckInSupp: "ManagerModification", CheckOut: "Late",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("compact() = %+v, want %+v (must drop punch_at/location/employee/group)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDetails_EmptyAndNil(t *testing.T) {
|
||||
if got := projectDetails(nil); len(got) != 0 {
|
||||
t.Errorf("projectDetails(nil) = %+v, want empty", got)
|
||||
}
|
||||
if got := projectDetails(&userTasksQueryData{}); len(got) != 0 {
|
||||
t.Errorf("projectDetails(empty) = %+v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// +records shortcut behaviour
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRecords_MissingFrom_IsValidationError(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, AttendanceRecords, []string{"+records"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error when --from is omitted, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--from") {
|
||||
t.Errorf("error should name --from, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecords_InvalidFrom_IsValidationError(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026/06/01"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error for a malformed --from, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecords_FromAfterTo_IsValidationError(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-02", "--to", "2026-06-01"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error when --from is after --to, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecords_RejectsBotIdentity(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01", "--as", "bot"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for bot identity on a user-only shortcut, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecords_SingleDay_CollapsesToFromEqualsTo pins that omitting --to queries a
|
||||
// single workday, and that the constant fields are auto-injected into the body.
|
||||
func TestRecords_SingleDay_CollapsesToFromEqualsTo(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
stub := &httpmock.Stub{Method: "POST", URL: queryURL, Body: okResponse(nil)}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
UserIDs []string `json:"user_ids"`
|
||||
CheckDateFrom int `json:"check_date_from"`
|
||||
CheckDateTo int `json:"check_date_to"`
|
||||
NeedOvertimeResult bool `json:"need_overtime_result"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body not valid JSON: %v (%s)", err, stub.CapturedBody)
|
||||
}
|
||||
if body.CheckDateFrom != 20260601 || body.CheckDateTo != 20260601 {
|
||||
t.Errorf("single-day should set from==to==20260601, got from=%d to=%d", body.CheckDateFrom, body.CheckDateTo)
|
||||
}
|
||||
if body.UserIDs == nil || len(body.UserIDs) != 0 {
|
||||
t.Errorf("user_ids should serialise to an empty array, got %#v", body.UserIDs)
|
||||
}
|
||||
if !body.NeedOvertimeResult {
|
||||
t.Errorf("need_overtime_result should default to true")
|
||||
}
|
||||
}
|
||||
|
||||
// detailResult builds one user_task_result with a full nested punch flow,
|
||||
// including the device-fingerprint fields the projection must drop.
|
||||
func detailResult() interface{} {
|
||||
return map[string]interface{}{
|
||||
"employee_name": "test-employee", "user_id": "test-user-id", "group_id": "g1", "shift_id": "s1",
|
||||
"day": 20260601,
|
||||
"records": []interface{}{
|
||||
map[string]interface{}{
|
||||
"check_in_result": "Normal", "check_in_result_supplement": "None",
|
||||
"check_in_shift_time": "1609722000",
|
||||
"check_in_record": map[string]interface{}{
|
||||
"check_time": "1609722123", "location_name": "test-location",
|
||||
"ssid": "office-wifi", "bssid": "aa:bb:cc", "device_id": "dev-1",
|
||||
},
|
||||
"check_in_record_id": "rid-in",
|
||||
"check_out_result": "Late", "check_out_result_supplement": "None",
|
||||
"check_out_shift_time": "1609754400",
|
||||
"check_out_record": map[string]interface{}{
|
||||
"check_time": "1609755000", "location_name": "test-location",
|
||||
},
|
||||
"check_out_record_id": "rid-out",
|
||||
"task_shift_type": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecords_Default_ProjectsCompactItems(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{Method: "POST", URL: queryURL, Body: okResponse([]interface{}{detailResult()})})
|
||||
|
||||
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"check_in\": \"Normal\"") {
|
||||
t.Errorf("default should include result enum, got:\n%s", out)
|
||||
}
|
||||
for _, leaked := range []string{"check_in_punch_at", "check_in_location", "employee_name", "group_id"} {
|
||||
if strings.Contains(out, leaked) {
|
||||
t.Errorf("default (compact) must NOT include %q, got:\n%s", leaked, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecords_Detail_ProjectsFullItems(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{Method: "POST", URL: queryURL, Body: okResponse([]interface{}{detailResult()})})
|
||||
|
||||
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01", "--detail"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Items []punchDetail `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("stdout not JSON envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data.Items) != 1 {
|
||||
t.Fatalf("want 1 detail item, got %d", len(env.Data.Items))
|
||||
}
|
||||
d := env.Data.Items[0]
|
||||
if d.CheckInPunchAt != 1609722123 || d.CheckInLocation != "test-location" || d.EmployeeName != "test-employee" || d.GroupID != "g1" {
|
||||
t.Errorf("detail item missing expected fields: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecords_Detail_ExcludesFingerprintFields(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{Method: "POST", URL: queryURL, Body: okResponse([]interface{}{detailResult()})})
|
||||
|
||||
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01", "--detail"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, leaked := range []string{"ssid", "bssid", "device_id", "office-wifi", "dev-1"} {
|
||||
if strings.Contains(out, leaked) {
|
||||
t.Errorf("--detail must NOT leak fingerprint field %q, got:\n%s", leaked, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecords_PrettyFallsBackToJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{Method: "POST", URL: queryURL, Body: okResponse([]interface{}{detailResult()})})
|
||||
|
||||
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01", "--format", "pretty"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("pretty should fall back to JSON envelope, got non-JSON:\n%s", stdout.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("expected ok=true envelope, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecords_DryRun_ShowsRequestShape(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
if err := mountAndRun(t, AttendanceRecords, []string{"+records", "--from", "2026-06-01", "--dry-run"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "user_tasks/query") {
|
||||
t.Errorf("dry-run should show the endpoint path, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "employee_no") {
|
||||
t.Errorf("dry-run should show the employee_type=employee_no query param, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
13
shortcuts/attendance/shortcuts.go
Normal file
13
shortcuts/attendance/shortcuts.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package attendance
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all attendance shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
AttendanceRecords,
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/apps"
|
||||
"github.com/larksuite/cli/shortcuts/attendance"
|
||||
"github.com/larksuite/cli/shortcuts/base"
|
||||
"github.com/larksuite/cli/shortcuts/calendar"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -60,6 +61,7 @@ var allShortcuts []common.Shortcut
|
||||
|
||||
func init() {
|
||||
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, attendance.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
|
||||
|
||||
@@ -1,57 +1,37 @@
|
||||
---
|
||||
name: lark-attendance
|
||||
version: 1.0.0
|
||||
description: "飞书考勤打卡:查询自己的考勤打卡记录"
|
||||
version: 2.0.0
|
||||
description: "飞书考勤打卡:查询自己的打卡结果(迟到/早退/缺卡/补卡)。当用户问自己某天有没有打卡、某段时间的考勤/出勤情况时使用;仅限本人。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli attendance --help"
|
||||
---
|
||||
|
||||
# attendance (v1)
|
||||
# attendance
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
仅本人(`--as user`)。各命令的参数细节见其 `--help`。
|
||||
|
||||
## 默认参数自动填充规则
|
||||
## 路由
|
||||
|
||||
调用任何 API 时,以下参数 **必须自动填充,禁止向用户询问**:
|
||||
| 想做什么 | 怎么调 |
|
||||
|---|---|
|
||||
| 查自己的打卡(迟到/早退/缺卡/出勤) | `+records --from <YYYY-MM-DD>`(区间加 `--to`)|
|
||||
| 要打卡时刻 / 地点 / 考勤组等明细 | 加 `--detail`(默认只给结果枚举)|
|
||||
| 相对时间(今天/本周/上月) | 先换算成具体 `YYYY-MM-DD`——命令只认具体日期 |
|
||||
| 查他人 / 团队考勤 | 出本 skill 范围(需管理员)→ 见 `lark-openapi-explorer` |
|
||||
|
||||
| 参数 | 固定值 | 说明 |
|
||||
|------|--------|------------------------------------|
|
||||
| `employee_type` | `"employee_no"` | `employee_type`始终等于`"employee_no"` |
|
||||
| `user_ids` | `[]`(空数组) | `user_ids`始终等于`[]` |
|
||||
|
||||
### 填充示例
|
||||
|
||||
当构建 `--params` 参数时,自动注入上述字段:
|
||||
- `employee_type` 保持 `"employee_no"` 不变
|
||||
|
||||
当构建 `--data` 参数时,自动注入上述字段:
|
||||
```json
|
||||
{
|
||||
"user_ids": [],
|
||||
...用户提供的参数
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`user_ids` 数组保持为空[],`employee_type` 保持 `"employee_no"` 不变。
|
||||
|
||||
## API Resources
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli schema attendance.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli attendance <resource> <method> [flags] # 调用 API
|
||||
# 查一段时间的打卡结果
|
||||
lark-cli attendance +records --as user --from 2026-06-01 --to 2026-06-08
|
||||
|
||||
# 要明细(几点几分、在哪打的)
|
||||
lark-cli attendance +records --as user --from 2026-06-01 --detail
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### user_tasks
|
||||
|
||||
- `query` — 查询用户考勤打卡记录
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `user_tasks.query` | `attendance:task:readonly` |
|
||||
## 注意
|
||||
|
||||
- 空 `items` = 无记录(非报错)。
|
||||
- 认证 / 权限报错见 `lark-shared`。
|
||||
|
||||
Reference in New Issue
Block a user