312 lines
18 KiB
Python
312 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""hr-roster 花名册字段体系(F03) 后端服务
|
|
端口9280 ocai模式; 复用 appbase/sqlor/ahserver/apppublic/rbac
|
|
20个端点, 写操作审计落库 roster_op_log
|
|
"""
|
|
from __future__ import annotations
|
|
import json, re, time
|
|
from appbase import get_config
|
|
from apppublic import logger, now
|
|
from ahserver import http_router, json_response, request
|
|
from sqlor import db
|
|
|
|
APP_PORT = 9280
|
|
OP_TYPE_CREATE="create"; OP_TYPE_UPDATE="update"; OP_TYPE_DELETE="delete"
|
|
OP_TYPE_ENABLE="enable"; OP_TYPE_DISABLE="disable"; OP_TYPE_SORT="sort"; OP_TYPE_GEN_EMPNO="gen_empno"
|
|
TABLE_GROUP="roster_field_group"; TABLE_DEF="roster_field_def"; TABLE_TYPE_RULE="roster_type_rule"
|
|
TABLE_EMPNO_RULE="roster_empno_rule"; TABLE_OP_LOG="roster_op_log"
|
|
ALLOWED_FIELD_TYPES={"text","number","date","datetime","select","multi_select","attachment","bool"}
|
|
|
|
def audit(op_type, target_table, target_id, before=None, after=None, operator_id=None, operator_name=None, client_ip=None, remark=None):
|
|
try:
|
|
db.insert(TABLE_OP_LOG, {"op_type":op_type,"target_table":target_table,"target_id":target_id,
|
|
"before_json":json.dumps(before,ensure_ascii=False) if before is not None else None,
|
|
"after_json":json.dumps(after,ensure_ascii=False) if after is not None else None,
|
|
"operator_id":operator_id,"operator_name":operator_name,"client_ip":client_ip,"remark":remark,"created_at":now()})
|
|
except Exception as exc:
|
|
logger.error("audit fail: %s", exc)
|
|
|
|
def _operator():
|
|
ctx = getattr(request, "ctx", {}) or {}
|
|
return {"operator_id":ctx.get("user_id"),"operator_name":ctx.get("user_name"),"client_ip":ctx.get("client_ip")}
|
|
|
|
def _require(cond, msg):
|
|
if not cond: raise ValueError(msg)
|
|
|
|
def _valid_field_code(code):
|
|
return bool(re.match(r"^[a-zA-Z][a-zA-Z0-9_]{1,63}$", code or ""))
|
|
|
|
def _parse_options(options):
|
|
if options is None: return None
|
|
if isinstance(options, str): options = json.loads(options)
|
|
if not isinstance(options, list): raise ValueError("options 必须是数组")
|
|
for item in options:
|
|
if not isinstance(item, dict) or "label" not in item or "value" not in item:
|
|
raise ValueError("options 每项必须含 label/value")
|
|
return options
|
|
|
|
@http_router.get("/api/roster/field-groups")
|
|
def list_field_groups():
|
|
rows = db.select("SELECT * FROM roster_field_group WHERE deleted_at IS NULL ORDER BY sort_order ASC, id ASC")
|
|
return json_response({"code":0,"data":rows})
|
|
|
|
@http_router.post("/api/roster/field-groups")
|
|
def create_field_group():
|
|
body = request.json()
|
|
_require(body.get("group_code"),"group_code 必填"); _require(body.get("group_name"),"group_name 必填")
|
|
_require(_valid_field_code(body["group_code"]),"group_code 非法")
|
|
exists = db.select_one("SELECT id FROM roster_field_group WHERE group_code=%s AND deleted_at IS NULL",(body["group_code"],))
|
|
_require(not exists,"分组编码已存在")
|
|
data={"group_code":body["group_code"],"group_name":body["group_name"],"group_desc":body.get("group_desc"),
|
|
"is_builtin":int(bool(body.get("is_builtin",0))),"sort_order":int(body.get("sort_order",0)),
|
|
"is_enabled":int(bool(body.get("is_enabled",1))),"created_by":_operator()["operator_id"],"created_at":now()}
|
|
new_id=db.insert(TABLE_GROUP,data)
|
|
audit(OP_TYPE_CREATE,TABLE_GROUP,new_id,None,data,**_operator())
|
|
return json_response({"code":0,"data":{"id":new_id}})
|
|
|
|
@http_router.put("/api/roster/field-groups/<int:gid>")
|
|
def update_field_group(gid):
|
|
body=request.json()
|
|
before=db.select_one("SELECT * FROM roster_field_group WHERE id=%s AND deleted_at IS NULL",(gid,))
|
|
_require(before,"分组不存在")
|
|
data={"group_name":body.get("group_name",before["group_name"]),"group_desc":body.get("group_desc",before["group_desc"]),
|
|
"sort_order":int(body.get("sort_order",before["sort_order"])),"is_enabled":int(bool(body.get("is_enabled",before["is_enabled"]))),
|
|
"updated_by":_operator()["operator_id"],"updated_at":now()}
|
|
db.update(TABLE_GROUP,data,{"id":gid})
|
|
audit(OP_TYPE_UPDATE,TABLE_GROUP,gid,before,data,**_operator())
|
|
return json_response({"code":0,"data":{"id":gid}})
|
|
|
|
@http_router.delete("/api/roster/field-groups/<int:gid>")
|
|
def delete_field_group(gid):
|
|
before=db.select_one("SELECT * FROM roster_field_group WHERE id=%s AND deleted_at IS NULL",(gid,))
|
|
_require(before,"分组不存在"); _require(not before["is_builtin"],"内置分组不可删除")
|
|
cnt=db.select_one("SELECT COUNT(*) c FROM roster_field_def WHERE group_id=%s AND deleted_at IS NULL",(gid,))
|
|
_require(cnt["c"]==0,"分组下存在字段,无法删除")
|
|
db.update(TABLE_GROUP,{"deleted_at":now()},{"id":gid})
|
|
audit(OP_TYPE_DELETE,TABLE_GROUP,gid,before,None,**_operator())
|
|
return json_response({"code":0,"data":{"id":gid}})
|
|
|
|
@http_router.get("/api/roster/field-defs")
|
|
def list_field_defs():
|
|
group_id=request.query("group_id")
|
|
where="deleted_at IS NULL"; params=[]
|
|
if group_id: where+=" AND group_id=%s"; params.append(group_id)
|
|
rows=db.select("SELECT * FROM roster_field_def WHERE %s ORDER BY group_id ASC, sort_order ASC, id ASC"%where, params)
|
|
return json_response({"code":0,"data":rows})
|
|
|
|
@http_router.post("/api/roster/field-defs")
|
|
def create_field_def():
|
|
body=request.json()
|
|
_require(body.get("field_code"),"field_code 必填"); _require(body.get("field_name"),"field_name 必填")
|
|
_require(body.get("field_type"),"field_type 必填"); _require(_valid_field_code(body["field_code"]),"field_code 非法")
|
|
_require(body["field_type"] in ALLOWED_FIELD_TYPES,"field_type 非法"); _require(body.get("group_id"),"group_id 必填")
|
|
grp=db.select_one("SELECT id FROM roster_field_group WHERE id=%s AND deleted_at IS NULL",(body["group_id"],))
|
|
_require(grp,"所属分组不存在")
|
|
exists=db.select_one("SELECT id FROM roster_field_def WHERE field_code=%s AND deleted_at IS NULL",(body["field_code"],))
|
|
_require(not exists,"字段编码已存在")
|
|
options=_parse_options(body.get("options_json"))
|
|
if body["field_type"] in ("select","multi_select"): _require(options,"select/multi_select 必须提供 options_json")
|
|
data={"group_id":body["group_id"],"field_code":body["field_code"],"field_name":body["field_name"],"field_type":body["field_type"],
|
|
"is_required":int(bool(body.get("is_required",0))),"is_unique":int(bool(body.get("is_unique",0))),
|
|
"is_sensitive":int(bool(body.get("is_sensitive",0))),"default_value":body.get("default_value"),
|
|
"options_json":json.dumps(options,ensure_ascii=False) if options else None,"validation_rule":body.get("validation_rule"),
|
|
"placeholder":body.get("placeholder"),"help_text":body.get("help_text"),"sort_order":int(body.get("sort_order",0)),
|
|
"is_enabled":int(bool(body.get("is_enabled",1))),"created_by":_operator()["operator_id"],"created_at":now()}
|
|
new_id=db.insert(TABLE_DEF,data)
|
|
audit(OP_TYPE_CREATE,TABLE_DEF,new_id,None,data,**_operator())
|
|
return json_response({"code":0,"data":{"id":new_id}})
|
|
|
|
@http_router.put("/api/roster/field-defs/<int:fid>")
|
|
def update_field_def(fid):
|
|
body=request.json()
|
|
before=db.select_one("SELECT * FROM roster_field_def WHERE id=%s AND deleted_at IS NULL",(fid,))
|
|
_require(before,"字段不存在")
|
|
field_type=body.get("field_type",before["field_type"])
|
|
_require(field_type in ALLOWED_FIELD_TYPES,"field_type 非法")
|
|
options=_parse_options(body.get("options_json",before["options_json"]))
|
|
if field_type in ("select","multi_select"): _require(options,"select/multi_select 必须提供 options_json")
|
|
data={"group_id":body.get("group_id",before["group_id"]),"field_name":body.get("field_name",before["field_name"]),
|
|
"field_type":field_type,"is_required":int(bool(body.get("is_required",before["is_required"]))),
|
|
"is_unique":int(bool(body.get("is_unique",before["is_unique"]))),"is_sensitive":int(bool(body.get("is_sensitive",before["is_sensitive"]))),
|
|
"default_value":body.get("default_value",before["default_value"]),
|
|
"options_json":json.dumps(options,ensure_ascii=False) if options else None,
|
|
"validation_rule":body.get("validation_rule",before["validation_rule"]),"placeholder":body.get("placeholder",before["placeholder"]),
|
|
"help_text":body.get("help_text",before["help_text"]),"sort_order":int(body.get("sort_order",before["sort_order"])),
|
|
"is_enabled":int(bool(body.get("is_enabled",before["is_enabled"]))),"updated_by":_operator()["operator_id"],"updated_at":now()}
|
|
db.update(TABLE_DEF,data,{"id":fid})
|
|
audit(OP_TYPE_UPDATE,TABLE_DEF,fid,before,data,**_operator())
|
|
return json_response({"code":0,"data":{"id":fid}})
|
|
|
|
@http_router.delete("/api/roster/field-defs/<int:fid>")
|
|
def delete_field_def(fid):
|
|
before=db.select_one("SELECT * FROM roster_field_def WHERE id=%s AND deleted_at IS NULL",(fid,))
|
|
_require(before,"字段不存在")
|
|
db.update(TABLE_DEF,{"deleted_at":now()},{"id":fid})
|
|
audit(OP_TYPE_DELETE,TABLE_DEF,fid,before,None,**_operator())
|
|
return json_response({"code":0,"data":{"id":fid}})
|
|
|
|
@http_router.post("/api/roster/field-defs/sort")
|
|
def sort_field_defs():
|
|
body=request.json(); items=body.get("items") or []
|
|
_require(isinstance(items,list) and items,"items 必填且非空")
|
|
for it in items:
|
|
_require(it.get("id"),"item.id 必填")
|
|
db.update(TABLE_DEF,{"sort_order":int(it.get("sort_order",0))},{"id":it["id"]})
|
|
audit(OP_TYPE_SORT,TABLE_DEF,None,None,{"items":items},**_operator())
|
|
return json_response({"code":0,"data":{"count":len(items)}})
|
|
|
|
@http_router.post("/api/roster/field-defs/toggle")
|
|
def toggle_field_def():
|
|
body=request.json(); _require(body.get("id"),"id 必填")
|
|
before=db.select_one("SELECT * FROM roster_field_def WHERE id=%s AND deleted_at IS NULL",(body["id"],))
|
|
_require(before,"字段不存在")
|
|
enabled=int(bool(body.get("is_enabled")))
|
|
db.update(TABLE_DEF,{"is_enabled":enabled},{"id":body["id"]})
|
|
audit(OP_TYPE_ENABLE if enabled else OP_TYPE_DISABLE,TABLE_DEF,body["id"],before,{"is_enabled":enabled},**_operator())
|
|
return json_response({"code":0,"data":{"id":body["id"],"is_enabled":enabled}})
|
|
|
|
@http_router.get("/api/roster/type-rules")
|
|
def list_type_rules():
|
|
rows=db.select("SELECT * FROM roster_type_rule WHERE deleted_at IS NULL ORDER BY sort_order ASC, id ASC")
|
|
for r in rows:
|
|
r["required_field_codes"]=json.loads(r["required_field_codes"]) if r.get("required_field_codes") else []
|
|
return json_response({"code":0,"data":rows})
|
|
|
|
def _build_type_rule_data(body,before=None):
|
|
codes=body.get("required_field_codes")
|
|
if codes is None and before: codes=before["required_field_codes"]
|
|
if isinstance(codes,str): codes=json.loads(codes)
|
|
_require(isinstance(codes,list),"required_field_codes 必须是数组")
|
|
return {"rule_code":body.get("rule_code",before["rule_code"] if before else None),
|
|
"rule_name":body.get("rule_name",before["rule_name"] if before else None),
|
|
"emp_type":body.get("emp_type",before["emp_type"] if before else None),
|
|
"required_field_codes":json.dumps(codes,ensure_ascii=False),
|
|
"is_enabled":int(bool(body.get("is_enabled",before["is_enabled"] if before else 1))),
|
|
"sort_order":int(body.get("sort_order",before["sort_order"] if before else 0))}
|
|
|
|
@http_router.post("/api/roster/type-rules")
|
|
def create_type_rule():
|
|
body=request.json()
|
|
_require(body.get("rule_code"),"rule_code 必填"); _require(body.get("rule_name"),"rule_name 必填"); _require(body.get("emp_type"),"emp_type 必填")
|
|
exists=db.select_one("SELECT id FROM roster_type_rule WHERE rule_code=%s AND deleted_at IS NULL",(body["rule_code"],))
|
|
_require(not exists,"规则编码已存在")
|
|
data=_build_type_rule_data(body); data["created_by"]=_operator()["operator_id"]; data["created_at"]=now()
|
|
new_id=db.insert(TABLE_TYPE_RULE,data)
|
|
audit(OP_TYPE_CREATE,TABLE_TYPE_RULE,new_id,None,data,**_operator())
|
|
return json_response({"code":0,"data":{"id":new_id}})
|
|
|
|
@http_router.put("/api/roster/type-rules/<int:rid>")
|
|
def update_type_rule(rid):
|
|
body=request.json()
|
|
before=db.select_one("SELECT * FROM roster_type_rule WHERE id=%s AND deleted_at IS NULL",(rid,))
|
|
_require(before,"规则不存在")
|
|
data=_build_type_rule_data(body,before); data["updated_by"]=_operator()["operator_id"]; data["updated_at"]=now()
|
|
db.update(TABLE_TYPE_RULE,data,{"id":rid})
|
|
audit(OP_TYPE_UPDATE,TABLE_TYPE_RULE,rid,before,data,**_operator())
|
|
return json_response({"code":0,"data":{"id":rid}})
|
|
|
|
@http_router.delete("/api/roster/type-rules/<int:rid>")
|
|
def delete_type_rule(rid):
|
|
before=db.select_one("SELECT * FROM roster_type_rule WHERE id=%s AND deleted_at IS NULL",(rid,))
|
|
_require(before,"规则不存在")
|
|
db.update(TABLE_TYPE_RULE,{"deleted_at":now()},{"id":rid})
|
|
audit(OP_TYPE_DELETE,TABLE_TYPE_RULE,rid,before,None,**_operator())
|
|
return json_response({"code":0,"data":{"id":rid}})
|
|
|
|
@http_router.get("/api/roster/empno-rules")
|
|
def list_empno_rules():
|
|
rows=db.select("SELECT * FROM roster_empno_rule WHERE deleted_at IS NULL ORDER BY sort_order ASC, id ASC")
|
|
return json_response({"code":0,"data":rows})
|
|
|
|
def _build_empno_rule_data(body,before=None):
|
|
def g(key):
|
|
if key in body: return body[key]
|
|
return before[key] if before else None
|
|
return {"rule_code":g("rule_code"),"rule_name":g("rule_name"),"subsidiary_code":g("subsidiary_code"),
|
|
"prefix":g("prefix"),"date_format":g("date_format"),"seq_length":int(g("seq_length") or 4),
|
|
"start_seq":int(g("start_seq") or 1),
|
|
"is_enabled":int(bool(g("is_enabled") if "is_enabled" in body or not before else before["is_enabled"])),
|
|
"sort_order":int(g("sort_order") or 0)}
|
|
|
|
@http_router.post("/api/roster/empno-rules")
|
|
def create_empno_rule():
|
|
body=request.json()
|
|
_require(body.get("rule_code"),"rule_code 必填"); _require(body.get("rule_name"),"rule_name 必填")
|
|
exists=db.select_one("SELECT id FROM roster_empno_rule WHERE rule_code=%s AND deleted_at IS NULL",(body["rule_code"],))
|
|
_require(not exists,"规则编码已存在")
|
|
data=_build_empno_rule_data(body); data["created_by"]=_operator()["operator_id"]; data["created_at"]=now()
|
|
new_id=db.insert(TABLE_EMPNO_RULE,data)
|
|
audit(OP_TYPE_CREATE,TABLE_EMPNO_RULE,new_id,None,data,**_operator())
|
|
return json_response({"code":0,"data":{"id":new_id}})
|
|
|
|
@http_router.put("/api/roster/empno-rules/<int:rid>")
|
|
def update_empno_rule(rid):
|
|
body=request.json()
|
|
before=db.select_one("SELECT * FROM roster_empno_rule WHERE id=%s AND deleted_at IS NULL",(rid,))
|
|
_require(before,"规则不存在")
|
|
data=_build_empno_rule_data(body,before); data["updated_by"]=_operator()["operator_id"]; data["updated_at"]=now()
|
|
db.update(TABLE_EMPNO_RULE,data,{"id":rid})
|
|
audit(OP_TYPE_UPDATE,TABLE_EMPNO_RULE,rid,before,data,**_operator())
|
|
return json_response({"code":0,"data":{"id":rid}})
|
|
|
|
@http_router.delete("/api/roster/empno-rules/<int:rid>")
|
|
def delete_empno_rule(rid):
|
|
before=db.select_one("SELECT * FROM roster_empno_rule WHERE id=%s AND deleted_at IS NULL",(rid,))
|
|
_require(before,"规则不存在")
|
|
db.update(TABLE_EMPNO_RULE,{"deleted_at":now()},{"id":rid})
|
|
audit(OP_TYPE_DELETE,TABLE_EMPNO_RULE,rid,before,None,**_operator())
|
|
return json_response({"code":0,"data":{"id":rid}})
|
|
|
|
@http_router.post("/api/roster/empno-rules/generate")
|
|
def generate_empno():
|
|
body=request.json()
|
|
rule_code=body.get("rule_code"); subsidiary_code=body.get("subsidiary_code")
|
|
if rule_code:
|
|
rule=db.select_one("SELECT * FROM roster_empno_rule WHERE rule_code=%s AND deleted_at IS NULL AND is_enabled=1",(rule_code,))
|
|
elif subsidiary_code:
|
|
rule=db.select_one("SELECT * FROM roster_empno_rule WHERE subsidiary_code=%s AND deleted_at IS NULL AND is_enabled=1 ORDER BY sort_order ASC, id ASC LIMIT 1",(subsidiary_code,))
|
|
else:
|
|
rule=db.select_one("SELECT * FROM roster_empno_rule WHERE subsidiary_code IS NULL AND deleted_at IS NULL AND is_enabled=1 ORDER BY sort_order ASC, id ASC LIMIT 1")
|
|
_require(rule,"未找到可用的工号规则")
|
|
db.execute_scalar("UPDATE roster_empno_rule SET current_seq = current_seq + 1 WHERE id=%s AND deleted_at IS NULL",(rule["id"],))
|
|
rule_after=db.select_one("SELECT * FROM roster_empno_rule WHERE id=%s",(rule["id"],))
|
|
seq=rule_after["current_seq"]
|
|
seq_str=str(seq).zfill(int(rule["seq_length"]))
|
|
prefix=rule["prefix"] or ""
|
|
date_part=""
|
|
if rule.get("date_format"):
|
|
fmt=rule["date_format"]
|
|
if fmt.startswith("%"): date_part=time.strftime(fmt)
|
|
else: date_part=time.strftime(fmt.replace("YYYY","%Y").replace("MM","%m").replace("DD","%d"))
|
|
emp_no=prefix+date_part+seq_str
|
|
exists=db.select_one("SELECT COUNT(*) c FROM roster_empno_rule WHERE rule_code=%s",(rule["rule_code"],))
|
|
_require(exists and exists["c"]==1,"工号规则冲突")
|
|
audit(OP_TYPE_GEN_EMPNO,TABLE_EMPNO_RULE,rule["id"],None,{"emp_no":emp_no,"seq":seq},**_operator())
|
|
return json_response({"code":0,"data":{"emp_no":emp_no,"seq":seq}})
|
|
|
|
@http_router.get("/api/roster/options/<field_code>")
|
|
def get_field_options(field_code):
|
|
row=db.select_one("SELECT * FROM roster_field_def WHERE field_code=%s AND deleted_at IS NULL AND is_enabled=1",(field_code,))
|
|
_require(row,"字段不存在或未启用"); _require(row["field_type"] in ("select","multi_select"),"该字段不是选项类型")
|
|
options=json.loads(row["options_json"]) if row.get("options_json") else []
|
|
return json_response({"code":0,"data":{"field_code":field_code,"options":options}})
|
|
|
|
@http_router.get("/api/roster/ping")
|
|
def ping():
|
|
return json_response({"code":0,"data":"pong"})
|
|
|
|
@http_router.get("/")
|
|
def index():
|
|
return json_response({"code":0,"data":"hr_roster_field ok"})
|
|
|
|
def main():
|
|
cfg=get_config()
|
|
host=cfg.get("hr_roster.host","0.0.0.0")
|
|
port=cfg.get("hr_roster.port",APP_PORT)
|
|
logger.info("hr_roster_field starting on %s:%s",host,port)
|
|
from ahserver import run
|
|
run(app=None,host=host,port=port)
|
|
|
|
if __name__=="__main__":
|
|
main()
|