Files
process-leave-attendance-de…/scripts/service/task_service.py
chenfuqiang ee2a4d9e22
All checks were successful
技能自动化发布 / release (push) Successful in 15s
feat: 初始化高校人事请假考勤技能 (process-leave-attendance-default)
基于 skill-template 脚手架开发,包含:
- 5 张业务表(教职工、请假政策、考勤日历、请假记录、假期余额)
- 请假核算引擎(扣薪计算、审批层级判定、年假抵扣)
- CLI 入口(import-employees / calculate-leave / render-html)
- HTML 审批与扣薪报告生成
- 示例数据与完整测试套件(295 tests passed)
2026-09-07 16:59:24 +08:00

286 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""任务编排:鉴权、进度 emit、请假考勤领域调度。"""
from __future__ import annotations
import json
import os
from typing import Any, Optional
from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines
from jiangchang_skill_core.activity import JobStopped, emit, finish, job_context
from db.connection import init_db
from service.employee_import_service import IMPORT_SUBDIR, ensure_builtin_samples
from service.entitlement_service import check_entitlement
from service.leave_service import calculate_leave, render_leave_report
from util.constants import LOG_LOGGER_NAME, PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION
from util.logging_config import get_skill_logger, setup_skill_logging
from util.runtime_paths import get_db_path, get_skill_data_dir, get_skill_root
def _get_task_logger():
try:
return get_skill_logger()
except RuntimeError:
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
return get_skill_logger()
def _print_json(payload: dict[str, Any]) -> None:
print(json.dumps(payload, ensure_ascii=False, indent=2))
_FINISH_RESERVED = frozenset(
{"status", "message", "skill", "error_code", "stage", "ok", "type", "schema_version"}
)
def _finish_success(message: str, payload: Optional[dict[str, Any]] = None, **extra: Any) -> None:
body: dict[str, Any] = {}
if payload:
body.update({k: v for k, v in payload.items() if k not in _FINISH_RESERVED})
body.update({k: v for k, v in extra.items() if k not in _FINISH_RESERVED})
finish(status="success", message=message, skill=SKILL_SLUG, **body)
def _parse_int(raw: Optional[str], name: str) -> int:
if raw is None or not str(raw).strip():
raise ValueError(f"缺少参数: {name}")
text = str(raw).strip()
if "{{" in text or "}}" in text:
raise ValueError(f"参数 {name} 未正确代入(收到 {text!r})。请选中行后再操作。")
try:
return int(text)
except ValueError as exc:
raise ValueError(f"参数 {name} 必须是整数,当前为 {text!r}") from exc
def _parse_optional_int(raw: Optional[str], name: str) -> Optional[int]:
if raw is None or not str(raw).strip():
return None
text = str(raw).strip()
if "{{" in text or "}}" in text:
return None
return _parse_int(raw, name)
def _check_entitlement_or_fail(log, task_type: str) -> bool:
ok, reason = check_entitlement(SKILL_SLUG)
if ok:
return True
log.warning(
"entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED",
task_type,
)
emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth")
finish(
status="failed",
message=reason,
skill=SKILL_SLUG,
error_code="ENTITLEMENT_DENIED",
stage="auth",
)
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
return False
def _parse_bool(raw: Optional[str]) -> bool:
if raw is None:
return False
text = str(raw).strip().lower()
if "{{" in text or "}}" in text:
return False
return text in ("1", "true", "yes", "是", "y")
def cmd_calculate_leave(
employee_id: Optional[str] = None,
leave_type: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
has_certificate: Optional[str] = None,
certificate_type: Optional[str] = None,
) -> int:
log = _get_task_logger()
task_type = "calculate-leave"
with job_context(skill=SKILL_SLUG):
log.info(
"task_start task_type=%s employee_id=%s leave_type=%s",
task_type,
employee_id,
leave_type,
)
emit("开始核算请假", skill=SKILL_SLUG, stage="calculate")
try:
if not _check_entitlement_or_fail(log, task_type):
return 1
eid = _parse_int(employee_id, "employee_id")
lt = (leave_type or "").strip()
if not lt or "{{" in lt:
raise ValueError("缺少参数: leave_type")
sd = (start_date or "").strip()
if not sd or "{{" in sd:
raise ValueError("缺少参数: start_date")
ed = (end_date or "").strip()
if not ed or "{{" in ed:
raise ValueError("缺少参数: end_date")
ct = (certificate_type or "").strip()
if "{{" in ct or "}}" in ct:
ct = None
payload = calculate_leave(
eid,
leave_type=lt,
start_date=sd,
end_date=ed,
has_certificate=_parse_bool(has_certificate),
certificate_type=ct or None,
)
_finish_success(str(payload.get("message") or "核算完成"), payload)
print("OK")
_print_json(payload)
return 0
except ValueError as exc:
log.warning("task_failed task_type=%s msg=%s", task_type, exc)
print(f"ERROR:{exc}")
finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="VALIDATION")
return 1
except JobStopped:
raise
except Exception:
log.exception("task_failed task_type=%s", task_type)
finish(status="failed", message="请假核算失败", skill=SKILL_SLUG, stage="calculate")
return 1
def cmd_import_employees(file_path: Optional[str] = None) -> int:
log = _get_task_logger()
task_type = "import-employees"
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=%s file=%s", task_type, file_path)
emit("导入教职工", skill=SKILL_SLUG, stage="import")
try:
if not _check_entitlement_or_fail(log, task_type):
return 1
from service.employee_import_service import import_employees_json
path = (file_path or "").strip() or "示例教职工.json"
payload = import_employees_json(path)
_finish_success(str(payload.get("message") or "教职工已导入"), payload)
print("OK")
_print_json(payload)
return 0
except ValueError as exc:
log.warning("task_failed task_type=%s msg=%s", task_type, exc)
print(f"ERROR:{exc}")
finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="VALIDATION")
return 1
except Exception:
log.exception("task_failed task_type=%s", task_type)
finish(status="failed", message="导入教职工失败", skill=SKILL_SLUG, stage="import")
return 1
def cmd_render_html(request_id: Optional[str] = None) -> int:
log = _get_task_logger()
emit("生成请假报告", skill=SKILL_SLUG, stage="report")
try:
rid = _parse_optional_int(request_id, "request_id")
payload = render_leave_report(rid)
log.info("render_html_ok path=%s", payload.get("html_path"))
print("OK")
_print_json(payload)
return 0
except ValueError as exc:
log.warning("render_html_failed msg=%s", exc)
print(f"ERROR:{exc}")
return 1
def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int:
"""兼容入口:需 employee_id(来自 target 或 input_id)。"""
log = _get_task_logger()
employee_raw = target or input_id
if not employee_raw or not str(employee_raw).strip():
msg = "缺少参数: employee_id(run 需指定教职工编号,或使用 calculate-leave)"
log.warning("task_failed task_type=run msg=%s", msg)
print(f"ERROR:{msg}")
return 1
print("ERROR:run 入口仅兼容旧调用,请使用 calculate-leave 子命令并提供完整参数。")
return 1
def cmd_health() -> int:
log = _get_task_logger()
try:
init_db()
ensure_builtin_samples(overwrite=False)
except Exception as exc: # noqa: BLE001
log.warning("health_init_db_failed msg=%s", exc)
print(f"ERROR:init_db {exc}")
return 1
runtime = collect_runtime_diagnostics(
skill_slug=SKILL_SLUG,
platform_kit_min_version=PLATFORM_KIT_MIN_VERSION,
skill_root=get_skill_root(),
)
example_path = os.path.join(get_skill_root(), ".env.example")
env_path = config.get_env_file_path() or ""
env_exists = bool(env_path and os.path.isfile(env_path))
health_status = "failed" if runtime.has_fatal_issues else "ok"
lines = [
f"{SKILL_SLUG} health: {health_status}",
*format_runtime_health_lines(runtime),
f"env_path: {env_path}",
f"env_exists: {env_exists}",
f"example_path: {os.path.abspath(example_path)}",
]
for line in lines:
print(line)
log.info("health_ok status=%s", health_status)
return 1 if runtime.has_fatal_issues else 0
def cmd_version() -> int:
print(json.dumps({"version": SKILL_VERSION, "skill": SKILL_SLUG}, ensure_ascii=False))
return 0
def cmd_config_path() -> int:
example_path = os.path.join(get_skill_root(), ".env.example")
env_path = config.get_env_file_path() or ""
data = get_skill_data_dir()
print(
json.dumps(
{
"skill": SKILL_SLUG,
"env_path": env_path,
"example_path": os.path.abspath(example_path),
"data_dir": data,
"employees_dir": f"input/{IMPORT_SUBDIR}",
},
ensure_ascii=False,
)
)
return 0
def cmd_init_db() -> int:
try:
init_db()
ensure_builtin_samples(overwrite=False)
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "skill": SKILL_SLUG, "error": str(exc)}, ensure_ascii=False))
return 1
print(
json.dumps(
{
"ok": True,
"skill": SKILL_SLUG,
"db_path": get_db_path(),
"employees_dir": f"input/{IMPORT_SUBDIR}",
},
ensure_ascii=False,
)
)
return 0