Skill: ithou/sn-work-record (version 1.4.3)

--- name: sn-work-record description: Use when handling 蜀宁 OA 工时：提交、修改、查询某天工时记录、获取项目列表，或排查验证码登录与 OA 查询认证问题。 metadata: { "openclaw": { "requires": { "bins": ["openssl"] }, "env": ["OA_ENC_KEY"] }, "author": "chengcheng" } } --- # 蜀宁 OA 工时管理 ## 快速开始 > 推荐统一入口：`/root/.agents/skills/sn-work-record/sn-work-record` > > 所有 Python 脚本现在都带运行时自检：即使误用系统 `python3` 启动，也会优先自动切换到依赖完整的解释器。也可通过环境变量 `SN_WORK_RECORD_PYTHON` 显式指定解释器路径。 ### 登录（自动执行） ```bash /root/.agents/skills/sn-work-record/sn-work-record login [--credentials PATH] [--base-url http://X.X.X.X:PORT] ``` 或： ```bash python scripts/sn_oa_login.py [--credentials PATH] [--base-url http://X.X.X.X:PORT] ``` → token 输出到 stdout（可 `| tee token.txt` 保存） ### 提交工时 ```bash /root/.agents/skills/sn-work-record/sn-work-record submit 2026-04-11 --job-desc "工时描述" ``` 或： ```bash python scripts/submit_time_entry.py 2026-04-11 --job-desc "工时描述" ``` → 内部调用 `POST /sn/timeEntry/saveOrUpdate` > 默认优先使用凭据文件中的默认项目；支持 `--save-draft` 保存草稿，并会在直接提交时自动检查是否为工作日。 ### 修改工时记录 ```bash /root/.agents/skills/sn-work-record/sn-work-record update <工时ID> --job-desc "新描述" ``` → 内部调用 `PUT /sn/timeEntry/update` ### 查询工时列表 ```bash /root/.agents/skills/sn-work-record/sn-work-record query 2026-04-10 ``` → 内部调用 `POST /sn/timeEntry/findEveryDayList` ### 获取工时详情 ```bash /root/.agents/skills/sn-work-record/sn-work-record details <工时ID> ``` → 内部调用 `GET /sn/timeEntry/details/<工时ID>` > 注意：工时提交/查询/详情/修改接口统一通过完整认证会话发起，必须同时携带 `Authorization: Bearer {token}` 和 `Admin-OA-[REDACTED_SECRET]` 两个请求头。 ### 获取项目列表 ```bash python scripts/list_projects.py [--credentials PATH] [--base-url URL] ``` 列出 OA 系统所有项目，供用户选择默认项目。 ## 当前脚本 - `sn-work-record`：统一 launcher，封装常用子命令入口 - `scripts/runtime_bootstrap.py`：运行时解释器自检/自动切换 - `scripts/sn_oa_login.py`：登录并输出 token - `scripts/list_projects.py`：查询项目列表（支持 `--json`） - `scripts/submit_time_entry.py`：提交工时或保存草稿 - `scripts/query_time_entry.py`：查询某天工时列表 - `scripts/get_time_entry_details.py`：按工时 ID 查询详情 - `scripts/update_time_entry.py`：按工时 ID 修改描述或逻辑删除 - `scripts/oa_utils.py`：公共能力（登录、认证会话、状态映射、工时提交/查询/详情/修改） ## 凭证文件格式 存放在 `memory/sn-work-record-credentials.md`： ```markdown # 蜀宁 OA 凭证 - **账号**: <手机号> - **密码**: <密码> - **Base URL**: http://<IP>:<PORT> - **默认项目ID**: <your-project-id>（可选） - **默认项目名称**: <your-project-name>（可选） ``` 支持加密存储（`.md.enc`），使用 AES-256-CBC + openssl，密钥通过 `OA_ENC_KEY` 环境变量或 `~/.openclaw/workspace/.cache/oa_enc_key` 提供。 ## fillDate 格式 提交工时时 `fillDate` 使用纯日期格式 `YYYY-MM-DD` 即可（如 `2026-04-10`），API 不要求 `(周X)` 后缀。 ## 默认配置 - **项目**：需通过 `list_projects.py` 脚本获取用户确认后设定，写入 `memory/sn-work-record-credentials.md` - **时长**：8h/天 - **状态**：`10` = 草稿，`20` = 审批中，`30` = 已审批 ## 对话输出与隐私约定 - 默认回复中**不要主动暴露**项目 ID、工时记录 ID、项目配置值等内部标识。 - 查询结果默认只返回：日期、项目名称、时长、描述、状态、填写时间、更新时间。 - 只有在用户**明确要求查看 ID**，或执行修改/详情/删除等操作**确实必须引用 ID** 时，才展示对应 ID。 - 编写示例、文档、响应模板时，优先使用占位符（如 `<your-project-id>`、`<record-id>`），不要写入真实配置。 ## 自然语言示例 | 用户说 | 执行动作 | |--------|---------| | "今天填了工时" | `python scripts/submit_time_entry.py <日期> --job-desc "..."` 提交 8h 工时（默认项目） | | "周一到周三做某个项目" | 批量提交多天 | | "把昨天工时改成xxx" | 查昨天 → `update_time_entry.py` 修改描述 | | "查一下今天的工时状态" | `python scripts/query_time_entry.py <日期>` 查询 | | "看看这条工时详情" | `python scripts/get_time_entry_details.py <工时ID>` | | "把这条工时删掉" | `python scripts/update_time_entry.py <工时ID> --delete` | ## 节假日限制 以下节假日期间**不予填写工时**（2026年官方安排）： | 节日 | 放假日期 | |------|---------| | 元旦 | 2026-01-01 ~ 2026-01-03 | | 春节 | 2026-02-15 ~ 2026-02-23 | | 清明节 | 2026-04-04 ~ 2026-04-06 | | 劳动节 | 2026-05-01 ~ 2026-05-05 | | 端午节 | 2026-06-19 ~ 2026-06-21 | | 中秋节 | 2026-09-25 ~ 2026-09-27 | | 国庆节 | 2026-10-01 ~ 2026-10-07 | 如遇节假日区间，回复：「节假日期间无需填写工时~」并拒绝提交。 ## 提交成功输出模板 ``` ✅ {日期}({星期})工时提交成功！ • 日期：{fillDate} • 项目：{projectName} • 时长：{manHour}h • 描述：{jobDesc} • 状态：审批中 🟢 ``` 修改描述成功时输出： ``` ✅ {日期} 工时描述已更新！ • 日期：{fillDate} • 新描述：{jobDesc} • 状态：审批中 🟢 ``` ## 环境要求 - Python 3 - ddddocr：`pip3 install ddddocr` - chinese_calendar：`pip3 install chinese_calendar`（用于精确判断中国工作日/节假日） - requests：`pip3 install requests` - 凭据文件：`memory/sn-work-record-credentials.md`（明文或 .enc 加密均可） > 说明：脚本会优先使用依赖完整的解释器。若环境特殊，可通过 [REDACTED_SECRET] 显式指定解释器路径。若需要单独准备虚拟环境，推荐名称优先使用 `sn_work_record_env`（也兼容 `oa_worktime_env`、`ddddocr_env`、`.venv`、`venv` 等常见命名）。 ## 首次使用 1. 运行 `python scripts/sn_oa_login.py` 完成登录 2. 运行 `python scripts/list_projects.py` 获取项目列表，选择默认项目 3. 将项目信息写入凭据文件 ## 增强说明 - 公共认证头逻辑已统一收口到 `oa_utils.py`，避免不同脚本各写一套导致认证不一致 - 项目列表脚本新增 `--json`，方便调试或二次处理 - 提交、查询、详情、修改四个工时脚本都走同一套登录与认证逻辑，减少后续维护成本 - 所有入口脚本已接入 `runtime_bootstrap.py`：若当前解释器缺少依赖，会自动切换到可用解释器再执行 - 解释器优先级支持环境变量 `SN_WORK_RECORD_PYTHON` 覆盖，也会自动探测常见虚拟环境名称 - 新增 `sn-work-record` launcher，后续优先使用它作为统一入口 - 目录说明已与实际文件对齐：提交/查询/详情/修改都已提供脚本，避免再次误判

path: references/api.md
content: # 蜀宁 OA 工时系统 - API 参考

## 系统信息

| 项目 | 值 |
|------|-----|
| baseURL | `None`（通过 `--base-url` 参数或凭据文件指定） |
| API 前缀 | /sn/* |
| 凭据文件 | memory/sn-work-record-credentials.md |
| 登录脚本 | scripts/sn_oa_login.py |
| baseURL 参数 | `--base-url` 或凭据文件中的 Base URL 字段 |

## 登录

**推荐用 `scripts/sn_oa_login.py`**，自动处理验证码识别（ddddocr + 预处理 + eval）。

手动流程：
1. `GET /sn/captchaImage` → 获取 base64 PNG + uuid
2. ddddocr 识别算术题，预处理（放大3倍灰度），清理非数字运算符后 `eval()` 求值
3. `GET /sn/passWord/encryption?passWord=<密码>` → 加密密码
4. `POST /sn/login` → `{ "username", "password", "code", "uuid" }` → 返回 `{ "code": 200, "data": { "token": "..." } }`

## 项目接口

### 获取项目下拉列表
```
POST /sn/project/timeFeeDropDownPageList
Body: {"page": 1, "size": 8000}
```
返回 `{ "code": "200", "data": { "content": [{ "id": "...", "projectBoName": "..." }, ...] } }`（注意字段名为 `projectBoName`）

---

## 工时接口

### 工作日限制

工时仅允许在**工作日**提交，使用 `chinese_calendar.is_workday()` 判断。

该函数已自动处理以下情况：
- 法定节假日（如清明、国庆）→ 非工作日
- 调休补班（周六日上班）→ 工作日

**Agent 代码检查示例**：

```python
from oa_utils import is_workday

if not is_workday("2026-04-05"):
    print("非工作日，无需填写工时~")
    return  # 拒绝提交
```

---

### 查询某天工时列表
```
POST /sn/timeEntry/findEveryDayList
Body: {"fillDate": "2026-04-10"}
Headers:
  Authorization: Bearer <token>
  Admin-OA-[REDACTED_SECRET]
  Content-Type: application/json
```
返回 `{ "code": "200", "data": [{ id, fillDate, projectName, jobDesc, state, manHour, ... }] }`

> **注意**：查询接口不能只带 Bearer token，必须同时带 `Authorization` 和 `Admin-OA-Token`，否则会返回 401。

### 获取工时详情
```
GET /sn/timeEntry/details/<工时ID>
Headers:
  Authorization: Bearer <token>
  Admin-OA-[REDACTED_SECRET]
```
返回 `{ "code": "200", "data": { id, jobDesc, state, fillDate, ... } }`

### 保存/提交工时
```
POST /sn/timeEntry/saveOrUpdate
Headers:
  Authorization: Bearer <token>
  Admin-OA-[REDACTED_SECRET]
  Content-Type: application/json
Body: {
  "timeEntryList": [{
    "fillDate": "2026-04-10",
    "projectName": "<your-project-name>",
    "projectId": "<your-project-id>",
    "projectPhase": "1",
    "workType": "5",
    "manHour": 8,
    "jobDesc": "工作描述",
    "boToPmStatus": 1,
    "fillType": "1"
  }],
  "saveOrSubmit": 1    // 1=直接提交，0=仅保存草稿
}
```
> **注意**: `fillDate` 使用纯日期格式 `YYYY-MM-DD` 即可，API 不要求 `(周X)` 后缀。
> **注意**: 提交接口与查询/详情/修改一样，也建议统一带完整认证头，避免会话不一致。

### 修改工时描述 / 逻辑删除
```
PUT /sn/timeEntry/update
Headers:
  Authorization: Bearer <token>
  Admin-OA-[REDACTED_SECRET]
Body: {"id": "<工时ID>", "jobDesc": "新描述"}
```
逻辑删除示例：
```
PUT /sn/timeEntry/update
Body: {"id": "<工时ID>", "isDeleted": 1}
```
- **方法：PUT**（不是 POST）
- 审批中(20)可直接修改，无需撤回
- 修改后状态自动变回审批中(20)
- 删除工时使用同一接口，设置 `isDeleted=1`

## 状态值

| 值 | 含义 |
|----|------|
| `10` | 草稿 |
| `20` | 审批中 |
| `30` | 已审批 |

## 工时修改工作流程

1. `POST /sn/timeEntry/findEveryDayList` → 找到目标工时 ID
2. `PUT /sn/timeEntry/update` → 修改描述或删除
3. `GET /sn/timeEntry/details/<id>` → 验证

## 推荐脚本

- 提交工时：`python scripts/submit_time_entry.py 2026-04-11 --job-desc "工时描述"`
- 保存草稿：`python scripts/submit_time_entry.py 2026-04-11 --job-desc "工时描述" --save-draft`
- 查询某天工时：`python scripts/query_time_entry.py 2026-04-10`
- 查询工时详情：`python scripts/get_time_entry_details.py <工时ID>`
- 修改描述：`python scripts/update_time_entry.py <工时ID> --job-desc "新描述"`
- 逻辑删除：`python scripts/update_time_entry.py <工时ID> --delete`

> `submit_time_entry.py` 已补齐；提交/查询/详情/修改脚本都应复用 `oa_utils.py` 中的登录与认证会话能力。

## 凭据文件格式

```markdown
# 蜀宁 OA 凭证

- **账号**: <手机号>
- **密码**: <密码>
- **Base URL**: http://<IP>:<PORT>
- **默认项目ID**: <your-project-id>（可选，首次使用后设置）
- **默认项目名称**: <your-project-name>（可选，首次使用后设置）
```

支持明文（`.md`）和加密（`.md.enc`，AES-256-CBC + openssl）两种格式。加密密钥通过环境变量 `OA_ENC_KEY` 或 `~/.openclaw/workspace/.cache/oa_enc_key` 文件提供。

## 对话输出约定

- 面向用户汇报查询结果时，默认不要主动返回 `projectId`、工时记录 `id` 等内部标识。
- 默认展示字段以业务可读信息为主：日期、项目名称、时长、描述、状态、填写时间、更新时间。
- 仅当用户明确要求查看，或后续修改/详情/删除操作必须用到时，再返回对应 ID。
- 文档示例与测试示例优先使用占位符，避免写入真实项目配置。

## 典型响应

```
查询列表: {"code":"200","data":[{"id":"...","jobDesc":"...","state":"20"}]}
修改:     {"code":"200","isOk":true,"data":{"jobDesc":"新描述"}}
提交:     {"code":"200","data":true}
```


path: scripts/get_time_entry_details.py
content: #!/usr/bin/env python3
"""根据工时 ID 查询蜀宁 OA 工时详情"""

import argparse
import json
import os
import sys

from runtime_bootstrap import ensure_runtime

ensure_runtime(["requests", "ddddocr", "PIL"])

from oa_utils import build_session, get_time_entry_details, load_credentials, login, resolve_base_url, state_to_text

DEFAULT_CREDS = os.path.expanduser("~/.openclaw/workspace/memory/sn-work-record-credentials.md")


def main():
    parser = argparse.ArgumentParser(description="根据工时 ID 查询蜀宁 OA 工时详情")
    parser.add_argument("entry_id", help="工时 ID")
    parser.add_argument("--credentials", default=DEFAULT_CREDS)
    parser.add_argument("--base-url", dest="base_url", default=None)
    parser.add_argument("--json", action="store_true", help="输出原始 JSON")
    args = parser.parse_args()

    try:
        username, password, base_url = load_credentials(args.credentials)
        base_url = resolve_base_url(base_url, args.base_url)

        print("正在登录 OA 系统...", file=sys.stderr)
        [REDACTED_SECRET], password, base_url)

        print(f"正在获取工时详情 {args.entry_id} ...", file=sys.stderr)
        session = build_session(token)
        detail, result = get_time_entry_details(session, base_url, args.entry_id)

        if args.json:
            print(json.dumps(result, ensure_ascii=False, indent=2))
            return

        if not detail:
            print("未找到工时详情")
            return

        state = str(detail.get("state", ""))
        print(f"工时详情：{args.entry_id}\n")
        print(f"日期：{detail.get('fillDate', '')}")
        print(f"项目：{detail.get('projectName', '')}")
        print(f"项目ID：{detail.get('projectId', '')}")
        print(f"时长：{detail.get('manHour', '')}h")
        print(f"描述：{detail.get('jobDesc', '')}")
        print(f"状态：{state_to_text(state)} ({state})")
        print(f"填写人：{detail.get('fillUserName', '')}")
        print(f"填写时间：{detail.get('createdTime', '')}")
        print(f"更新时间：{detail.get('updatedTime', '')}")
        print(f"是否删除：{detail.get('isDeleted', '')}")

    except FileNotFoundError as e:
        print(f"文件未找到: {e}", file=sys.stderr)
        sys.exit(1)
    except ValueError as e:
        print(f"凭据错误: {e}", file=sys.stderr)
        sys.exit(1)
    except RuntimeError as e:
        print(f"{e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"未知异常: {type(e).__name__}: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/list_projects.py
content: #!/usr/bin/env python3
"""获取蜀宁 OA 项目列表，供用户选择默认项目"""

import argparse
import os
import sys

from runtime_bootstrap import ensure_runtime

ensure_runtime(["requests", "ddddocr", "PIL"])

from oa_utils import REQ_TIMEOUT, build_session, load_credentials, login, resolve_base_url

DEFAULT_CREDS = os.path.expanduser("~/.openclaw/workspace/memory/sn-work-record-credentials.md")


def main():
    parser = argparse.ArgumentParser(description="获取蜀宁 OA 项目列表")
    parser.add_argument("--credentials", default=DEFAULT_CREDS)
    parser.add_argument("--base-url", dest="base_url", default=None)
    parser.add_argument("--json", action="store_true", help="输出原始 JSON")
    args = parser.parse_args()

    try:
        username, password, base_url, current_project_id, current_project_name = load_credentials(
            args.credentials, require_project=True
        )
        base_url = resolve_base_url(base_url, args.base_url)

        print("正在登录 OA 系统...", file=sys.stderr)
        [REDACTED_SECRET], password, base_url)

        print("正在获取项目列表...", file=sys.stderr)
        session = build_session(token)
        r = session.post(
            f"{base_url}/sn/project/timeFeeDropDownPageList",
            json={"page": 1, "size": 8000},
            timeout=REQ_TIMEOUT,
        )
        r.raise_for_status()
        result = r.json()

        if str(result.get("code")) != "200":
            print(f"获取项目列表失败: {result}", file=sys.stderr)
            sys.exit(1)

        if args.json:
            import json

            print(json.dumps(result, ensure_ascii=False, indent=2))
            return

        items = result.get("data", {}).get("content", result.get("data", {}).get("records", []))
        if not items:
            print("未找到任何项目", file=sys.stderr)
            sys.exit(1)

        print(f"\n共找到 {len(items)} 个项目：\n")
        for i, p in enumerate(items, 1):
            pid = p.get("id", "")
            pname = p.get("projectBoName", p.get("projectName", p.get("name", "")))
            is_current = " ← 当前默认" if pid == current_project_id else ""
            print(f"  {i}. [{pid}] {pname}{is_current}")

        print("\n如需设为默认项目，请告诉助手：")
        print("  默认项目ID：<id>，默认项目名称：<名称>")
        print(f"\n当前默认：{current_project_name or '未设置'} [{current_project_id or '无'}]")

    except FileNotFoundError as e:
        print(f"文件未找到: {e}", file=sys.stderr)
        sys.exit(1)
    except ValueError as e:
        print(f"凭据错误: {e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"未知异常: {type(e).__name__}: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/oa_utils.py
content: #!/usr/bin/env python3
"""蜀宁 OA 公共工具 — 凭证读取、验证码识别、登录、会话构建、工时提交/查询/详情/修改、节假日检查"""

import base64
import datetime
import io
import os
import re
import subprocess
import sys
import time

import requests

MAX_RETRIES = 3
REQ_TIMEOUT = 10
STATE_MAP = {
    "10": "草稿",
    "20": "审批中",
    "30": "已审批",
}


def is_workday(date_str):
    """检查是否为工作日（格式：YYYY-MM-DD）。

    优先使用 chinese_calendar；若当前 Python 环境未安装该依赖，
    则降级为“周一到周五视为工作日、周末视为非工作日”的保守判断，
    避免脚本因环境不一致直接崩溃。
    """
    date_obj = datetime.datetime.strptime(date_str, "%Y-%m-%d").date()

    try:
        import chinese_calendar

        return chinese_calendar.is_workday(date_obj)
    except ModuleNotFoundError:
        print(
            "[warn] 当前 Python 环境未安装 chinese_calendar，已降级为周末/工作日基础判断；如需中国法定调休的精确结果，请使用已安装该依赖的虚拟环境。",
            file=sys.stderr,
        )
        return date_obj.weekday() < 5


def decrypt_file(enc_path):
    """用 openssl aes-256-cbc 解密 .enc 文件，返回 bytes"""
    key = os.environ.get("OA_ENC_KEY")
    key_file = os.path.expanduser("~/.openclaw/workspace/.cache/oa_enc_key")
    if not key and os.path.exists(key_file):
        with open(key_file) as f:
            key = f.read().strip()
    if not key:
        raise ValueError(f"找不到解密密钥（环境变量 OA_ENC_KEY 或 {key_file}）")
    result = subprocess.run(
        ["openssl", "aes-256-cbc", "-d", "-a", "-pbkdf2", "-pass", f"pass:{key}", "-in", enc_path],
        capture_output=True,
        timeout=10,
    )
    if result.returncode != 0:
        raise ValueError(f"解密失败: {result.stderr.decode(errors='replace')}")
    return result.stdout


def read_credentials_raw(path):
    """读取凭证文件（自动处理 .enc 加密文件）"""
    path = os.path.expanduser(path)
    if path.endswith(".enc"):
        return decrypt_file(path).decode("utf-8")
    enc_path = path + ".enc"
    if not os.path.exists(path) and os.path.exists(enc_path):
        return decrypt_file(enc_path).decode("utf-8")
    with open(path, encoding="utf-8") as f:
        return f.read()


def load_credentials(path, require_project=False):
    """解析凭证文件，返回 (账号, 密码, base_url[, projectId, projectName])"""
    creds = {}
    content = read_credentials_raw(path)
    for line in content.splitlines():
        line = line.strip()
        m = re.match(r"[-*]?\s*\*+\s*(账号|密码|Base URL|登录地址|默认项目ID|默认项目名称)\s*\*+[::]：?\s*(.+)", line)
        if not m:
            m = re.match(r"[-*]?\s*(账号|密码|Base URL|登录地址|默认项目ID|默认项目名称)\s*[:：]\s*(.+)", line)
        if m:
            key, val = m.group(1).strip(), m.group(2).strip()
            if "账号" in key:
                creds["账号"] = val
            elif "密码" in key:
                creds["密码"] = val
            elif "Base URL" in key or "登录地址" in key:
                creds["base"] = val.rstrip("/login").rstrip("/")
            elif "默认项目ID" in key:
                creds["projectId"] = val
            elif "默认项目名称" in key:
                creds["projectName"] = val
    if not creds.get("账号") or not creds.get("密码"):
        raise ValueError(f"凭据文件缺少账号或密码: {path}")
    if require_project:
        return creds["账号"], creds["密码"], creds.get("base"), creds.get("projectId"), creds.get("projectName")
    return creds["账号"], creds["密码"], creds.get("base")


def resolve_base_url(base_url, override=None):
    """优先使用命令行覆盖，否则使用凭据中的 base_url。"""
    if override:
        return override.rstrip("/")
    if base_url:
        return base_url.rstrip("/")
    raise ValueError("请通过 --base-url 参数或凭据文件指定 OA 系统地址")


def solve_captcha(img_bytes):
    """ddddocr 识别算术验证码（原图 + 放大灰度 + 二值反色 三重策略）"""
    import ddddocr
    from PIL import Image, ImageOps

    ocr = ddddocr.DdddOcr(show_ad=False)

    def _try(src):
        text = ocr.classification(src)
        expr = re.sub(r"[^0-9+\-*/]", "", text)
        if not expr:
            return None
        try:
            return str(int(eval(expr)))
        except Exception:
            pass
        expr = expr.rstrip("+-*/")
        try:
            return str(int(eval(expr)))
        except Exception:
            return None

    candidates = [("原图", img_bytes)]
    try:
        img = Image.open(io.BytesIO(img_bytes))
        up = img.convert("L").resize((img.width * 3, img.height * 3), Image.LANCZOS)
        buf = io.BytesIO()
        up.save(buf, format="PNG")
        candidates.append(("放大灰度", buf.getvalue()))
        bin_img = up.point(lambda x: 255 if x > 128 else 0)
        bin_img = ImageOps.invert(bin_img)
        buf = io.BytesIO()
        bin_img.save(buf, format="PNG")
        candidates.append(("二值反色", buf.getvalue()))
    except Exception:
        pass

    for name, src in candidates:
        result = _try(src)
        if result is not None:
            print(f"  [captcha] {name} → '{ocr.classification(src)}' → {result}", file=__import__("sys").stderr)
            return result
    return None


def login(username, password, base_url):
    """自动登录 OA 系统，返回 token"""
    import sys

    session = requests.Session()
    session.headers.update({"Accept-Language": "zh-CN,zh;q=0.9", "User-Agent": "SnOAClient/1.0"})

    for attempt in range(1, MAX_RETRIES + 1):
        try:
            r = session.get(f"{base_url}/sn/captchaImage", timeout=REQ_TIMEOUT)
            r.raise_for_status()
            data = r.json()["data"]
            uuid = data["uuid"]
            img_b64 = data["img"].split(",", 1)[1] if "," in data["img"] else data["img"]
            img_bytes = base64.b64decode(img_b64)

            answer = solve_captcha(img_bytes)
            if not answer:
                print(f"  [attempt {attempt}] ddddocr 识别失败，重试", file=sys.stderr)
                time.sleep(0.5)
                continue

            r = session.get(f"{base_url}/sn/passWord/encryption", params={"passWord": password}, timeout=REQ_TIMEOUT)
            r.raise_for_status()
            encrypted = r.json()["msg"]

            r = session.post(
                f"{base_url}/sn/login",
                json={"username": username, "password": encrypted, "code": answer, "uuid": uuid},
                timeout=REQ_TIMEOUT,
            )
            r.raise_for_status()
            result = r.json()
            if str(result.get("code")) != "200":
                print(f"  [attempt {attempt}] 登录失败({result.get('code')}): {result.get('msg', '')}", file=sys.stderr)
                time.sleep(0.5)
                continue

            [REDACTED_SECRET]"data", {}).get("token")
            if not [REDACTED_SECRET]"  [attempt {attempt}] token 为空", file=sys.stderr)
                time.sleep(0.5)
                continue

            return token

        except requests.exceptions.Timeout:
            print(f"  [attempt {attempt}] 网络超时", file=sys.stderr)
        except requests.exceptions.ConnectionError as e:
            print(f"  [attempt {attempt}] 连接失败: {e}", file=sys.stderr)
        except Exception as e:
            print(f"  [attempt {attempt}] 异常 {type(e).__name__}: {e}", file=sys.stderr)

        if attempt < MAX_RETRIES:
            time.sleep(0.5)

    raise RuntimeError("登录失败（已达最大重试次数）")


def build_session(token):
    """构造带完整认证头的 OA 会话。"""
    session = requests.Session()
    session.headers.update(
        {
            "Authorization": f"Bearer {token}",
            "Admin-OA-Token": token,
            "Content-Type": "application/json",
        }
    )
    return session


def state_to_text(state):
    """把工时状态码转成人类可读文本。"""
    return STATE_MAP.get(str(state), str(state) if state not in (None, "") else "未知")


def query_time_entries(session, base_url, fill_date):
    """查询某天工时列表，返回 records。"""
    r = session.post(
        f"{base_url}/sn/timeEntry/findEveryDayList",
        json={"fillDate": fill_date},
        timeout=REQ_TIMEOUT,
    )
    r.raise_for_status()
    result = r.json()
    if str(result.get("code")) != "200":
        raise RuntimeError(f"查询工时失败: {result}")
    return result.get("data", []) or [], result


def get_time_entry_details(session, base_url, entry_id):
    """根据工时 ID 获取详情。"""
    r = session.get(f"{base_url}/sn/timeEntry/details/{entry_id}", timeout=REQ_TIMEOUT)
    r.raise_for_status()
    result = r.json()
    if str(result.get("code")) != "200":
        raise RuntimeError(f"获取工时详情失败: {result}")
    return result.get("data", {}) or {}, result


def submit_time_entry(
    session,
    base_url,
    fill_date,
    project_id,
    project_name,
    job_desc,
    man_hour=8,
    save_or_submit=1,
    project_phase="1",
    work_type="5",
    bo_to_pm_status=1,
    fill_type="1",
):
    """提交或保存工时记录。"""
    payload = {
        "timeEntryList": [
            {
                "fillDate": fill_date,
                "projectName": project_name,
                "projectId": str(project_id),
                "projectPhase": str(project_phase),
                "workType": str(work_type),
                "manHour": man_hour,
                "jobDesc": job_desc,
                "boToPmStatus": bo_to_pm_status,
                "fillType": str(fill_type),
            }
        ],
        "saveOrSubmit": save_or_submit,
    }
    r = session.post(f"{base_url}/sn/timeEntry/saveOrUpdate", json=payload, timeout=REQ_TIMEOUT)
    r.raise_for_status()
    result = r.json()
    if str(result.get("code")) != "200":
        raise RuntimeError(f"提交工时失败: {result}")
    return result


def update_time_entry(session, base_url, entry_id, job_desc=None, is_deleted=None):
    """更新工时记录，支持修改描述或逻辑删除。"""
    payload = {"id": entry_id}
    if job_desc is not None:
        payload["jobDesc"] = job_desc
    if is_deleted is not None:
        payload["isDeleted"] = is_deleted
    r = session.put(f"{base_url}/sn/timeEntry/update", json=payload, timeout=REQ_TIMEOUT)
    r.raise_for_status()
    result = r.json()
    if str(result.get("code")) != "200":
        raise RuntimeError(f"修改工时失败: {result}")
    return result


path: scripts/query_time_entry.py
content: #!/usr/bin/env python3
"""查询蜀宁 OA 某天工时列表"""

import argparse
import json
import os
import sys

from runtime_bootstrap import ensure_runtime

ensure_runtime(["requests", "ddddocr", "PIL"])

from oa_utils import build_session, load_credentials, login, query_time_entries, resolve_base_url, state_to_text

DEFAULT_CREDS = os.path.expanduser("~/.openclaw/workspace/memory/sn-work-record-credentials.md")


def main():
    parser = argparse.ArgumentParser(description="查询蜀宁 OA 某天工时列表")
    parser.add_argument("fill_date", nargs="?", help="查询日期，格式 YYYY-MM-DD")
    parser.add_argument("--fill-date", dest="fill_date_flag", help="查询日期，格式 YYYY-MM-DD")
    parser.add_argument("--credentials", default=DEFAULT_CREDS)
    parser.add_argument("--base-url", dest="base_url", default=None)
    parser.add_argument("--json", action="store_true", help="输出原始 JSON")
    args = parser.parse_args()

    fill_date = args.fill_date_flag or args.fill_date
    if not fill_date:
        parser.error("请提供查询日期，例如：2026-04-10")

    try:
        username, password, base_url = load_credentials(args.credentials)
        base_url = resolve_base_url(base_url, args.base_url)

        print("正在登录 OA 系统...", file=sys.stderr)
        [REDACTED_SECRET], password, base_url)

        print(f"正在查询 {fill_date} 的工时...", file=sys.stderr)
        session = build_session(token)
        items, result = query_time_entries(session, base_url, fill_date)

        if args.json:
            print(json.dumps(result, ensure_ascii=False, indent=2))
            return

        if not items:
            print(f"{fill_date} 暂无工时记录")
            return

        print(f"{fill_date} 共 {len(items)} 条工时记录：\n")
        for i, item in enumerate(items, 1):
            state = str(item.get("state", ""))
            print(f"{i}. 项目：{item.get('projectName', '')}")
            print(f"   时长：{item.get('manHour', '')}h")
            print(f"   描述：{item.get('jobDesc', '')}")
            print(f"   状态：{state_to_text(state)} ({state})")
            print(f"   ID：{item.get('id', '')}")
            print(f"   填写时间：{item.get('createdTime', '')}")
            print(f"   更新时间：{item.get('updatedTime', '')}")
            if i != len(items):
                print()

    except FileNotFoundError as e:
        print(f"文件未找到: {e}", file=sys.stderr)
        sys.exit(1)
    except ValueError as e:
        print(f"凭据错误: {e}", file=sys.stderr)
        sys.exit(1)
    except RuntimeError as e:
        print(f"{e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"未知异常: {type(e).__name__}: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/runtime_bootstrap.py
content: #!/usr/bin/env python3
"""sn-work-record 运行时引导：优先切换到依赖完整的 Python 解释器。"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from typing import Iterable

ENV_GUARD = "SN_WORK_RECORD_RUNTIME_BOOTSTRAPPED"
ENV_PREFERRED = "SN_WORK_RECORD_PYTHON"
DEFAULT_VENV_NAMES = ["sn_work_record_env", "oa_worktime_env", "ddddocr_env", ".venv", "venv"]


def _unique_existing(paths: Iterable[str | None]) -> list[str]:
    result: list[str] = []
    seen = set()
    for path in paths:
        if not path:
            continue
        real = os.path.realpath(os.path.expanduser(path))
        if real in seen or not os.path.exists(real):
            continue
        seen.add(real)
        result.append(real)
    return result


def _build_candidates(preferred_python: str | None = None) -> list[str]:
    workspace = os.path.expanduser("~/.openclaw/workspace")
    env_python = os.environ.get(ENV_PREFERRED)
    named_venvs = [os.path.join(workspace, name, "bin", "python") for name in DEFAULT_VENV_NAMES]
    fallback_candidates = [env_python, preferred_python, sys.executable, shutil.which("python3"), shutil.which("python")]
    return _unique_existing([env_python, preferred_python, *named_venvs, *fallback_candidates])


def _has_modules(python_executable: str, modules: list[str]) -> bool:
    code = (
        "import importlib.util, sys; "
        "mods = sys.argv[1:]; "
        "missing = [m for m in mods if importlib.util.find_spec(m) is None]; "
        "raise SystemExit(0 if not missing else 1)"
    )
    try:
        proc = subprocess.run(
            [python_executable, "-c", code, *modules],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            timeout=5,
            check=False,
        )
        return proc.returncode == 0
    except Exception:
        return False


def ensure_runtime(required_modules: list[str], preferred_python: str | None = None) -> None:
    """确保当前脚本运行在依赖完整的解释器中。"""
    if os.environ.get(ENV_GUARD) == "1":
        return

    current = os.path.realpath(sys.executable)
    candidates = _build_candidates(preferred_python)

    if _has_modules(current, required_modules):
        return

    for candidate in candidates:
        if os.path.realpath(candidate) == current:
            continue
        if _has_modules(candidate, required_modules):
            new_env = os.environ.copy()
            new_env[ENV_GUARD] = "1"
            os.execve(candidate, [candidate, *sys.argv], new_env)

    missing = ", ".join(required_modules)
    raise RuntimeError(
        "找不到可用的 Python 解释器来运行 sn-work-record；"
        f"需要依赖: {missing}；已检查: {', '.join(candidates) or '无'}；"
        f"可通过环境变量 {ENV_PREFERRED} 指定解释器路径"
    )


path: scripts/sn_oa_login.py
content: #!/usr/bin/env python3
"""蜀宁 OA 登录脚本（ddddocr 验证码 + 自动登录）

用法: python sn_oa_login.py [--credentials PATH] [--base-url URL]
退出码: 0=成功(token到stdout), 1=失败(错误到stderr)
"""

import argparse, os, sys

from runtime_bootstrap import ensure_runtime

ensure_runtime(["requests", "ddddocr", "PIL"])

DEFAULT_CREDS = os.path.expanduser("~/.openclaw/workspace/memory/sn-work-record-credentials.md")


def main():
    parser = argparse.ArgumentParser(description="蜀宁 OA 自动登录（ddddocr）")
    parser.add_argument("--credentials", default=DEFAULT_CREDS)
    parser.add_argument("--base-url", dest="base_url", default=None,
        help="OA 系统 base URL，不指定则从凭据文件读取")
    args = parser.parse_args()

    try:
        # 延迟导入，避免在 --help 时加载 ddddocr
        from oa_utils import load_credentials, login, resolve_base_url

        username, password, base_url = load_credentials(args.credentials)
        base_url = resolve_base_url(base_url, args.base_url)

        [REDACTED_SECRET], password, base_url)
        print(token, end="")  # 无换行，方便 capture
    except FileNotFoundError as e:
        print(f"❌ 文件未找到: {e}", file=sys.stderr); sys.exit(1)
    except ValueError as e:
        print(f"❌ 凭据错误: {e}", file=sys.stderr); sys.exit(1)
    except RuntimeError as e:
        print(f"❌ {e}", file=sys.stderr); sys.exit(1)
    except Exception as e:
        print(f"❌ 未知异常: {type(e).__name__}: {e}", file=sys.stderr); sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/submit_time_entry.py
content: #!/usr/bin/env python3
"""提交蜀宁 OA 工时记录，支持直接提交或保存草稿"""
import argparse
import json
import os
import sys

from runtime_bootstrap import ensure_runtime

ensure_runtime(["requests", "ddddocr", "PIL", "chinese_calendar"])

from oa_utils import (
    build_session,
    is_workday,
    load_credentials,
    login,
    resolve_base_url,
    submit_time_entry,
)

DEFAULT_CREDS = os.path.expanduser("~/.openclaw/workspace/memory/sn-work-record-credentials.md")


def main():
    parser = argparse.ArgumentParser(description="提交蜀宁 OA 工时记录")
    parser.add_argument("fill_date", nargs="?", help="填写日期，格式 YYYY-MM-DD")
    parser.add_argument("--fill-date", dest="fill_date_flag", help="填写日期，格式 YYYY-MM-DD")
    parser.add_argument("--job-desc", required=True, help="工时描述")
    parser.add_argument("--man-hour", type=float, default=8, help="工时时长，默认 8")
    parser.add_argument("--project-id", help="项目 ID；不传则尝试使用凭据中的默认项目ID")
    parser.add_argument("--project-name", help="项目名称；不传则尝试使用凭据中的默认项目名称")
    parser.add_argument("--project-phase", default="1", help="项目阶段，默认 1")
    parser.add_argument("--work-type", default="5", help="工作类型，默认 5")
    parser.add_argument("--bo-to-pm-status", type=int, default=1, help="默认 1")
    parser.add_argument("--fill-type", default="1", help="填报类型，默认 1")
    parser.add_argument("--save-draft", action="store_true", help="仅保存草稿，不直接提交")
    parser.add_argument("--credentials", default=DEFAULT_CREDS)
    parser.add_argument("--base-url", dest="base_url", default=None)
    parser.add_argument("--json", action="store_true", help="输出原始 JSON")
    args = parser.parse_args()

    fill_date = args.fill_date_flag or args.fill_date
    if not fill_date:
        parser.error("请提供填写日期，例如：2026-04-11")

    try:
        username, password, base_url, default_project_id, default_project_name = load_credentials(
            args.credentials, require_project=True
        )
        base_url = resolve_base_url(base_url, args.base_url)
        project_id = args.project_id or default_project_id
        project_name = args.project_name or default_project_name
        if not project_id or not project_name:
            raise ValueError("请提供 --project-id/--project-name，或先在凭据文件中设置默认项目")
        if not args.save_draft and not is_workday(fill_date):
            raise ValueError("节假日期间无需填写工时~")

        print("正在登录 OA 系统...", file=sys.stderr)
        [REDACTED_SECRET], password, base_url)
        session = build_session(token)
        print(f"正在提交 {fill_date} 的工时...", file=sys.stderr)
        result = submit_time_entry(
            session,
            base_url,
            fill_date=fill_date,
            project_id=project_id,
            project_name=project_name,
            job_desc=args.job_desc,
            man_hour=args.man_hour,
            save_or_submit=0 if args.save_draft else 1,
            project_phase=args.project_phase,
            work_type=args.work_type,
            bo_to_pm_status=args.bo_to_pm_status,
            fill_type=args.fill_type,
        )
        if args.json:
            print(json.dumps(result, ensure_ascii=False, indent=2))
            return

        action_text = "草稿已保存" if args.save_draft else "工时提交成功"
        state_text = "草稿" if args.save_draft else "审批中"
        print(f"{fill_date} {action_text}！\n")
        print(f"项目：{project_name}")
        print(f"项目ID：{project_id}")
        print(f"时长：{args.man_hour}h")
        print(f"描述：{args.job_desc}")
        print(f"状态：{state_text}")
    except FileNotFoundError as e:
        print(f"文件未找到: {e}", file=sys.stderr)
        sys.exit(1)
    except ValueError as e:
        print(f"凭据错误: {e}", file=sys.stderr)
        sys.exit(1)
    except RuntimeError as e:
        print(f"{e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"未知异常: {type(e).__name__}: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/update_time_entry.py
content: #!/usr/bin/env python3
"""修改蜀宁 OA 工时记录，支持按 ID 更新描述或逻辑删除"""

import argparse
import json
import os
import sys

from runtime_bootstrap import ensure_runtime

ensure_runtime(["requests", "ddddocr", "PIL"])

from oa_utils import build_session, get_time_entry_details, load_credentials, login, resolve_base_url, state_to_text, update_time_entry

DEFAULT_CREDS = os.path.expanduser("~/.openclaw/workspace/memory/sn-work-record-credentials.md")


def main():
    parser = argparse.ArgumentParser(description="修改蜀宁 OA 工时记录")
    parser.add_argument("entry_id", help="工时 ID")
    parser.add_argument("--job-desc", help="新的工时描述")
    parser.add_argument("--delete", action="store_true", help="逻辑删除工时（isDeleted=1）")
    parser.add_argument("--credentials", default=DEFAULT_CREDS)
    parser.add_argument("--base-url", dest="base_url", default=None)
    parser.add_argument("--json", action="store_true", help="输出原始 JSON")
    args = parser.parse_args()

    if args.job_desc is None and not args.delete:
        parser.error("请至少提供 --job-desc 或 --delete 之一")

    try:
        username, password, base_url = load_credentials(args.credentials)
        base_url = resolve_base_url(base_url, args.base_url)

        print("正在登录 OA 系统...", file=sys.stderr)
        [REDACTED_SECRET], password, base_url)
        session = build_session(token)

        print(f"正在修改工时 {args.entry_id} ...", file=sys.stderr)
        result = update_time_entry(
            session,
            base_url,
            args.entry_id,
            job_desc=args.job_desc,
            is_deleted=1 if args.delete else None,
        )
        detail, _ = get_time_entry_details(session, base_url, args.entry_id)

        if args.json:
            print(json.dumps({"update": result, "detail": detail}, ensure_ascii=False, indent=2))
            return

        state = str(detail.get("state", ""))
        print(f"工时修改成功：{args.entry_id}\n")
        print(f"日期：{detail.get('fillDate', '')}")
        print(f"项目：{detail.get('projectName', '')}")
        print(f"时长：{detail.get('manHour', '')}h")
        print(f"描述：{detail.get('jobDesc', '')}")
        print(f"状态：{state_to_text(state)} ({state})")
        print(f"是否删除：{detail.get('isDeleted', '')}")
        print(f"更新时间：{detail.get('updatedTime', '')}")

    except FileNotFoundError as e:
        print(f"文件未找到: {e}", file=sys.stderr)
        sys.exit(1)
    except ValueError as e:
        print(f"凭据错误: {e}", file=sys.stderr)
        sys.exit(1)
    except RuntimeError as e:
        print(f"{e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"未知异常: {type(e).__name__}: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)