# -*- 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 # ---------- 1. 字段分组 roster_field_group ---------- @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/") 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/") 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}}) # ---------- 2. 字段定义 roster_field_def ---------- @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/") 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/") 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}}) # ---------- 3. 员工类型规则 roster_type_rule ---------- @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/") 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/") 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}}) # ---------- 4. 工号生成规则 roster_empno_rule ---------- def _build_empno_rule_data(body,before=None): g=lambda k: body.get(k, before[k] if before and k in 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),"current_seq":int(g("current_seq") or 0), "is_enabled":int(bool(g("is_enabled") if g("is_enabled") is not None else 1)), "sort_order":int(g("sort_order") or 0)} @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}) @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 必填") _require(_valid_field_code(body["rule_code"]),"rule_code 非法") 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,"规则编码已存在") if body.get("subsidiary_code"): dup=db.select_one("SELECT id FROM roster_empno_rule WHERE subsidiary_code=%s AND deleted_at IS NULL AND is_enabled=1",(body["subsidiary_code"],)) _require(not dup,"该子公司已存在启用规则") 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/") 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/") 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() or {} count=max(1,int(body.get("count",1))) rule_id=body.get("rule_id"); subsidiary=body.get("subsidiary_code") if rule_id: rule=db.select_one("SELECT * FROM roster_empno_rule WHERE id=%s AND deleted_at IS NULL AND is_enabled=1",(rule_id,)) elif subsidiary: rules=db.select("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",(subsidiary,)) _require(len(rules)==1,"子公司工号规则冲突: 命中%d条启用规则"%len(rules)) rule=rules[0] 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,"无可用工号规则") start=int(rule["current_seq"])+1 db.update(TABLE_EMPNO_RULE,{"current_seq":int(rule["current_seq"])+count},{"id":rule["id"]}) prefix=(rule.get("prefix") or "")+(rule.get("subsidiary_code") or "") datepart=time.strftime(rule.get("date_format") or "%Y%m") if rule.get("date_format") else "" seqlen=int(rule.get("seq_length") or 4) empnos=[prefix+datepart+str(i).zfill(seqlen) for i in range(start,start+count)] audit(OP_TYPE_GEN_EMPNO,TABLE_EMPNO_RULE,rule["id"], {"current_seq":rule["current_seq"]},{"current_seq":int(rule["current_seq"])+count,"emp_nos":empnos},**_operator()) return json_response({"code":0,"data":{"rule_code":rule["rule_code"],"emp_nos":empnos}}) # ---------- 5. options 下拉联动 ---------- @http_router.get("/api/roster/options/") def get_options(field_code): row=db.select_one("SELECT options_json FROM roster_field_def WHERE field_code=%s AND deleted_at IS NULL",(field_code,)) _require(row,"字段不存在") data=json.loads(row["options_json"]) if row.get("options_json") else [] return json_response({"code":0,"data":data})