All checks were successful
技能自动化发布 / release (push) Successful in 15s
基于 skill-template 脚手架开发,包含: - 5 张业务表(教职工、请假政策、考勤日历、请假记录、假期余额) - 请假核算引擎(扣薪计算、审批层级判定、年假抵扣) - CLI 入口(import-employees / calculate-leave / render-html) - HTML 审批与扣薪报告生成 - 示例数据与完整测试套件(295 tests passed)
286 lines
9.9 KiB
Python
286 lines
9.9 KiB
Python
"""任务编排:鉴权、进度 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
|