Skill: fangwei-frank/shift-scheduler (version 1.0.0)

--- name: shift-scheduler description: > Staff shift scheduling and management assistant for retail store managers and employees. Answers shift queries, sends reminders, handles swap requests, and manages on-call routing. Use when someone asks: 我今天几点上班, 班表, 排班, 换班, 请假, 谁在值班, what's my shift, who's working today, shift swap, schedule, 今天谁当班, cover my shift, time off request, 我想换班, 排班表查询. metadata: openclaw: emoji: 📅 --- # Shift Scheduler ## Overview This skill manages staff scheduling queries and shift-related requests. It reads schedule data from the configured source and routes requests to the appropriate manager for approval. **Depends on:** Staff list + schedule data from Step 02 / Step 05 config. **Configured in:** `skills_config.shift-scheduler` --- ## Data Source Options | Source | Config Value | Setup | |--------|-------------|-------| | Google Sheets | `"google_sheets"` | Share link in config | | Excel/CSV file | `"file"` | Path to schedule file | | WeCom Calendar | `"wecom_calendar"` | WeCom API integration | | Manual input | `"manual"` | Manager pastes schedule text | **Reference:** [schedule-formats.md](references/schedule-formats.md) --- ## Query Types & Responses ### "我今天几点上班？" 1. Identify the asker (match to staff list by WeCom ID or name) 2. Look up today's shift in schedule data 3. Return: shift start/end time + location (if multi-floor/location) ``` 张三，你今天的班是： 📍 收银台 10:00 – 19:00（含1小时午休） 搭班同事：李四、王五 ``` ### "今天谁在当班？" (Manager query) ``` 今日在班人员（[date]）： 早班 (09:00-17:00)：张三、李四 晚班 (14:00-22:00)：王五、赵六 值班店长：王经理 ``` ### "我想换班" (Swap request) 1. Confirm: who wants to swap, which shift, with whom (or open request) 2. Check if target staff is available (not already scheduled) 3. Submit to manager for approval (L1) 4. Notify both parties of outcome ### "我想请假" (Time-off request) 1. Confirm dates and reason (optional) 2. Submit to manager (L2 — always needs approval) 3. Check if replacement coverage is available; flag if not 4. Track request status; notify when approved/denied ### Proactive Shift Reminders Send 12 hours before shift starts (configurable via `remind_before_hours`): ``` ⏰ 上班提醒 张三，明天 [date] 你的班是： 10:00 – 19:00（[location]） 记得准时到岗～ ``` --- ## Swap Request Flow ``` 员工申请换班 ↓ 检查被换员工排班（有无冲突） ↓ 发送 L1 确认给店长 ↓ 店长确认 → 更新排班表 → 通知双方 店长拒绝 → 通知申请人 + 给出原因 ↓ 记录换班历史（避免频繁换班模式） ``` --- ## On-Call Routing Integration The shift schedule drives escalation routing in `complaint-handler` and `inventory-query`. When an escalation fires: 1. Check current time against shift schedule 2. Route to whoever is listed as on-duty manager for this time slot 3. If no on-duty manager found: fall back to `off_hours_contact` in `permissions_config` This ensures L2/L3 alerts always go to the right person, not just a fixed manager ID. --- ## Schedule Update When manager pastes or uploads a new schedule: 1. Parse it using `scripts/parse_schedule.py` 2. Validate: no double-booking, all shifts covered, no gaps > 2 hours during open hours 3. Confirm with manager before activating 4. Archive previous schedule **Script:** `scripts/parse_schedule.py` — parses Excel/text schedule into structured JSON. **Reference:** [schedule-formats.md](references/schedule-formats.md)

path: references/schedule-formats.md
content: # Schedule Data Formats

## Supported Input Formats

### Format A: CSV/Excel
Standard tabular format. One row per shift assignment.

Required columns:
```
date        | staff_id  | staff_name | start_time | end_time | location | role
2024-07-15  | zhang_san | 张三       | 10:00      | 19:00    | 收银台   | 收银员
2024-07-15  | li_si     | 李四       | 14:00      | 22:00    | 卖场     | 导购
```

Optional columns:
- `break_start` / `break_end` — lunch/rest break
- `on_call` — boolean, whether staff is emergency on-call
- `manager_on_duty` — boolean, designates shift manager

---

### Format B: Text Table (pasted by manager)
Common when managers paste from WeChat or type manually.

```
7月15日班表：
早班（10:00-19:00）：张三、李四
晚班（14:00-22:00）：王五、赵六
值班店长：王经理（全天）
```

Parse with `scripts/parse_schedule.py --format text`.

---

### Format C: Google Sheets
Share a public-read link or service account access.
Structure must match Format A (tabular, same column names).

Config:
```json
{
  "source": "google_sheets",
  "sheet_url": "https://docs.google.com/spreadsheets/d/...",
  "sheet_name": "7月排班",
  "header_row": 1
}
```

---

### Format D: WeCom Calendar
Pull from WeCom's calendar API if org uses it for scheduling.
Each calendar event = one shift. Event title format: `[staff_name] [role]`.

---

## Structured Schedule Schema

After parsing any format, normalize to:

```json
{
  "schedule_id": "2024-07-w3",
  "period": { "start": "2024-07-15", "end": "2024-07-21" },
  "shifts": [
    {
      "shift_id": "s001",
      "date": "2024-07-15",
      "staff_id": "zhang_san",
      "staff_name": "张三",
      "start_time": "10:00",
      "end_time": "19:00",
      "break_minutes": 60,
      "location": "收银台",
      "role": "收银员",
      "on_call": false,
      "manager_on_duty": false
    }
  ],
  "manager_on_duty": {
    "2024-07-15": { "staff_id": "wang_mgr", "name": "王经理", "phone": "138..." }
  }
}
```

## Validation Rules

After parsing, validate:
- No staff member has two overlapping shifts on the same day
- At least one manager_on_duty per day the store is open
- No gaps > 2 hours during store open hours without coverage
- Minimum [N] staff per shift (configurable, default 2)

Report validation errors before activating the new schedule.


path: scripts/parse_schedule.py
content: #!/usr/bin/env python3
"""
parse_schedule.py — Parse staff shift schedules from various formats into structured JSON

Usage:
    python3 parse_schedule.py --input schedule.csv [--format csv|text|excel]
                               [--output schedule.json] [--validate]

Output: Structured schedule JSON (see schedule-formats.md)
"""

import sys
import json
import re
import argparse
from pathlib import Path
from datetime import datetime, timedelta

try:
    import pandas as pd
    HAS_PANDAS = True
except ImportError:
    HAS_PANDAS = False

# ─── CSV/Excel Parser ─────────────────────────────────────────────────────────

COLUMN_ALIASES = {
    "date": ["date", "日期", "班次日期"],
    "staff_id": ["staff_id", "工号", "员工编号", "id"],
    "staff_name": ["staff_name", "姓名", "员工姓名", "name", "名字"],
    "start_time": ["start_time", "上班时间", "开始时间", "start", "上班"],
    "end_time": ["end_time", "下班时间", "结束时间", "end", "下班"],
    "location": ["location", "岗位", "位置", "工位"],
    "role": ["role", "角色", "职位", "岗位名称"],
    "on_call": ["on_call", "值班", "备班"],
    "manager_on_duty": ["manager_on_duty", "值班店长", "当班主管"],
}

def normalize_col(name: str) -> str:
    name_strip = name.strip()
    for standard, aliases in COLUMN_ALIASES.items():
        if name_strip in aliases or name_strip.lower() in [a.lower() for a in aliases]:
            return standard
    return name_strip.lower().replace(" ", "_")


def parse_csv_excel(filepath: str) -> list:
    if not HAS_PANDAS:
        raise ImportError("pandas required: pip install pandas openpyxl")
    ext = Path(filepath).suffix.lower()
    if ext in (".xlsx", ".xls"):
        df = pd.read_excel(filepath, dtype=str)
    else:
        df = pd.read_csv(filepath, dtype=str)
    df.columns = [normalize_col(c) for c in df.columns]

    shifts = []
    for i, row in df.iterrows():
        shift = {
            "shift_id": f"s{i+1:04d}",
            "date": str(row.get("date", "")).strip(),
            "staff_id": str(row.get("staff_id", f"staff_{i+1}")).strip(),
            "staff_name": str(row.get("staff_name", "")).strip(),
            "start_time": str(row.get("start_time", "")).strip(),
            "end_time": str(row.get("end_time", "")).strip(),
            "location": str(row.get("location", "")).strip() or "门店",
            "role": str(row.get("role", "")).strip() or "员工",
            "on_call": str(row.get("on_call", "")).strip().lower() in ("true", "是", "1", "yes"),
            "manager_on_duty": str(row.get("manager_on_duty", "")).strip().lower() in ("true", "是", "1", "yes"),
        }
        # Skip empty rows
        if not shift["date"] or not shift["staff_name"]:
            continue
        shifts.append(shift)
    return shifts


# ─── Text Parser ──────────────────────────────────────────────────────────────

def parse_text_schedule(text: str) -> list:
    """Parse informal Chinese text schedule."""
    shifts = []
    lines = [l.strip() for l in text.strip().split("\n") if l.strip()]

    current_date = None
    shift_id = 1

    # Date patterns
    date_patterns = [
        r"(\d{4})[年/\-](\d{1,2})[月/\-](\d{1,2})",  # 2024年7月15日 / 2024/7/15
        r"(\d{1,2})[月/](\d{1,2})[日号]?",             # 7月15日
    ]

    # Shift time patterns
    time_pattern = re.compile(r"(\d{1,2}:\d{2})[–\-~到至](\d{1,2}:\d{2})")
    name_pattern = re.compile(r"[：:]\s*(.+)")

    for line in lines:
        # Try to detect date
        for pat in date_patterns:
            m = re.search(pat, line)
            if m:
                groups = m.groups()
                if len(groups) == 3:
                    current_date = f"{groups[0]}-{int(groups[1]):02d}-{int(groups[2]):02d}"
                elif len(groups) == 2:
                    year = datetime.now().year
                    current_date = f"{year}-{int(groups[0]):02d}-{int(groups[1]):02d}"
                break

        if not current_date:
            continue

        # Try to detect time range in line
        time_match = time_pattern.search(line)
        if not time_match:
            continue

        start_time, end_time = time_match.groups()

        # Extract names (after ：or :)
        name_match = name_pattern.search(line)
        if not name_match:
            continue

        names_str = name_match.group(1)
        # Strip leading time-like artifacts (e.g. "00-19:00）：" prefix)
        names_str = re.sub(r"^[\d:：\-–~）\)）\s]+", "", names_str).strip()
        # Split by common delimiters
        names = [n.strip() for n in re.split(r"[,，、 ]+", names_str) if n.strip()]

        # Detect if this line mentions manager
        is_manager = any(kw in line for kw in ["店长", "经理", "主管"])

        for name in names:
            # Skip annotation words
            if any(kw in name for kw in ["早班", "晚班", "全天", "班", "值班"]):
                continue
            shifts.append({
                "shift_id": f"s{shift_id:04d}",
                "date": current_date,
                "staff_id": re.sub(r"[^\w]", "_", name).lower(),
                "staff_name": name,
                "start_time": start_time,
                "end_time": end_time,
                "location": "门店",
                "role": "店长" if is_manager else "员工",
                "on_call": False,
                "manager_on_duty": is_manager,
            })
            shift_id += 1

    return shifts


# ─── Validation ───────────────────────────────────────────────────────────────

def validate_schedule(shifts: list) -> list:
    """Return list of validation warnings."""
    warnings = []

    # Check for overlapping shifts per staff per day
    from collections import defaultdict
    by_staff_day = defaultdict(list)
    for s in shifts:
        key = (s["staff_id"], s["date"])
        by_staff_day[key].append(s)

    for (staff_id, date), staff_shifts in by_staff_day.items():
        if len(staff_shifts) > 1:
            # Simple overlap check
            for i, a in enumerate(staff_shifts):
                for b in staff_shifts[i+1:]:
                    if a["start_time"] < b["end_time"] and b["start_time"] < a["end_time"]:
                        warnings.append(
                            f"⚠️ 排班冲突：{staff_shifts[0]['staff_name']} 在 {date} 有时间重叠"
                        )

    # Check for manager coverage
    by_date = defaultdict(list)
    for s in shifts:
        by_date[s["date"]].append(s)

    for date, day_shifts in by_date.items():
        has_manager = any(s.get("manager_on_duty") for s in day_shifts)
        if not has_manager:
            warnings.append(f"⚠️ {date} 没有指定值班店长")

    # Check minimum staff per day
    for date, day_shifts in by_date.items():
        if len(day_shifts) < 2:
            warnings.append(f"⚠️ {date} 在班人数不足（仅{len(day_shifts)}人）")

    return warnings


# ─── Build final schedule object ──────────────────────────────────────────────

def build_schedule(shifts: list, source_file: str) -> dict:
    if not shifts:
        return {"shifts": [], "period": {}, "manager_on_duty": {}, "meta": {"source": source_file}}

    dates = sorted(set(s["date"] for s in shifts))
    manager_map = {}
    for s in shifts:
        if s.get("manager_on_duty") and s["date"] not in manager_map:
            manager_map[s["date"]] = {
                "staff_id": s["staff_id"],
                "name": s["staff_name"],
            }

    return {
        "schedule_id": f"schedule_{dates[0]}",
        "period": {"start": dates[0], "end": dates[-1]},
        "shifts": shifts,
        "manager_on_duty": manager_map,
        "meta": {
            "source": source_file,
            "total_shifts": len(shifts),
            "total_staff": len(set(s["staff_id"] for s in shifts)),
            "days_covered": len(dates),
        }
    }


# ─── Main ─────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="Parse staff schedule into structured JSON")
    parser.add_argument("--input", required=True)
    parser.add_argument("--format", default="auto", choices=["auto", "csv", "excel", "text"])
    parser.add_argument("--output", default=None)
    parser.add_argument("--validate", action="store_true")
    args = parser.parse_args()

    input_path = Path(args.input)
    if not input_path.exists():
        print(f"Error: File not found: {input_path}", file=sys.stderr)
        sys.exit(1)

    fmt = args.format
    if fmt == "auto":
        ext = input_path.suffix.lower()
        fmt = "excel" if ext in (".xlsx", ".xls") else "csv" if ext == ".csv" else "text"

    if fmt in ("csv", "excel"):
        shifts = parse_csv_excel(str(input_path))
    else:
        raw = input_path.read_text(encoding="utf-8")
        shifts = parse_text_schedule(raw)

    schedule = build_schedule(shifts, input_path.name)

    if args.validate:
        warnings = validate_schedule(shifts)
        schedule["validation_warnings"] = warnings
        if warnings:
            for w in warnings:
                print(w, file=sys.stderr)

    output_json = json.dumps(schedule, ensure_ascii=False, indent=2)
    if args.output:
        Path(args.output).write_text(output_json, encoding="utf-8")
        print(f"✅ Parsed {len(shifts)} shifts → {args.output}", file=sys.stderr)
    else:
        print(output_json)


if __name__ == "__main__":
    main()


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