diff --git a/apps/hr_org.py b/apps/hr_org.py index ef46e2e..3a0620e 100644 --- a/apps/hr_org.py +++ b/apps/hr_org.py @@ -1 +1,942 @@ -# hr-org 后端(23 端点,见 dev-notes-T03.md) \ No newline at end of file +# -*- coding: utf-8 -*- +""" +hr-org 组织管理模块后端实现 +================================= +模块: hr-org (组织管理 F01 + 合同公司) +端口: 9280 +API 前缀: /hr-org/api/{name}.dspy + +实现 23 个端点: + 1 org_unit.list 组织分页/条件查询(叠加数据范围) + 2 org_unit.get 组织详情 + 3 org_unit.save 组织新增/修改(含生效时间) + 4 org_unit.delete 组织删除(有下级/员工则拒绝) + 5 org_unit.deactivate 组织停用(级联停用子树) + 6 org_unit.move 组织移动(子树+员工联动) + 7 org_unit.tree 组织树(扁平 -> 树) + 8 org_unit.effective 组织生效时间维护 + 9 org_field_def.list 组织自定义字段定义查询 + 10 org_field_def.get 组织自定义字段定义详情 + 11 org_field_def.save 组织自定义字段定义新增/修改 + 12 org_field_def.delete 组织自定义字段定义删除 + 13 org_field_value.list 组织自定义字段值(EAV)查询 + 14 org_field_value.get 组织自定义字段值详情 + 15 org_field_value.save 组织自定义字段值新增/修改 + 16 org_field_value.delete 组织自定义字段值删除 + 17 org_unit_change.list 组织时间轴变更记录查询 + 18 org_unit_change.get 组织时间轴变更记录详情 + 19 org_unit_change.save 组织时间轴变更记录写入 + 20 org_contract_company.list 合同公司查询 + 21 org_contract_company.get 合同公司详情 + 22 org_contract_company.save 合同公司新增/修改 + 23 org_contract_company.delete 合同公司删除 + +另有模块级端点(不计入 23): + - org_import.import Excel 批量导入(≥200 行验证) + - org_tree.view 按日期 as_of 查看历史组织架构 + - org_chart.view 按日期 as_of 查看组织架构图 + 导出图片 + +依赖基础模块: sqlor(数据访问)、rbac(权限/数据范围)、ahserver(HTTP 框架)、 +appbase(配置)、apppublic(工具)。本模块不重复造轮子, 通过 DataStore 接口 +对接 sqlor; 未接入真实 sqlor 时可用 standalone 模式(selftest 用 sqlite3)运行。 +""" +from __future__ import annotations + +import json +import uuid +from datetime import date, datetime + +# --------------------------------------------------------------------------- +# 基础模块导入(存在则用, 缺失则降级为 standalone 供自测/单测) +# --------------------------------------------------------------------------- +try: # pragma: no cover - 真实部署环境 + from appPublic.sqlor import Sqlor # noqa: F401 + _HAS_SQLOR = True +except Exception: # pragma: no cover + _HAS_SQLOR = False + +try: # pragma: no cover + from appPublic import jsonutil # noqa: F401 +except Exception: # pragma: no cover + jsonutil = None + +# --------------------------------------------------------------------------- +# 常量 +# --------------------------------------------------------------------------- +MODULE = "hr-org" +API_PREFIX = "/hr-org/api" +TABLE_ORG_UNIT = "org_unit" +TABLE_ORG_FIELD_DEF = "org_field_def" +TABLE_ORG_FIELD_VALUE = "org_field_value" +TABLE_ORG_UNIT_CHANGE = "org_unit_change" +TABLE_ORG_CONTRACT_COMPANY = "org_contract_company" + +# org_unit_change 变更类型字典 +CHANGE_TYPES = ("create", "delete", "split", "merge", "modify") + + +# --------------------------------------------------------------------------- +# 工具函数 +# --------------------------------------------------------------------------- +def new_id(prefix: str = "id") -> str: + """生成 32 位以内主键。""" + return "%s_%s" % (prefix, uuid.uuid4().hex[:24]) + + +def now() -> str: + """当前时间字符串(YYYY-MM-DD HH:MM:SS)。""" + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def today() -> str: + return date.today().isoformat() + + +def _dt(v): + """宽松地把值转成 date, 无法解析返回 None。""" + if v in (None, ""): + return None + if isinstance(v, datetime): + return v.date() + if isinstance(v, date): + return v + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y%m%d"): + try: + return datetime.strptime(str(v)[:10], fmt).date() + except Exception: + continue + return None + + +def _ok(data=None, message="ok"): + return {"code": 0, "message": message, "data": data} + + +def _err(message, code=1): + return {"code": code, "message": message, "data": None} + + +# --------------------------------------------------------------------------- +# DataStore 抽象: 对接 sqlor; standalone 模式用 sqlite3 实现 +# --------------------------------------------------------------------------- +class DataStore: + """数据访问接口。真实部署时用 sqlor 实现; 自测用 sqlite3 实现。""" + + def query(self, sql: str, params=None): + raise NotImplementedError + + def query_one(self, sql: str, params=None): + rows = self.query(sql, params) + return rows[0] if rows else None + + def execute(self, sql: str, params=None): + raise NotImplementedError + + def insert(self, table: str, data: dict): + cols = list(data.keys()) + ph = ", ".join(["?"] * len(cols)) + sql = "INSERT INTO %s (%s) VALUES (%s)" % ( + table, ", ".join(cols), ph) + self.execute(sql, list(data.values())) + + def update(self, table: str, data: dict, where: str, where_params=None): + sets = ", ".join(["%s = ?" % c for c in data.keys()]) + sql = "UPDATE %s SET %s WHERE %s" % (table, sets, where) + self.execute(sql, list(data.values()) + list(where_params or [])) + + +# 全局 store 引用, 由 register() 注入 +_STORE: "DataStore | None" = None + + +def register(store_or_app) -> None: + """注册数据访问层。 + + 支持两种形态: + * 传入 DataStore 实例(selftest / standalone) + * 传入 Sage app 对象, 内部从其上下文取出 sqlor + """ + global _STORE + if isinstance(store_or_app, DataStore): + _STORE = store_or_app + return + # 尝试从 app 上下文取 sqlor + ctx = getattr(store_or_app, "ctx", None) or getattr(store_or_app, "context", None) + if ctx is None: + _STORE = store_or_app + return + sqlor = getattr(ctx, "sqlor", None) + if sqlor is not None and not isinstance(sqlor, DataStore): + _STORE = _SqlorStore(sqlor) + else: + _STORE = sqlor + + +class _SqlorStore(DataStore): + """把真实 sqlor 适配到 DataStore 接口。""" + + def __init__(self, sqlor): + self._sqlor = sqlor + + def query(self, sql, params=None): + return self._sqlor.query(sql, params) + + def execute(self, sql, params=None): + return self._sqlor.execute(sql, params) + + +def _store() -> DataStore: + if _STORE is None: + raise RuntimeError("hr-org DataStore 未注册, 请先调用 register()") + return _STORE + + +# --------------------------------------------------------------------------- +# 审计 & 数据范围(写操作走审计, 查询叠加数据范围) +# --------------------------------------------------------------------------- +def _audit(action: str, entity: str, entity_id, before=None, after=None, + operator=None) -> None: + """写操作审计日志(写入 sys_audit_log, 失败不阻断业务)。""" + try: + _store().insert("sys_audit_log", { + "id": new_id("audit"), + "module": MODULE, + "action": action, + "entity": entity, + "entity_id": entity_id, + "before_data": json.dumps(before, ensure_ascii=False, default=str) + if before is not None else None, + "after_data": json.dumps(after, ensure_ascii=False, default=str) + if after is not None else None, + "operator": operator, + "created_at": now(), + }) + except Exception: + # 审计表在纯自测环境可能不存在, 忽略即可 + pass + + +def _apply_data_scope(sql: str, where: str, params, operator) -> tuple: + """查询叠加数据范围。委托 rbac 计算数据范围条件后拼入 where。 + + 此处提供一个可扩展钩子: 若接入 rbac, 由 rbac.data_scope() 返回附加条件; + 未接入时原样返回(不缩小范围)。 + """ + try: + import rbac # noqa: F401 (基础模块) + scoped = rbac.data_scope(MODULE, operator) + if scoped: + where = "(%s) AND (%s)" % (where, scoped) if where else scoped + except Exception: + pass + if where: + sql = sql + (" WHERE " + where if " WHERE " not in sql.upper() else " AND " + where) + return sql, params + + +# --------------------------------------------------------------------------- +# 纯业务逻辑(可独立单测) +# --------------------------------------------------------------------------- +def build_tree(rows, id_field="id", parent_field="parent_id"): + """把扁平组织列表构建为树。""" + nodes = {r[id_field]: dict(r, children=[]) for r in rows} + roots = [] + for r in rows: + node = nodes[r[id_field]] + pid = r.get(parent_field) + if pid and pid != r[id_field] and pid in nodes: + nodes[pid]["children"].append(node) + else: + roots.append(node) + + def _sort(n): + n["children"].sort(key=lambda c: (c.get("sort_no") or 0, c.get("org_code") or "")) + for c in n["children"]: + _sort(c) + + for rt in roots: + _sort(rt) + return roots + + +def filter_orgs_as_of(rows, as_of): + """按日期 as_of 过滤历史组织(生效日期 <= as_of 且 失效日期为空或 > as_of)。""" + cut = _dt(as_of) or date.today() + out = [] + for r in rows: + eff = _dt(r.get("effective_date")) + exp = _dt(r.get("expire_date")) + if eff is not None and eff > cut: + continue + if exp is not None and exp <= cut: + continue + out.append(r) + return out + + +def collect_descendants(rows, org_id, id_field="id", parent_field="parent_id"): + """收集某组织及其全部子孙组织 id。""" + children = {r.get(parent_field): [] for r in rows} + for r in rows: + children.setdefault(r.get(parent_field), []).append(r[id_field]) + seen = set() + stack = [org_id] + while stack: + cur = stack.pop() + if cur in seen: + continue + seen.add(cur) + for c in children.get(cur, []): + stack.append(c) + return seen + + +def find_cycle(rows, org_id, new_parent_id, id_field="id", parent_field="parent_id"): + """若把 org_id 移动到 new_parent_id 会形成环则返回 True。""" + parent_map = {r[id_field]: r.get(parent_field) for r in rows} + if new_parent_id == org_id: + return True + cur = new_parent_id + seen = set() + while cur: + if cur == org_id: + return True + if cur in seen: + break + seen.add(cur) + cur = parent_map.get(cur) + return False + + +def validate_import_rows(rows): + """Excel 导入数据校验。返回 (errors, valid_rows)。 + + rows: [{org_code, org_name, parent_code, org_type, effective_date, ...}] + """ + errors = [] + if not rows: + errors.append("导入数据为空, 请至少提供一行组织数据") + return errors, [] + valid = [] + seen_codes = set() + for idx, r in enumerate(rows, start=2): # 第 1 行为表头 + line = "第 %d 行" % idx + code = (r.get("org_code") or "").strip() if isinstance(r, dict) else "" + name = (r.get("org_name") or "").strip() if isinstance(r, dict) else "" + if not code: + errors.append("%s: org_code 组织编码必填" % line) + if not name: + errors.append("%s: org_name 组织名称必填" % line) + if code and code in seen_codes: + errors.append("%s: org_code=%s 在导入数据中重复" % (line, code)) + if code: + seen_codes.add(code) + eff = _dt(r.get("effective_date")) if isinstance(r, dict) else None + exp = _dt(r.get("expire_date")) if isinstance(r, dict) else None + if eff and exp and exp <= eff: + errors.append("%s: 失效日期不能早于生效日期" % line) + if not errors or errors[-1] != "%s: org_code 组织编码必填" % line: + pass + valid.append(r) + return errors, valid + + +# --------------------------------------------------------------------------- +# 通用 CRUD 工具 +# --------------------------------------------------------------------------- +def _page(rows, page, page_size): + page = max(1, int(page or 1)) + page_size = max(1, int(page_size or 20)) + total = len(rows) + start = (page - 1) * page_size + return { + "list": rows[start:start + page_size], + "total": total, + "page": page, + "page_size": page_size, + } + + +def _clean(row, table_fields): + """只保留表字段, 去掉多余键。table_fields 为字段名集合。""" + return {k: v for k, v in row.items() if k in table_fields} + + +# --------------------------------------------------------------------------- +# 1-8 org_unit 端点 +# --------------------------------------------------------------------------- +_ORG_UNIT_FIELDS = { + "id", "org_code", "org_name", "parent_id", "org_type", "leader_id", + "effective_date", "expire_date", "status", "sort_no", "remark", + "created_by", "updated_by", "created_at", "updated_at", +} + + +def org_unit_list(store: DataStore, params, operator=None): + where, p = [], [] + if params.get("org_code"): + where.append("org_code LIKE ?") + p.append("%" + params["org_code"] + "%") + if params.get("org_name"): + where.append("org_name LIKE ?") + p.append("%" + params["org_name"] + "%") + if params.get("parent_id"): + where.append("parent_id = ?") + p.append(params["parent_id"]) + if params.get("status"): + where.append("status = ?") + p.append(params["status"]) + sql = "SELECT * FROM org_unit" + sql, p = _apply_data_scope(sql, " AND ".join(where), p, operator) + sql += " ORDER BY sort_no ASC, org_code ASC" + rows = store.query(sql, p) + return _ok(_page(rows, params.get("page"), params.get("page_size"))) + + +def org_unit_get(store: DataStore, params, operator=None): + row = store.query_one("SELECT * FROM org_unit WHERE id = ?", [params.get("id")]) + if not row: + return _err("组织不存在") + return _ok(row) + + +def org_unit_save(store: DataStore, params, operator=None): + data = _clean(params, _ORG_UNIT_FIELDS) + if not data.get("org_code") or not data.get("org_name"): + return _err("org_code 与 org_name 必填") + if not data.get("id"): + # 新增 + data["id"] = new_id("org") + data["status"] = data.get("status") or "active" + data["created_by"] = operator + data["created_at"] = now() + data["updated_at"] = now() + # 编码唯一性校验 + dup = store.query_one("SELECT id FROM org_unit WHERE org_code = ?", [data["org_code"]]) + if dup: + return _err("组织编码 %s 已存在" % data["org_code"]) + store.insert(TABLE_ORG_UNIT, data) + _record_change(store, "create", data["id"], None, data, operator) + _audit("create", "org_unit", data["id"], None, data, operator) + return _ok(data) + # 修改 + old = store.query_one("SELECT * FROM org_unit WHERE id = ?", [data["id"]]) + if not old: + return _err("组织不存在") + data["updated_by"] = operator + data["updated_at"] = now() + store.update(TABLE_ORG_UNIT, data, "id = ?", [data["id"]]) + _record_change(store, "modify", data["id"], old, data, operator) + _audit("modify", "org_unit", data["id"], old, data, operator) + return _ok(data) + + +def org_unit_delete(store: DataStore, params, operator=None): + oid = params.get("id") + old = store.query_one("SELECT * FROM org_unit WHERE id = ?", [oid]) + if not old: + return _err("组织不存在") + kids = store.query("SELECT id FROM org_unit WHERE parent_id = ?", [oid]) + if kids: + return _err("存在下级组织, 不能删除(请先删除或移动下级)") + try: + emps = store.query( + "SELECT id FROM roster_employee WHERE org_id = ?", [oid]) + if emps: + return _err("组织下存在员工, 不能删除") + except Exception: + pass + store.execute("DELETE FROM org_unit WHERE id = ?", [oid]) + _record_change(store, "delete", oid, old, None, operator) + _audit("delete", "org_unit", oid, old, None, operator) + return _ok({"id": oid}) + + +def org_unit_deactivate(store: DataStore, params, operator=None): + oid = params.get("id") + rows = store.query("SELECT * FROM org_unit") + if not any(r["id"] == oid for r in rows): + return _err("组织不存在") + ids = collect_descendants(rows, oid) + for each in sorted(ids): + store.update(TABLE_ORG_UNIT, {"status": "inactive", "updated_at": now(), + "updated_by": operator}, "id = ?", [each]) + _audit("deactivate", "org_unit", oid, None, {"ids": sorted(ids)}, operator) + return _ok({"deactivated": sorted(ids)}) + + +def org_unit_move(store: DataStore, params, operator=None): + oid = params.get("id") + new_parent = params.get("parent_id") or None + rows = store.query("SELECT * FROM org_unit") + if not any(r["id"] == oid for r in rows): + return _err("组织不存在") + if new_parent and not any(r["id"] == new_parent for r in rows): + return _err("目标上级组织不存在") + if find_cycle(rows, oid, new_parent): + return _err("不能移动到自身或其子孙组织下") + old = next(r for r in rows if r["id"] == oid) + if old.get("parent_id") == new_parent: + return _ok({"moved": [oid]}) + + subtree = collect_descendants(rows, oid) + store.update(TABLE_ORG_UNIT, {"parent_id": new_parent, "updated_at": now(), + "updated_by": operator}, "id = ?", [oid]) + # 员工联动: 子树内员工的组织路径同步更新(存在相关字段则更新, 否则忽略) + for sid in subtree: + try: + store.execute( + "UPDATE roster_employee SET org_path = ? WHERE org_id = ?", + [new_parent or "", sid]) + except Exception: + pass + _record_change(store, "modify", oid, + {"parent_id": old.get("parent_id")}, + {"parent_id": new_parent, "subtree": sorted(subtree)}, operator) + _audit("move", "org_unit", oid, + {"parent_id": old.get("parent_id")}, {"parent_id": new_parent}, operator) + return _ok({"moved": sorted(subtree), "new_parent": new_parent}) + + +def org_unit_tree(store: DataStore, params, operator=None): + rows = store.query("SELECT * FROM org_unit ORDER BY sort_no ASC, org_code ASC") + if params.get("as_of"): + rows = filter_orgs_as_of(rows, params["as_of"]) + if params.get("only_active"): + rows = [r for r in rows if r.get("status") != "inactive"] + return _ok(build_tree(rows)) + + +def org_unit_effective(store: DataStore, params, operator=None): + oid = params.get("id") + old = store.query_one("SELECT * FROM org_unit WHERE id = ?", [oid]) + if not old: + return _err("组织不存在") + eff = _dt(params.get("effective_date")) + exp = _dt(params.get("expire_date")) + if eff and exp and exp <= eff: + return _err("失效日期不能早于生效日期") + data = {"effective_date": eff.isoformat() if eff else old.get("effective_date"), + "expire_date": exp.isoformat() if exp else (params.get("expire_date") is None and old.get("expire_date")) or None, + "updated_at": now(), "updated_by": operator} + if params.get("expire_date") is None: + data["expire_date"] = None + store.update(TABLE_ORG_UNIT, data, "id = ?", [oid]) + _record_change(store, "modify", oid, old, data, operator) + _audit("effective", "org_unit", oid, old, data, operator) + return _ok(data) + + +# --------------------------------------------------------------------------- +# 9-12 org_field_def (组织自定义字段定义) +# --------------------------------------------------------------------------- +_ORG_FIELD_DEF_FIELDS = { + "id", "field_code", "field_name", "field_type", "entity_type", "options", + "required", "sort_no", "status", "created_by", "updated_by", + "created_at", "updated_at", +} + + +def org_field_def_list(store: DataStore, params, operator=None): + rows = store.query("SELECT * FROM org_field_def ORDER BY sort_no ASC") + return _ok(_page(rows, params.get("page"), params.get("page_size"))) + + +def org_field_def_get(store: DataStore, params, operator=None): + row = store.query_one("SELECT * FROM org_field_def WHERE id = ?", [params.get("id")]) + return _ok(row) if row else _err("字段定义不存在") + + +def org_field_def_save(store: DataStore, params, operator=None): + data = _clean(params, _ORG_FIELD_DEF_FIELDS) + if not data.get("field_code") or not data.get("field_name"): + return _err("field_code 与 field_name 必填") + if not data.get("id"): + data["id"] = new_id("ofd") + data["status"] = data.get("status") or "active" + data["created_at"] = now() + data["updated_at"] = now() + store.insert(TABLE_ORG_FIELD_DEF, data) + _audit("create", "org_field_def", data["id"], None, data, operator) + else: + old = store.query_one("SELECT * FROM org_field_def WHERE id = ?", [data["id"]]) + if not old: + return _err("字段定义不存在") + data["updated_at"] = now() + store.update(TABLE_ORG_FIELD_DEF, data, "id = ?", [data["id"]]) + _audit("modify", "org_field_def", data["id"], old, data, operator) + return _ok(data) + + +def org_field_def_delete(store: DataStore, params, operator=None): + fid = params.get("id") + old = store.query_one("SELECT * FROM org_field_def WHERE id = ?", [fid]) + if not old: + return _err("字段定义不存在") + store.execute("DELETE FROM org_field_def WHERE id = ?", [fid]) + _audit("delete", "org_field_def", fid, old, None, operator) + return _ok({"id": fid}) + + +# --------------------------------------------------------------------------- +# 13-16 org_field_value (组织自定义字段值, EAV) +# --------------------------------------------------------------------------- +_ORG_FIELD_VALUE_FIELDS = { + "id", "org_id", "field_def_id", "field_value", "created_by", + "updated_by", "created_at", "updated_at", +} + + +def org_field_value_list(store: DataStore, params, operator=None): + where, p = [], [] + if params.get("org_id"): + where.append("org_id = ?") + p.append(params["org_id"]) + if params.get("field_def_id"): + where.append("field_def_id = ?") + p.append(params["field_def_id"]) + sql = "SELECT * FROM org_field_value" + if where: + sql += " WHERE " + " AND ".join(where) + rows = store.query(sql, p) + return _ok(_page(rows, params.get("page"), params.get("page_size"))) + + +def org_field_value_get(store: DataStore, params, operator=None): + row = store.query_one("SELECT * FROM org_field_value WHERE id = ?", [params.get("id")]) + return _ok(row) if row else _err("字段值不存在") + + +def org_field_value_save(store: DataStore, params, operator=None): + # 缺参校验(验收点) + if not params.get("org_id"): + return _err("org_id 缺参: 必须指定所属组织") + if not params.get("field_def_id"): + return _err("field_def_id 缺参: 必须指定字段定义") + data = _clean(params, _ORG_FIELD_VALUE_FIELDS) + # 外键存在性校验 + org = store.query_one("SELECT id FROM org_unit WHERE id = ?", [data["org_id"]]) + if not org: + return _err("组织 %s 不存在" % data["org_id"]) + fd = store.query_one("SELECT id FROM org_field_def WHERE id = ?", [data["field_def_id"]]) + if not fd: + return _err("字段定义 %s 不存在" % data["field_def_id"]) + if not data.get("id"): + data["id"] = new_id("ofv") + data["created_at"] = now() + data["updated_at"] = now() + store.insert(TABLE_ORG_FIELD_VALUE, data) + _audit("create", "org_field_value", data["id"], None, data, operator) + else: + old = store.query_one("SELECT * FROM org_field_value WHERE id = ?", [data["id"]]) + if not old: + return _err("字段值不存在") + data["updated_at"] = now() + store.update(TABLE_ORG_FIELD_VALUE, data, "id = ?", [data["id"]]) + _audit("modify", "org_field_value", data["id"], old, data, operator) + return _ok(data) + + +def org_field_value_delete(store: DataStore, params, operator=None): + vid = params.get("id") + old = store.query_one("SELECT * FROM org_field_value WHERE id = ?", [vid]) + if not old: + return _err("字段值不存在") + store.execute("DELETE FROM org_field_value WHERE id = ?", [vid]) + _audit("delete", "org_field_value", vid, old, None, operator) + return _ok({"id": vid}) + + +# --------------------------------------------------------------------------- +# 17-19 org_unit_change (时间轴变更记录) +# --------------------------------------------------------------------------- +def _record_change(store: DataStore, change_type, org_id, before, after, operator): + """统一写时间轴变更记录(供 org_unit 各写操作调用)。""" + try: + store.insert(TABLE_ORG_UNIT_CHANGE, { + "id": new_id("ouc"), + "org_id": org_id, + "change_type": change_type, + "change_date": today(), + "before_data": json.dumps(before, ensure_ascii=False, default=str) + if before is not None else None, + "after_data": json.dumps(after, ensure_ascii=False, default=str) + if after is not None else None, + "operator": operator, + "created_at": now(), + }) + except Exception: + pass + + +def org_unit_change_list(store: DataStore, params, operator=None): + where, p = [], [] + if params.get("org_id"): + where.append("org_id = ?") + p.append(params["org_id"]) + if params.get("change_type"): + where.append("change_type = ?") + p.append(params["change_type"]) + if params.get("from_date"): + where.append("change_date >= ?") + p.append(params["from_date"]) + if params.get("to_date"): + where.append("change_date <= ?") + p.append(params["to_date"]) + sql = "SELECT * FROM org_unit_change" + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY change_date DESC, created_at DESC" + rows = store.query(sql, p) + return _ok(_page(rows, params.get("page"), params.get("page_size"))) + + +def org_unit_change_get(store: DataStore, params, operator=None): + row = store.query_one("SELECT * FROM org_unit_change WHERE id = ?", [params.get("id")]) + return _ok(row) if row else _err("变更记录不存在") + + +def org_unit_change_save(store: DataStore, params, operator=None): + ct = params.get("change_type") + if ct not in CHANGE_TYPES: + return _err("change_type 非法, 允许: %s" % ", ".join(CHANGE_TYPES)) + if not params.get("org_id"): + return _err("org_id 缺参") + rec = { + "id": new_id("ouc"), + "org_id": params["org_id"], + "change_type": ct, + "change_date": params.get("change_date") or today(), + "before_data": params.get("before_data"), + "after_data": params.get("after_data"), + "operator": operator, + "created_at": now(), + } + store.insert(TABLE_ORG_UNIT_CHANGE, rec) + _audit("create", "org_unit_change", rec["id"], None, rec, operator) + return _ok(rec) + + +# --------------------------------------------------------------------------- +# 20-23 org_contract_company (合同公司) +# --------------------------------------------------------------------------- +_ORG_CONTRACT_COMPANY_FIELDS = { + "id", "company_code", "company_name", "credit_code", "legal_person", + "contact_info", "status", "created_by", "updated_by", "created_at", + "updated_at", +} + + +def org_contract_company_list(store: DataStore, params, operator=None): + where, p = [], [] + if params.get("company_code"): + where.append("company_code LIKE ?") + p.append("%" + params["company_code"] + "%") + if params.get("company_name"): + where.append("company_name LIKE ?") + p.append("%" + params["company_name"] + "%") + if params.get("status"): + where.append("status = ?") + p.append(params["status"]) + sql = "SELECT * FROM org_contract_company" + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY company_code ASC" + rows = store.query(sql, p) + return _ok(_page(rows, params.get("page"), params.get("page_size"))) + + +def org_contract_company_get(store: DataStore, params, operator=None): + row = store.query_one("SELECT * FROM org_contract_company WHERE id = ?", [params.get("id")]) + return _ok(row) if row else _err("合同公司不存在") + + +def _valid_credit_code(code): + """统一社会信用代码基本校验: 18 位, 数字或大写字母。""" + if not code: + return True + if len(code) != 18: + return False + return all(c.isdigit() or ("A" <= c <= "Z") for c in code) + + +def org_contract_company_save(store: DataStore, params, operator=None): + data = _clean(params, _ORG_CONTRACT_COMPANY_FIELDS) + if not data.get("company_code") or not data.get("company_name"): + return _err("company_code 与 company_name 必填") + if data.get("credit_code") and not _valid_credit_code(data["credit_code"]): + return _err("统一社会信用代码须为 18 位数字或大写字母") + if not data.get("id"): + data["id"] = new_id("occ") + data["status"] = data.get("status") or "active" + data["created_at"] = now() + data["updated_at"] = now() + dup = store.query_one( + "SELECT id FROM org_contract_company WHERE company_code = ?", + [data["company_code"]]) + if dup: + return _err("公司编码 %s 已存在" % data["company_code"]) + store.insert(TABLE_ORG_CONTRACT_COMPANY, data) + _audit("create", "org_contract_company", data["id"], None, data, operator) + else: + old = store.query_one( + "SELECT * FROM org_contract_company WHERE id = ?", [data["id"]]) + if not old: + return _err("合同公司不存在") + data["updated_at"] = now() + store.update(TABLE_ORG_CONTRACT_COMPANY, data, "id = ?", [data["id"]]) + _audit("modify", "org_contract_company", data["id"], old, data, operator) + return _ok(data) + + +def org_contract_company_delete(store: DataStore, params, operator=None): + cid = params.get("id") + old = store.query_one("SELECT * FROM org_contract_company WHERE id = ?", [cid]) + if not old: + return _err("合同公司不存在") + store.execute("DELETE FROM org_contract_company WHERE id = ?", [cid]) + _audit("delete", "org_contract_company", cid, old, None, operator) + return _ok({"id": cid}) + + +# --------------------------------------------------------------------------- +# 模块级端点: org_import / org_tree / org_chart +# --------------------------------------------------------------------------- +def org_import_import(store: DataStore, params, operator=None): + """Excel 批量导入(≥200 行验证)。""" + rows = params.get("rows") + if not isinstance(rows, list): + return _err("rows 参数必须为数组") + errors, valid = validate_import_rows(rows) + if errors: + return _err("; ".join(errors)) + if len(valid) > 200 and not params.get("confirmed"): + # ≥200 行需二次确认(防误操作) + return {"code": 2, "message": "导入数据超过 200 行, 请确认(confirmed=true)后重试", + "data": {"rows": len(valid)}} + ok_ids = [] + for r in valid: + r = dict(r) + r["id"] = new_id("org") + r["status"] = r.get("status") or "active" + r["created_at"] = now() + r["updated_at"] = now() + r["created_by"] = operator + r.pop("parent_code", None) + dup = store.query_one("SELECT id FROM org_unit WHERE org_code = ?", [r["org_code"]]) + if dup: + continue + store.insert(TABLE_ORG_UNIT, r) + _record_change(store, "create", r["id"], None, r, operator) + ok_ids.append(r["id"]) + _audit("import", "org_unit", None, None, {"imported": ok_ids}, operator) + return _ok({"imported": len(ok_ids), "ids": ok_ids}) + + +def org_tree_view(store: DataStore, params, operator=None): + rows = store.query("SELECT * FROM org_unit ORDER BY sort_no ASC, org_code ASC") + rows = filter_orgs_as_of(rows, params.get("as_of")) + if params.get("only_active"): + rows = [r for r in rows if r.get("status") != "inactive"] + tree = build_tree(rows) + return _ok({"as_of": params.get("as_of") or today(), "tree": tree}) + + +def org_chart_view(store: DataStore, params, operator=None): + rows = store.query("SELECT * FROM org_unit ORDER BY sort_no ASC, org_code ASC") + rows = filter_orgs_as_of(rows, params.get("as_of")) + tree = build_tree(rows) + export = params.get("export_image") in (True, "true", "1", 1) + payload = {"as_of": params.get("as_of") or today(), "chart": tree} + if export: + # 导出图片: 返回图数据(前端/渲染服务据此生成 PNG) + payload["image"] = { + "type": "org_chart", + "data": tree, + "format": params.get("format") or "png", + } + return _ok(payload) + + +# --------------------------------------------------------------------------- +# 端点注册表 +# --------------------------------------------------------------------------- +_HANDLERS = { + "org_unit.list": org_unit_list, + "org_unit.get": org_unit_get, + "org_unit.save": org_unit_save, + "org_unit.delete": org_unit_delete, + "org_unit.deactivate": org_unit_deactivate, + "org_unit.move": org_unit_move, + "org_unit.tree": org_unit_tree, + "org_unit.effective": org_unit_effective, + + "org_field_def.list": org_field_def_list, + "org_field_def.get": org_field_def_get, + "org_field_def.save": org_field_def_save, + "org_field_def.delete": org_field_def_delete, + + "org_field_value.list": org_field_value_list, + "org_field_value.get": org_field_value_get, + "org_field_value.save": org_field_value_save, + "org_field_value.delete": org_field_value_delete, + + "org_unit_change.list": org_unit_change_list, + "org_unit_change.get": org_unit_change_get, + "org_unit_change.save": org_unit_change_save, + + "org_contract_company.list": org_contract_company_list, + "org_contract_company.get": org_contract_company_get, + "org_contract_company.save": org_contract_company_save, + "org_contract_company.delete": org_contract_company_delete, + + "org_import.import": org_import_import, + "org_tree.view": org_tree_view, + "org_chart.view": org_chart_view, +} + +# 23 个核心端点(验收清单) +CORE_ENDPOINTS = [k for k in _HANDLERS if k in { + "org_unit.list", "org_unit.get", "org_unit.save", "org_unit.delete", + "org_unit.deactivate", "org_unit.move", "org_unit.tree", "org_unit.effective", + "org_field_def.list", "org_field_def.get", "org_field_def.save", + "org_field_def.delete", + "org_field_value.list", "org_field_value.get", "org_field_value.save", + "org_field_value.delete", + "org_unit_change.list", "org_unit_change.get", "org_unit_change.save", + "org_contract_company.list", "org_contract_company.get", + "org_contract_company.save", "org_contract_company.delete", +}] + + +def endpoints(): + """返回全部端点名(含 3 个模块级端点)。""" + return sorted(_HANDLERS.keys()) + + +def core_endpoints(): + return sorted(CORE_ENDPOINTS) + + +def dispatch(name: str, params=None, operator=None): + """按端点名分发。params 为请求参数 dict。""" + handler = _HANDLERS.get(name) + if handler is None: + return _err("未知端点: %s" % name) + try: + return handler(_store(), params or {}, operator) + except RuntimeError as e: + return _err(str(e)) + + +# --------------------------------------------------------------------------- +# 真实部署接入: ahserver 注册路由 +# for name, fn in _HANDLERS.items(): +# ahserver.route("%s/%s.dspy" % (API_PREFIX, name), fn) +# --------------------------------------------------------------------------- +def bind_ahserver(ahserver): + """把全部端点注册到 ahserver。ahserver 提供 route(path, handler)。""" + for name in _HANDLERS: + path = "%s/%s.dspy" % (API_PREFIX, name) + ahserver.route(path, lambda params, n=name, o=None: dispatch(n, params, o)) + return list(_HANDLERS.keys()) diff --git a/apps/hr_org_selftest.py b/apps/hr_org_selftest.py index 2d6b915..da09942 100644 --- a/apps/hr_org_selftest.py +++ b/apps/hr_org_selftest.py @@ -1 +1,239 @@ -# hr-org 自测脚本 \ No newline at end of file +# -*- coding: utf-8 -*- +""" +hr-org 组织管理模块自测脚本 +================================= +覆盖: + * 模块导入 + * 23 端点注册 + * org_unit CRUD / 树构建 / 停用 / 移动 + * org_field_def / org_field_value (EAV, 缺参校验) + * org_unit_change 时间轴 + * org_import 空数据 & ≥200 行校验 + * 合同公司 CRUD(含统一社会信用代码校验) + +standalone 模式: 用 sqlite3 内存库实现 DataStore, 不依赖 sqlor。 +""" +from __future__ import annotations + +import json +import sqlite3 +import sys +import traceback + + +# 确保能 import 到 apps 包 +sys.path.insert(0, __file__.rsplit("/apps/", 1)[0]) + +import hr_org # noqa: E402 + + +class SqliteStore(hr_org.DataStore): + """内存 sqlite3 实现 DataStore。""" + + def __init__(self): + self.conn = sqlite3.connect(":memory:") + self.conn.row_factory = sqlite3.Row + self._create_schema() + + def _create_schema(self): + cur = self.conn.cursor() + cur.executescript(""" + CREATE TABLE org_unit ( + id TEXT PRIMARY KEY, org_code TEXT, org_name TEXT, parent_id TEXT, + org_type TEXT, leader_id TEXT, effective_date TEXT, expire_date TEXT, + status TEXT, sort_no INTEGER, remark TEXT, created_by TEXT, + updated_by TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE org_field_def ( + id TEXT PRIMARY KEY, field_code TEXT, field_name TEXT, field_type TEXT, + entity_type TEXT, options TEXT, required INTEGER, sort_no INTEGER, + status TEXT, created_by TEXT, updated_by TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE org_field_value ( + id TEXT PRIMARY KEY, org_id TEXT, field_def_id TEXT, field_value TEXT, + created_by TEXT, updated_by TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE org_unit_change ( + id TEXT PRIMARY KEY, org_id TEXT, change_type TEXT, change_date TEXT, + before_data TEXT, after_data TEXT, operator TEXT, created_at TEXT + ); + CREATE TABLE org_contract_company ( + id TEXT PRIMARY KEY, company_code TEXT, company_name TEXT, credit_code TEXT, + legal_person TEXT, contact_info TEXT, status TEXT, created_by TEXT, + updated_by TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE roster_employee ( + id TEXT PRIMARY KEY, org_id TEXT, org_path TEXT + ); + CREATE TABLE sys_audit_log ( + id TEXT PRIMARY KEY, module TEXT, action TEXT, entity TEXT, + entity_id TEXT, before_data TEXT, after_data TEXT, operator TEXT, + created_at TEXT + ); + """) + self.conn.commit() + + def query(self, sql, params=None): + cur = self.conn.cursor() + cur.execute(sql, params or []) + rows = cur.fetchall() + return [dict(r) for r in rows] + + def execute(self, sql, params=None): + cur = self.conn.cursor() + cur.execute(sql, params or []) + self.conn.commit() + return cur.rowcount + + +PASS = 0 +FAIL = 0 + + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print("[PASS] %s" % name) + else: + FAIL += 1 + print("[FAIL] %s %s" % (name, detail)) + + +def main(): + store = SqliteStore() + hr_org.register(store) + op = "selftest_user" + + # 1. 模块导入 + check("模块导入 hr_org", hr_org is not None) + + # 2. 23 端点注册 + core = hr_org.core_endpoints() + check("23 核心端点注册", len(core) == 23, + "实际 %d 个" % len(core)) + check("全部端点含模块级端点", len(hr_org.endpoints()) == 26, + "实际 %d 个" % len(hr_org.endpoints())) + + # 3. org_unit 新增 + r = hr_org.org_unit_save(store, { + "org_code": "ORG001", "org_name": "总部", "sort_no": 1}, op) + check("org_unit 新增", r["code"] == 0, str(r)) + root_id = r["data"]["id"] + r2 = hr_org.org_unit_save(store, { + "org_code": "ORG002", "org_name": "研发部", "parent_id": root_id, + "sort_no": 2}, op) + check("org_unit 新增子组织", r2["code"] == 0, str(r2)) + child_id = r2["data"]["id"] + + # 编码唯一 + r = hr_org.org_unit_save(store, {"org_code": "ORG001", "org_name": "x"}, op) + check("org_code 唯一性校验", r["code"] != 0, str(r)) + + # 4. 树构建 + tree = hr_org.org_unit_tree(store, {}, op) + check("组织树构建", tree["code"] == 0 and len(tree["data"]) == 1 + and len(tree["data"][0]["children"]) == 1, str(tree)) + + # 5. 移动 & 循环校验 + r = hr_org.org_unit_move(store, {"id": root_id, "parent_id": child_id}, op) + check("移动环校验拒绝", r["code"] != 0, str(r)) + grand = hr_org.org_unit_save(store, { + "org_code": "ORG003", "org_name": "后端组", "parent_id": child_id, + "sort_no": 3}, op) + grand_id = grand["data"]["id"] + r = hr_org.org_unit_move(store, {"id": grand_id, "parent_id": root_id}, op) + check("组织移动(子树联动)", r["code"] == 0, str(r)) + + # 6. 删除有下级的组织 + r = hr_org.org_unit_delete(store, {"id": child_id}, op) + check("删除有下级组织被拒", r["code"] != 0, str(r)) + + # 7. 停用级联 + r = hr_org.org_unit_deactivate(store, {"id": root_id}, op) + check("停用级联子树", r["code"] == 0 and len(r["data"]["deactivated"]) >= 2, str(r)) + + # 8. 生效时间 + r = hr_org.org_unit_effective(store, { + "id": root_id, "effective_date": "2024-01-01", "expire_date": "2030-01-01"}, op) + check("生效时间维护", r["code"] == 0, str(r)) + r = hr_org.org_unit_effective(store, { + "id": root_id, "effective_date": "2025-01-01", "expire_date": "2020-01-01"}, op) + check("生效/失效日期校验", r["code"] != 0, str(r)) + + # 9. org_field_def + r = hr_org.org_field_def_save(store, { + "field_code": "office_area", "field_name": "办公面积", "field_type": "number"}, op) + check("字段定义新增", r["code"] == 0, str(r)) + fd_id = r["data"]["id"] + check("字段定义查询", hr_org.org_field_def_list(store, {}, op)["code"] == 0) + check("字段定义详情", hr_org.org_field_def_get(store, {"id": fd_id}, op)["code"] == 0) + + # 10. org_field_value 缺参校验 + r = hr_org.org_field_value_save(store, {"field_value": "100"}, op) + check("org_field_value 缺 org_id 校验", r["code"] != 0 and "org_id" in r["message"], str(r)) + r = hr_org.org_field_value_save(store, {"org_id": root_id}, op) + check("org_field_value 缺 field_def_id 校验", r["code"] != 0 and "field_def_id" in r["message"], str(r)) + r = hr_org.org_field_value_save(store, { + "org_id": root_id, "field_def_id": fd_id, "field_value": "100"}, op) + check("org_field_value 正常写入", r["code"] == 0, str(r)) + + # 11. org_unit_change 时间轴 + changes = hr_org.org_unit_change_list(store, {"org_id": root_id}, op) + check("时间轴记录生成(create/modify/effective)", + changes["code"] == 0 and changes["data"]["total"] >= 2, str(changes)) + r = hr_org.org_unit_change_save(store, { + "org_id": root_id, "change_type": "split", + "change_date": "2024-06-01"}, op) + check("时间轴手动记录", r["code"] == 0, str(r)) + r = hr_org.org_unit_change_save(store, { + "org_id": root_id, "change_type": "bad_type"}, op) + check("时间轴非法类型拒绝", r["code"] != 0, str(r)) + + # 12. org_import 空数据 + r = hr_org.org_import_import(store, {"rows": []}, op) + check("导入空数据校验", r["code"] != 0, str(r)) + + # 13. org_import ≥200 行确认 + big = [{"org_code": "IMP%03d" % i, "org_name": "导入组织%d" % i} + for i in range(205)] + r = hr_org.org_import_import(store, {"rows": big}, op) + check("≥200 行需二次确认", r["code"] == 2, str(r)) + r = hr_org.org_import_import(store, {"rows": big, "confirmed": True}, op) + check("≥200 行确认后导入", r["code"] == 0, str(r)) + + # 14. 合同公司 CRUD + r = hr_org.org_contract_company_save(store, { + "company_code": "C001", "company_name": "测试合同公司", + "credit_code": "91330100MA27XJ1234"}, op) + check("合同公司新增", r["code"] == 0, str(r)) + cc_id = r["data"]["id"] + r = hr_org.org_contract_company_save(store, { + "company_code": "C002", "company_name": "坏信用代码", + "credit_code": "bad"}, op) + check("统一社会信用代码校验", r["code"] != 0, str(r)) + r = hr_org.org_contract_company_get(store, {"id": cc_id}, op) + check("合同公司详情", r["code"] == 0, str(r)) + r = hr_org.org_contract_company_delete(store, {"id": cc_id}, op) + check("合同公司删除", r["code"] == 0, str(r)) + + # 15. org_tree / org_chart as_of + r = hr_org.org_tree_view(store, {"as_of": "2024-06-01"}, op) + check("org_tree as_of 历史架构", r["code"] == 0, str(r)) + r = hr_org.org_chart_view(store, {"as_of": "2024-06-01", "export_image": True}, op) + check("org_chart 导出图片数据", r["code"] == 0 and "image" in r["data"], str(r)) + + # 16. dispatch 兜底 + check("dispatch 未知端点", hr_org.dispatch("nope", {}).get("code") != 0) + + print("\n===== 自测结果: PASS=%d FAIL=%d =====" % (PASS, FAIL)) + if FAIL: + sys.exit(1) + + +if __name__ == "__main__": + try: + main() + except Exception: + traceback.print_exc() + sys.exit(2) diff --git a/apps/hr_roster_field.py b/apps/hr_roster_field.py index d1bf1e6..3b7cbe4 100644 --- a/apps/hr_roster_field.py +++ b/apps/hr_roster_field.py @@ -1,572 +1,311 @@ # -*- coding: utf-8 -*- +"""hr-roster 花名册字段体系(F03) 后端服务 +端口9280 ocai模式; 复用 appbase/sqlor/ahserver/apppublic/rbac +20个端点, 写操作审计落库 roster_op_log """ -hr-roster 花名册字段体系 (F03) 后端服务 -======================================== -- 端口 9280 (ocai 模式) -- 数据访问: sqlor -- 权限: rbac -- 写操作审计: roster_op_log 落库 - -端点清单 (18 个): - GET /api/roster/field-groups 字段分组列表 - POST /api/roster/field-groups 新增字段分组 - PUT /api/roster/field-groups/ 更新字段分组 - DELETE /api/roster/field-groups/ 删除字段分组 - GET /api/roster/field-defs 字段定义列表(支持分组筛选) - POST /api/roster/field-defs 新增字段定义 - PUT /api/roster/field-defs/ 更新字段定义 - DELETE /api/roster/field-defs/ 删除字段定义 - POST /api/roster/field-defs/sort 字段拖拽排序(批量更新sort_order) - POST /api/roster/field-defs/toggle 字段启停 - GET /api/roster/type-rules 员工类型规则列表 - POST /api/roster/type-rules 新增员工类型规则 - PUT /api/roster/type-rules/ 更新员工类型规则 - DELETE /api/roster/type-rules/ 删除员工类型规则 - GET /api/roster/empno-rules 工号规则列表 - POST /api/roster/empno-rules 新增工号规则 - PUT /api/roster/empno-rules/ 更新工号规则 - DELETE /api/roster/empno-rules/ 删除工号规则 - POST /api/roster/empno-rules/generate 生成工号(冲突校验) - GET /api/roster/options/ 字段选项端点(下拉联动) -""" - 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 -import json -import re -import time -from datetime import datetime - -# 基础模块引用 (已存在, 直接使用) -from appbase import get_config # 配置 -from apppublic import logger, now # 工具 -from ahserver import http_router, json_response, request # HTTP 框架 -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" +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"} -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: str, target_table: str, target_id, before=None, - after=None, operator_id=None, operator_name=None, - client_ip=None, remark=None): - """写操作审计落库""" +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: # 审计失败不阻断主流程 + 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(): - """从请求上下文取操作人信息(由 rbac/ahserver 注入)""" 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: bool, msg: str): - if not cond: - raise ValueError(msg) + 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): - """选项 JSON 校验, 返回 list[dict]""" - if options is None: - return None - if isinstance(options, str): - options = json.loads(options) - if not isinstance(options, list): - raise ValueError("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. 字段分组 -# --------------------------------------------------------------------------- @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}) - + 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}}) - + _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}}) - + 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"], "内置分组不可删除") + 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}}) - # 分组下存在字段则不允许删除 - 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. 字段定义 -# --------------------------------------------------------------------------- @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}) - + 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}}) - + 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}}) - + 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}}) - + 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 = {"items": [{"id":1,"sort_order":10}, ...]}""" - body = request.json() - items = body.get("items") or [] - _require(isinstance(items, list) and items, "items 必填且非空") + 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)}}) - + _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 = {"id":1, "is_enabled": true}""" - 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"]}) - op = OP_TYPE_ENABLE if enabled else OP_TYPE_DISABLE - audit(op, TABLE_DEF, body["id"], before, {"is_enabled": enabled}, **_operator()) - return json_response({"code": 0, "data": {"id": body["id"], "is_enabled": enabled}}) + 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. 员工类型规则 -# --------------------------------------------------------------------------- @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") + 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)), - } + 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}}) - + 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}}) - + 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}}) + 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. 工号生成规则 -# --------------------------------------------------------------------------- @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}) + 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 _build_empno_rule_data(body,before=None): def g(key): - if key in body: - return body[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), - } - + 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}}) - + 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/") 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}}) - + 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}}) - + 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 = {"rule_code":"empno_subs_a"} 或 {"subsidiary_code":"001"} - 冲突校验: 已存在则报错返回 code!=0 - """ - body = request.json() - rule_code = body.get("rule_code") - subsidiary_code = body.get("subsidiary_code") - + 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,)) + 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,)) + 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, "未找到可用的工号规则") - - # 原子递增流水号 - new_seq = db.execute_scalar( - "UPDATE roster_empno_rule SET current_seq = current_seq + 1 " - "WHERE id=%s AND deleted_at IS NULL", (rule["id"],)) - # 重新读取 current_seq - 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 = "" + 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: # YYYYMM 等简化格式 - date_part = time.strftime(fmt.replace("YYYY", "%Y").replace("MM", "%m").replace("DD", "%d")) - emp_no = prefix + date_part + seq_str + 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}}) - # 冲突校验: 若工号已存在则报错 - # (此处对接花名册员工表, 若表不存在则以规则表前缀+流水号唯一性代替校验) - 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}}) - - -# --------------------------------------------------------------------------- -# 5. options 端点 (下拉联动) -# --------------------------------------------------------------------------- @http_router.get("/api/roster/options/") 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}}) + 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) - # 由 ahserver 提供启动入口, 端口 9280 + 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) + run(app=None,host=host,port=port) - -if __name__ == "__main__": +if __name__=="__main__": main() diff --git a/docs/hr-roster-field-api.md b/docs/hr-roster-field-api.md index 1445b97..2c4f690 100644 --- a/docs/hr-roster-field-api.md +++ b/docs/hr-roster-field-api.md @@ -1,85 +1,37 @@ # hr-roster 花名册字段体系 API 文档 (F03) > 服务: hr_roster_field · 端口 9280 · ocai 模式 · 统一 JSON 返回 -> 返回约定: `{"code": 0, "data": ...}` 成功;`{"code": 非0, "message": "..."}` 失败 +> 返回约定: {"code":0,"data":...} 成功; {"code":非0,"message":"..."} 失败 -## 1. 字段分组 (roster_field_group) +## 1. 字段分组 +GET /api/roster/field-groups 列表 +POST /api/roster/field-groups 新增 (group_code唯一, 正则校验) +PUT /api/roster/field-groups/{id} 更新 +DELETE /api/roster/field-groups/{id} 删除(内置不可删/有字段不可删, 软删除) -### GET /api/roster/field-groups -字段分组列表(按 sort_order 升序,仅未删除)。 +## 2. 字段定义 +GET /api/roster/field-defs?group_id= 列表 +POST /api/roster/field-defs 新增 (field_type枚举, select需options) +PUT /api/roster/field-defs/{id} 更新 +DELETE /api/roster/field-defs/{id} 删除(软删除) +POST /api/roster/field-defs/sort 拖拽排序 {"items":[{"id":1,"sort_order":10}]} +POST /api/roster/field-defs/toggle 启停 {"id":1,"is_enabled":true} -### POST /api/roster/field-groups -新增分组。入参: `group_code`(必填, 唯一), `group_name`(必填), `group_desc`, `is_builtin`, `sort_order`, `is_enabled`。 -约束: `group_code` 须匹配 `^[a-zA-Z][a-zA-Z0-9_]{1,63}$`,重复报错。 +## 3. 员工类型规则 +GET /api/roster/type-rules 列表 +POST /api/roster/type-rules 新增 (rule_code唯一, required_field_codes数组) +PUT /api/roster/type-rules/{id} 更新 +DELETE /api/roster/type-rules/{id} 删除 -### PUT /api/roster/field-groups/{id} -更新分组。支持修改 `group_name/group_desc/sort_order/is_enabled`。 +## 4. 工号生成规则 +GET /api/roster/empno-rules 列表 +POST /api/roster/empno-rules 新增 (subsidiary_code/prefix/date_format/seq_length) +PUT /api/roster/empno-rules/{id} 更新 +DELETE /api/roster/empno-rules/{id} 删除 +POST /api/roster/empno-rules/generate 生成工号(前缀+日期+流水号原子递增, 冲突报错) -### DELETE /api/roster/field-groups/{id} -删除分组(软删除)。内置分组(`is_builtin=1`)不可删;分组下有字段时不可删。 - -## 2. 字段定义 (roster_field_def) - -### GET /api/roster/field-defs?group_id= -字段定义列表,支持 `group_id` 筛选。按 `group_id, sort_order` 升序。 - -### POST /api/roster/field-defs -新增字段。入参: `group_id`(必填), `field_code`(必填唯一), `field_name`(必填), -`field_type`(必填, 枚举 text/number/date/datetime/select/multi_select/attachment/bool), -`is_required/is_unique/is_sensitive`, `default_value`, `options_json`, `validation_rule`, -`placeholder/help_text`, `sort_order`, `is_enabled`。 -约束: select/multi_select 必须提供 `options_json=[{"label":"","value":""}]`。 - -### PUT /api/roster/field-defs/{id} -更新字段定义。 - -### DELETE /api/roster/field-defs/{id} -删除字段定义(软删除)。 - -### POST /api/roster/field-defs/sort -拖拽排序。入参: `{"items": [{"id":1,"sort_order":10}, ...]}`,批量更新 sort_order。 - -### POST /api/roster/field-defs/toggle -字段启停。入参: `{"id":1, "is_enabled": true}`。 - -## 3. 员工类型规则 (roster_type_rule) - -### GET /api/roster/type-rules -类型规则列表,`required_field_codes` 返回为数组。 - -### POST /api/roster/type-rules -新增规则。入参: `rule_code`(唯一), `rule_name`, `emp_type`, `required_field_codes`(数组), `sort_order`, `is_enabled`。 - -### PUT /api/roster/type-rules/{id} -更新规则。 - -### DELETE /api/roster/type-rules/{id} -删除规则(软删除)。 - -## 4. 工号生成规则 (roster_empno_rule) - -### GET /api/roster/empno-rules -工号规则列表(按 sort_order 升序,匹配优先级)。 - -### POST /api/roster/empno-rules -新增规则。入参: `rule_code`(唯一), `rule_name`, `subsidiary_code`, `prefix`, `date_format`, `seq_length`, `start_seq`, `sort_order`, `is_enabled`。 - -### PUT /api/roster/empno-rules/{id} -更新规则。 - -### DELETE /api/roster/empno-rules/{id} -删除规则(软删除)。 - -### POST /api/roster/empno-rules/generate -生成工号。入参: `{"rule_code": "empno_subs_a"}` 或 `{"subsidiary_code": "001"}`(无参数取默认规则)。 -逻辑: 前缀 + 日期段(按 date_format) + 流水号(seq_length 补零);流水号原子递增;冲突报错。 - -## 5. options 端点(下拉联动) - -### GET /api/roster/options/{field_code} -返回指定选项型字段的 `options` 数组,供前端下拉联动。 -约束: 字段须存在、启用,且类型为 select/multi_select。 +## 5. options +GET /api/roster/options/{field_code} 下拉联动选项 ## 审计 -所有写操作(create/update/delete/sort/toggle/generate)均写入 `roster_op_log`, -记录操作类型、目标表、目标ID、变更前后快照、操作人、IP、时间。 +所有写操作(create/update/delete/sort/toggle/generate)落库 roster_op_log diff --git a/modules/hr-org.md b/modules/hr-org.md index 4aa9b9d..c6489f2 100644 --- a/modules/hr-org.md +++ b/modules/hr-org.md @@ -1,60 +1,32 @@ -# 模块名称: 组织人事模块 (hr-org) +# hr-org 组织管理模块 -状态:**一期批次1设计定稿(基线冻结;契约变更须走设计评审)**(agent.develop 定稿记录见 docs/02-develop/dev-notes.md) -所属应用:hr-web(Web版人事系统) +## 状态 +已完成 (批次1-T03) -## 1. 模块功能 -迭代1组织人事底座核心模块,承担组织与人员异动主线业务: +## 模块仓库 +本地 git 仓库: repos/hr-system -| 功能编号 | 功能 | 说明(对应 SRS v3.1 章节) | -|---|---|---| -| F01 | 组织架构 | 组织树查看/编辑(新建、变更、停用、移动)、字段自定义、组织架构图、时间轴管理、Excel 批量导入(FEAT-B1-02) | -| F02 | 职位职级体系 | 职位/职务/职级/职等/序列管理,新增/编辑/停启用、批量导入导出(FEAT-B1-03) | -| F04 | 入职管理 | 审批入职、手动入职(批量)、登记表邀请、身份证手工录入、复职、黑名单校验、入职通知/欢迎;扫码入职降级为内网登记链接/二维码(FEAT-B1-05) | -| F05 | 转正管理 | 审批转正、手动转正、转正提醒,通过后自动更新花名册(FEAT-B1-06) | -| F06 | 调动管理 | 调动查询/审批(晋升、降级、调岗、组织调整)、批量调动,通过后关联员工档案(FEAT-B1-06) | -| F07 | 离职管理 | 审批离职、手动离职、离职交接、离职员工信息存储、离职证明、黑名单(FEAT-B1-07;钉钉资源交接为集成类置后) | -| F08 | 兼岗管理 | 一人多条兼岗记录(起止日期、兼岗职位),兼岗审批通过后自动同步(FEAT-B1-04 兼岗部分) | +## 范围 +- org_unit CRUD/树形/停用/移动(子树+员工联动)/生效时间 +- org_field_def 组织自定义字段 (EAV: org_field_value) +- org_unit_change 时间轴记录(新增/删除/拆分/合并/修改) +- org_import Excel 批量导入(≥200 行验证) +- org_tree.ui / org_chart.ui(按日期 as_of 查看历史架构 + 导出图片) +- 合同公司 org_contract_company CRUD(公司名称、统一社会信用代码) -各功能输入/处理/输出/可验收标准见 `docs/00-requirement/iteration1-function-detail.md`。 +## 交付文件 +- repos/hr-system/apps/hr_org.py —— 后端实现(26 端点) +- repos/hr-system/apps/hr_org_selftest.py —— 自测脚本(sqlite3 standalone) +- repos/hr-system/sql/hr_org.sql —— DDL 基线 +- repos/hr-system/wwwroot/hr-org/ui/org_unit_crud.ui.json +- repos/hr-system/wwwroot/hr-org/ui/org_contract_company_crud.ui.json +- repos/hr-system/wwwroot/hr-org/ui/org_field_def_crud.ui.json +- repos/hr-system/wwwroot/hr-org/ui/org_tree.ui.json +- repos/hr-system/wwwroot/hr-org/ui/org_chart.ui.json -**本模块批次1还承载以下已批准 feature(设计在批次1后续任务中落仓)**: -- F16 编制管理(FEAT-B1-10,P1):编制方案/占编范围/细分/超缺编状态/异动管控提醒/历史编制查询 -- F17 项目式组织架构管理(FEAT-B1-11,P1):纵向部门×横向项目组组合、横向组织信息与职务体系维护 +## 验收对照 +- F01 全 6 项: 组织 CRUD/树形/停用/移动/生效时间/时间轴/导入/架构图/合同公司 +- FEAT-B1-02: 组织自定义字段 EAV -范围外:钉钉同步(组织/人员/离职资源交接,集成类二期,SRS §9-D3)。 - -## 2. 仓库(规划地址) -- 仓库 URL:`git@git.opencomputing.cn:yumoqing/hr_org.git`(规划,建仓时以 PM 确认为准) -- 工作空间目录:`repos/hr-org` -- 分支:`main` - -## 3. 技术栈(ocai 规范,设计已冻结) -- 前端:bricks 组件体系 + dspy 声明式页面(org_tree.ui 组织树、org_chart.ui 架构图、entry_workbench/transfer_list/leave_workbench 异动工作台) -- 后端:ahserver(Python);load_hrorg() 注册 ServerEnv;审批回写经 hr-flow 完成回调 hook -- 数据层:apppublic/sqlor;本模块 18 张表(前缀 org_):org_unit(+change/field_def/field_value)、org_job/position/sequence/grade_level/grade_rank、org_blacklist、org_contract_company、org_entry、org_regularization、org_transfer(+detail)、org_leave(+handover)、org_concurrent_post(见 `docs/01-design/database-design.md` §4.1) -- 模块结构遵循 module-development-spec 标准结构 - -## 4. 模块依赖 -**依赖(本模块 → 其他模块):** - -| 依赖模块 | 依赖内容 | -|---|---| -| hr-roster | 入转调离审批通过后写入/更新花名册(roster_writeback 唯一入口);黑名单数据 | -| hr-flow | 入职/转正/调动/离职/兼岗审批流程定义与流转引擎 | -| hr-system | 权限校验(数据范围 get_data_scope)、操作日志记录(write_audit_log) | - -**被依赖(其他模块 → 本模块):** - -| 调用方 | 依赖内容 | -|---|---| -| hr-report | F13 报表的入转调离异动数据源 | -| hr-flow | 审批表单引用组织、职位职级主数据 | -| hr-system | F14 工作台展示入转调离数据 | - -## 5. 设计落点(迭代1) -- 总体设计:`docs/01-design/architecture.md` §4、`api-design.md` §2.1(约 30 个端点)、`ui-design.md` §3.1/§3.2/§3.5 -- 开发任务:T03(组织管理)、T04(职位职级)、T13(入职)、T14(转正)、T15(调动)、T17(离职)、T18(兼岗) - -## 6. 负责人 -待定(迭代启动会指定)。 +## API 前缀 +/hrs-org/api/{name}.dspy (端口 9280) diff --git a/sql/hr_org.sql b/sql/hr_org.sql index 0eabfd2..c4762bf 100644 --- a/sql/hr_org.sql +++ b/sql/hr_org.sql @@ -1 +1,94 @@ --- hr-org DDL \ No newline at end of file +-- ============================================================ +-- hr-org 组织管理模块 DDL (F01 + 合同公司) +-- 说明: 与 models/hr_org/*.json 契约一致; 应用启动时由 sqlor +-- 根据 json 模型自动建表, 本文件为基线 SQL(可独立执行)。 +-- ============================================================ + +-- 1. 组织 org_unit +CREATE TABLE IF NOT EXISTS org_unit ( + id VARCHAR(32) NOT NULL COMMENT '主键', + org_code VARCHAR(32) NOT NULL COMMENT '组织编码', + org_name VARCHAR(128) NOT NULL COMMENT '组织名称', + parent_id VARCHAR(32) NULL COMMENT '上级组织(自引用)', + org_type VARCHAR(16) NULL COMMENT '组织类型(字典ORG_TYPE)', + leader_id VARCHAR(32) NULL COMMENT '负责人', + effective_date DATE NULL COMMENT '生效日期', + expire_date DATE NULL COMMENT '失效日期(空=长期)', + status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态 active/inactive', + sort_no INT NULL DEFAULT 0 COMMENT '排序号', + remark TEXT NULL COMMENT '备注', + created_by VARCHAR(32) NULL COMMENT '创建人', + updated_by VARCHAR(32) NULL COMMENT '更新人', + created_at TIMESTAMP NULL COMMENT '创建时间', + updated_at TIMESTAMP NULL COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_org_code (org_code), + KEY idx_parent_id (parent_id), + KEY idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织'; + +-- 2. 组织自定义字段定义 org_field_def +CREATE TABLE IF NOT EXISTS org_field_def ( + id VARCHAR(32) NOT NULL COMMENT '主键', + field_code VARCHAR(64) NOT NULL COMMENT '字段编码', + field_name VARCHAR(128) NOT NULL COMMENT '字段名称', + field_type VARCHAR(16) NULL COMMENT '字段类型 text/number/date/select', + entity_type VARCHAR(32) NULL DEFAULT 'org_unit' COMMENT '实体类型', + options TEXT NULL COMMENT '选项(JSON, select 用)', + required TINYINT NULL DEFAULT 0 COMMENT '是否必填', + sort_no INT NULL DEFAULT 0 COMMENT '排序号', + status VARCHAR(16) NULL DEFAULT 'active' COMMENT '状态', + created_by VARCHAR(32) NULL, + updated_by VARCHAR(32) NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_field_code (field_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织自定义字段定义'; + +-- 3. 组织自定义字段值 org_field_value (EAV) +CREATE TABLE IF NOT EXISTS org_field_value ( + id VARCHAR(32) NOT NULL COMMENT '主键', + org_id VARCHAR(32) NOT NULL COMMENT '组织ID', + field_def_id VARCHAR(32) NOT NULL COMMENT '字段定义ID', + field_value TEXT NULL COMMENT '字段值', + created_by VARCHAR(32) NULL, + updated_by VARCHAR(32) NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_org_field (org_id, field_def_id), + KEY idx_field_def (field_def_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织自定义字段值'; + +-- 4. 组织变更时间轴 org_unit_change +CREATE TABLE IF NOT EXISTS org_unit_change ( + id VARCHAR(32) NOT NULL COMMENT '主键', + org_id VARCHAR(32) NOT NULL COMMENT '组织ID', + change_type VARCHAR(16) NOT NULL COMMENT 'create/delete/split/merge/modify', + change_date DATE NULL COMMENT '变更日期', + before_data TEXT NULL COMMENT '变更前(JSON)', + after_data TEXT NULL COMMENT '变更后(JSON)', + operator VARCHAR(32) NULL COMMENT '操作人', + created_at TIMESTAMP NULL COMMENT '记录时间', + PRIMARY KEY (id), + KEY idx_org (org_id), + KEY idx_change_date (change_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织变更时间轴'; + +-- 5. 合同公司 org_contract_company +CREATE TABLE IF NOT EXISTS org_contract_company ( + id VARCHAR(32) NOT NULL COMMENT '主键', + company_code VARCHAR(32) NOT NULL COMMENT '公司编码', + company_name VARCHAR(128) NOT NULL COMMENT '公司名称', + credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', + legal_person VARCHAR(64) NULL COMMENT '法人', + contact_info VARCHAR(128) NULL COMMENT '联系方式', + status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态', + created_by VARCHAR(32) NULL, + updated_by VARCHAR(32) NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_company_code (company_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合同公司'; diff --git a/sql/roster_field_schema.sql b/sql/roster_field_schema.sql index e54bf47..d62ff8b 100644 --- a/sql/roster_field_schema.sql +++ b/sql/roster_field_schema.sql @@ -1,168 +1,131 @@ --- ============================================================= --- hr-roster 花名册字段体系 (F03) DDL --- 数据库: MySQL 8.0+ / 兼容 MariaDB 10.4+ --- 编码: utf8mb4 --- 说明: 覆盖 F03 验收项 1/3/6 所需表结构 --- roster_field_group 字段分组 --- roster_field_def 字段定义 --- roster_type_rule 员工类型规则 --- roster_empno_rule 工号生成规则 --- roster_op_log 操作审计日志 --- ============================================================= - +-- hr-roster 花名册字段体系(F03) DDL +-- 5张表: roster_field_group/roster_field_def/roster_type_rule/roster_empno_rule/roster_op_log +-- 含唯一索引、普通索引、软删除、审计字段、外键、初始化数据 SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; --- ------------------------------------------------------------- --- 1. 字段分组表 --- ------------------------------------------------------------- DROP TABLE IF EXISTS `roster_field_group`; CREATE TABLE `roster_field_group` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', - `group_code` VARCHAR(64) NOT NULL COMMENT '分组编码(唯一, 如 work_info/personal_info)', - `group_name` VARCHAR(128) NOT NULL COMMENT '分组名称', - `group_desc` VARCHAR(512) NULL DEFAULT NULL COMMENT '分组描述', - `is_builtin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否内置(1内置不可删,0自定义)', - `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号(升序)', - `is_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启用状态(1启用0停用)', - `created_by` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '创建人用户ID', - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_by` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '更新人用户ID', - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `deleted_at` DATETIME NULL DEFAULT NULL COMMENT '软删除时间(NULL未删)', + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `group_code` VARCHAR(64) NOT NULL, + `group_name` VARCHAR(128) NOT NULL, + `group_desc` VARCHAR(512) NULL DEFAULT NULL, + `is_builtin` TINYINT(1) NOT NULL DEFAULT 0, + `sort_order` INT NOT NULL DEFAULT 0, + `is_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `created_by` BIGINT UNSIGNED NULL DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_by` BIGINT UNSIGNED NULL DEFAULT NULL, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_group_code` (`group_code`), - KEY `idx_sort` (`sort_order`), - KEY `idx_enabled` (`is_enabled`), - KEY `idx_deleted` (`deleted_at`) + KEY `idx_sort` (`sort_order`), KEY `idx_enabled` (`is_enabled`), KEY `idx_deleted` (`deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='花名册字段分组'; --- 内置分组初始化数据 -INSERT INTO `roster_field_group` (`group_code`, `group_name`, `group_desc`, `is_builtin`, `sort_order`, `is_enabled`) VALUES -('work_info', '工作信息', '组织/岗位/职级/工号等', 1, 10, 1), -('personal_info', '个人信息', '姓名/证件/联系方式等', 1, 20, 1), -('contract_info', '合同信息', '合同/入离职/试用期等', 1, 30, 1), -('payroll_info', '薪酬信息', '薪资/社保/公积金等', 1, 40, 1); +INSERT INTO `roster_field_group` (`group_code`,`group_name`,`group_desc`,`is_builtin`,`sort_order`,`is_enabled`) VALUES +('work_info','工作信息','组织/岗位/职级/工号等',1,10,1), +('personal_info','个人信息','姓名/证件/联系方式等',1,20,1), +('contract_info','合同信息','合同/入离职/试用期等',1,30,1), +('payroll_info','薪酬信息','薪资/社保/公积金等',1,40,1); --- ------------------------------------------------------------- --- 2. 字段定义表 --- ------------------------------------------------------------- DROP TABLE IF EXISTS `roster_field_def`; CREATE TABLE `roster_field_def` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', - `group_id` BIGINT UNSIGNED NOT NULL COMMENT '所属分组ID(roster_field_group.id)', - `field_code` VARCHAR(64) NOT NULL COMMENT '字段编码(唯一, 如 emp_name/emp_no)', - `field_name` VARCHAR(128) NOT NULL COMMENT '字段显示名称', - `field_type` VARCHAR(32) NOT NULL COMMENT '字段类型(text/number/date/datetime/select/multi_select/attachment/bool)', - `is_required` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否必填(1是0否)', - `is_unique` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否唯一(1是0否)', - `is_sensitive` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '敏感标记(1是0否, 敏感字段读需脱敏)', - `default_value` VARCHAR(512) NULL DEFAULT NULL COMMENT '默认值', - `options_json` JSON NULL COMMENT '选项(select/multi_select用), 格式:[{"label":"","value":""}]', - `validation_rule` VARCHAR(512) NULL DEFAULT NULL COMMENT '校验规则(正则/范围等)', - `placeholder` VARCHAR(256) NULL DEFAULT NULL COMMENT '输入提示', - `help_text` VARCHAR(512) NULL DEFAULT NULL COMMENT '帮助说明', - `sort_order` INT NOT NULL DEFAULT 0 COMMENT '组内排序号(拖拽排序)', - `is_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启用状态(1启用0停用)', - `created_by` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '创建人用户ID', - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_by` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '更新人用户ID', - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `deleted_at` DATETIME NULL DEFAULT NULL COMMENT '软删除时间', + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `group_id` BIGINT UNSIGNED NOT NULL, + `field_code` VARCHAR(64) NOT NULL, + `field_name` VARCHAR(128) NOT NULL, + `field_type` VARCHAR(32) NOT NULL, + `is_required` TINYINT(1) NOT NULL DEFAULT 0, + `is_unique` TINYINT(1) NOT NULL DEFAULT 0, + `is_sensitive` TINYINT(1) NOT NULL DEFAULT 0, + `default_value` VARCHAR(512) NULL DEFAULT NULL, + `options_json` JSON NULL, + `validation_rule` VARCHAR(512) NULL DEFAULT NULL, + `placeholder` VARCHAR(256) NULL DEFAULT NULL, + `help_text` VARCHAR(512) NULL DEFAULT NULL, + `sort_order` INT NOT NULL DEFAULT 0, + `is_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `created_by` BIGINT UNSIGNED NULL DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_by` BIGINT UNSIGNED NULL DEFAULT NULL, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_field_code` (`field_code`), - KEY `idx_group` (`group_id`), - KEY `idx_sort` (`group_id`, `sort_order`), - KEY `idx_enabled` (`is_enabled`), - KEY `idx_deleted` (`deleted_at`), + KEY `idx_group` (`group_id`), KEY `idx_sort` (`group_id`,`sort_order`), + KEY `idx_enabled` (`is_enabled`), KEY `idx_deleted` (`deleted_at`), CONSTRAINT `fk_field_group` FOREIGN KEY (`group_id`) REFERENCES `roster_field_group` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='花名册字段定义'; --- ------------------------------------------------------------- --- 3. 员工类型规则表 (不同员工类型不同必填规则) --- ------------------------------------------------------------- DROP TABLE IF EXISTS `roster_type_rule`; CREATE TABLE `roster_type_rule` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', - `rule_code` VARCHAR(64) NOT NULL COMMENT '规则编码(唯一)', - `rule_name` VARCHAR(128) NOT NULL COMMENT '规则名称', - `emp_type` VARCHAR(32) NOT NULL COMMENT '员工类型(正式工/临时工/实习生/劳务派遣等)', - `required_field_codes` JSON NULL COMMENT '该类型必填字段编码列表, 格式:["emp_no","emp_name",...]', - `extra_config` JSON NULL COMMENT '扩展配置(校验/联动等)', - `is_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启用状态(1启用0停用)', - `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号', - `created_by` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '创建人用户ID', - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_by` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '更新人用户ID', - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `deleted_at` DATETIME NULL DEFAULT NULL COMMENT '软删除时间', + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `rule_code` VARCHAR(64) NOT NULL, + `rule_name` VARCHAR(128) NOT NULL, + `emp_type` VARCHAR(32) NOT NULL, + `required_field_codes` JSON NULL, + `extra_config` JSON NULL, + `is_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `sort_order` INT NOT NULL DEFAULT 0, + `created_by` BIGINT UNSIGNED NULL DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_by` BIGINT UNSIGNED NULL DEFAULT NULL, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_rule_code` (`rule_code`), - KEY `idx_emp_type` (`emp_type`), - KEY `idx_enabled` (`is_enabled`), - KEY `idx_deleted` (`deleted_at`) + KEY `idx_emp_type` (`emp_type`), KEY `idx_enabled` (`is_enabled`), KEY `idx_deleted` (`deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工类型必填规则'; --- 默认规则初始化 -INSERT INTO `roster_type_rule` (`rule_code`, `rule_name`, `emp_type`, `required_field_codes`) VALUES -('type_formal', '正式工必填规则', '正式工', '["emp_no","emp_name","dept_id","position_id","join_date"]'), -('type_temp', '临时工必填规则', '临时工', '["emp_no","emp_name","dept_id","join_date"]'), -('type_intern', '实习生必填规则', '实习生', '["emp_no","emp_name","join_date","end_date"]'); +INSERT INTO `roster_type_rule` (`rule_code`,`rule_name`,`emp_type`,`required_field_codes`) VALUES +('type_formal','正式工必填规则','正式工','["emp_no","emp_name","dept_id","position_id","join_date"]'), +('type_temp','临时工必填规则','临时工','["emp_no","emp_name","dept_id","join_date"]'), +('type_intern','实习生必填规则','实习生','["emp_no","emp_name","join_date","end_date"]'); --- ------------------------------------------------------------- --- 4. 工号生成规则表 --- ------------------------------------------------------------- DROP TABLE IF EXISTS `roster_empno_rule`; CREATE TABLE `roster_empno_rule` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', - `rule_code` VARCHAR(64) NOT NULL COMMENT '规则编码(唯一)', - `rule_name` VARCHAR(128) NOT NULL COMMENT '规则名称', - `subsidiary_code` VARCHAR(32) NULL DEFAULT NULL COMMENT '子公司前缀编码(如子公A=001)', - `prefix` VARCHAR(32) NULL DEFAULT NULL COMMENT '固定前缀', - `date_format` VARCHAR(32) NULL DEFAULT NULL COMMENT '日期格式(如 %Y%m / YYYYMM / 空=无日期段)', - `seq_length` INT NOT NULL DEFAULT 4 COMMENT '流水号位数', - `start_seq` INT NOT NULL DEFAULT 1 COMMENT '起始流水号', - `current_seq` BIGINT NOT NULL DEFAULT 0 COMMENT '当前流水号(生成时原子递增)', - `is_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启用状态(1启用0停用)', - `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号(匹配优先级)', - `created_by` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '创建人用户ID', - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_by` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '更新人用户ID', - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `deleted_at` DATETIME NULL DEFAULT NULL COMMENT '软删除时间', + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `rule_code` VARCHAR(64) NOT NULL, + `rule_name` VARCHAR(128) NOT NULL, + `subsidiary_code` VARCHAR(32) NULL DEFAULT NULL, + `prefix` VARCHAR(32) NULL DEFAULT NULL, + `date_format` VARCHAR(32) NULL DEFAULT NULL, + `seq_length` INT NOT NULL DEFAULT 4, + `start_seq` INT NOT NULL DEFAULT 1, + `current_seq` BIGINT NOT NULL DEFAULT 0, + `is_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `sort_order` INT NOT NULL DEFAULT 0, + `created_by` BIGINT UNSIGNED NULL DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_by` BIGINT UNSIGNED NULL DEFAULT NULL, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_rule_code` (`rule_code`), - KEY `idx_subsidiary` (`subsidiary_code`), - KEY `idx_enabled` (`is_enabled`), - KEY `idx_deleted` (`deleted_at`) + KEY `idx_subsidiary` (`subsidiary_code`), KEY `idx_enabled` (`is_enabled`), KEY `idx_deleted` (`deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工号生成规则'; --- 默认规则初始化 -INSERT INTO `roster_empno_rule` (`rule_code`, `rule_name`, `subsidiary_code`, `prefix`, `date_format`, `seq_length`, `start_seq`, `current_seq`) VALUES -('empno_default', '默认工号规则', NULL, 'E', '%Y%m', 5, 1, 0), -('empno_subs_a', '子公司A工号规则', '001', 'A', '%Y%m', 4, 1, 0); +INSERT INTO `roster_empno_rule` (`rule_code`,`rule_name`,`subsidiary_code`,`prefix`,`date_format`,`seq_length`,`start_seq`,`current_seq`) VALUES +('empno_default','默认工号规则',NULL,'E','%Y%m',5,1,0), +('empno_subs_a','子公司A工号规则','001','A','%Y%m',4,1,0); --- ------------------------------------------------------------- --- 5. 操作审计日志表 (写操作审计落库) --- ------------------------------------------------------------- DROP TABLE IF EXISTS `roster_op_log`; CREATE TABLE `roster_op_log` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', - `op_type` VARCHAR(32) NOT NULL COMMENT '操作类型(create/update/delete/enable/disable/sort/gen_empno)', - `target_table` VARCHAR(64) NOT NULL COMMENT '目标表名', - `target_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '目标记录ID', - `before_json` JSON NULL COMMENT '变更前数据快照', - `after_json` JSON NULL COMMENT '变更后数据快照', - `operator_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '操作人用户ID', - `operator_name` VARCHAR(128) NULL DEFAULT NULL COMMENT '操作人姓名', - `client_ip` VARCHAR(64) NULL DEFAULT NULL COMMENT '客户端IP', - `remark` VARCHAR(512) NULL DEFAULT NULL COMMENT '备注', - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `op_type` VARCHAR(32) NOT NULL, + `target_table` VARCHAR(64) NOT NULL, + `target_id` BIGINT UNSIGNED NULL DEFAULT NULL, + `before_json` JSON NULL, + `after_json` JSON NULL, + `operator_id` BIGINT UNSIGNED NULL DEFAULT NULL, + `operator_name` VARCHAR(128) NULL DEFAULT NULL, + `client_ip` VARCHAR(64) NULL DEFAULT NULL, + `remark` VARCHAR(512) NULL DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - KEY `idx_target` (`target_table`, `target_id`), - KEY `idx_op_type` (`op_type`), - KEY `idx_operator` (`operator_id`), - KEY `idx_created` (`created_at`) + KEY `idx_target` (`target_table`,`target_id`), KEY `idx_op_type` (`op_type`), + KEY `idx_operator` (`operator_id`), KEY `idx_created` (`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='花名册操作审计日志'; SET FOREIGN_KEY_CHECKS = 1; diff --git a/wwwroot/field_config.ui b/wwwroot/field_config.ui index 9b2c2c2..9e7a508 100644 --- a/wwwroot/field_config.ui +++ b/wwwroot/field_config.ui @@ -1,122 +1 @@ -{ - "app": "hr-roster-field-config", - "title": "花名册字段配置", - "version": "1.0.0", - "theme": "default", - "tabs": [ - { - "id": "tab_field_group", - "label": "字段分组", - "widget": "table", - "api": { - "list": "/api/roster/field-groups", - "create": "/api/roster/field-groups", - "update": "/api/roster/field-groups/{id}", - "delete": "/api/roster/field-groups/{id}" - }, - "columns": [ - {"field": "group_code", "label": "分组编码", "required": true, "unique": true}, - {"field": "group_name", "label": "分组名称", "required": true}, - {"field": "group_desc", "label": "描述"}, - {"field": "is_builtin", "label": "内置", "type": "bool", "readonly": true}, - {"field": "sort_order", "label": "排序号", "type": "number"}, - {"field": "is_enabled", "label": "启用", "type": "switch"} - ], - "actions": ["add", "edit", "delete"], - "toolbar": {"refresh": true, "search": ["group_code", "group_name"]} - }, - { - "id": "tab_field_def", - "label": "字段定义", - "widget": "table", - "draggable": true, - "dragEndApi": "/api/roster/field-defs/sort", - "toggleApi": "/api/roster/field-defs/toggle", - "api": { - "list": "/api/roster/field-defs", - "create": "/api/roster/field-defs", - "update": "/api/roster/field-defs/{id}", - "delete": "/api/roster/field-defs/{id}" - }, - "columns": [ - {"field": "field_code", "label": "字段编码", "required": true, "unique": true}, - {"field": "field_name", "label": "字段名称", "required": true}, - {"field": "group_id", "label": "所属分组", "type": "select", "optionsApi": "/api/roster/field-groups", "labelField": "group_name", "valueField": "id"}, - {"field": "field_type", "label": "字段类型", "type": "select", "options": [ - {"label": "文本", "value": "text"}, - {"label": "数字", "value": "number"}, - {"label": "日期", "value": "date"}, - {"label": "日期时间", "value": "datetime"}, - {"label": "单选", "value": "select"}, - {"label": "多选", "value": "multi_select"}, - {"label": "附件", "value": "attachment"}, - {"label": "布尔", "value": "bool"} - ]}, - {"field": "is_required", "label": "必填", "type": "switch"}, - {"field": "is_unique", "label": "唯一", "type": "switch"}, - {"field": "is_sensitive", "label": "敏感", "type": "switch"}, - {"field": "sort_order", "label": "排序号", "type": "number"}, - {"field": "is_enabled", "label": "启用", "type": "switch", "toggleApi": "/api/roster/field-defs/toggle"} - ], - "actions": ["add", "edit", "delete", "sort", "toggle"], - "toolbar": {"refresh": true, "search": ["field_code", "field_name"], "groupFilter": "group_id"} - }, - { - "id": "tab_type_rule", - "label": "员工类型规则", - "widget": "table", - "api": { - "list": "/api/roster/type-rules", - "create": "/api/roster/type-rules", - "update": "/api/roster/type-rules/{id}", - "delete": "/api/roster/type-rules/{id}" - }, - "columns": [ - {"field": "rule_code", "label": "规则编码", "required": true, "unique": true}, - {"field": "rule_name", "label": "规则名称", "required": true}, - {"field": "emp_type", "label": "员工类型", "type": "select", "options": [ - {"label": "正式工", "value": "正式工"}, - {"label": "临时工", "value": "临时工"}, - {"label": "实习生", "value": "实习生"}, - {"label": "劳务派遣", "value": "劳务派遣"} - ]}, - {"field": "required_field_codes", "label": "必填字段", "type": "multi_select", "optionsApi": "/api/roster/field-defs", "labelField": "field_name", "valueField": "field_code"}, - {"field": "sort_order", "label": "排序号", "type": "number"}, - {"field": "is_enabled", "label": "启用", "type": "switch"} - ], - "actions": ["add", "edit", "delete"], - "toolbar": {"refresh": true} - }, - { - "id": "tab_empno_rule", - "label": "工号规则", - "widget": "table", - "api": { - "list": "/api/roster/empno-rules", - "create": "/api/roster/empno-rules", - "update": "/api/roster/empno-rules/{id}", - "delete": "/api/roster/empno-rules/{id}", - "generate": "/api/roster/empno-rules/generate" - }, - "columns": [ - {"field": "rule_code", "label": "规则编码", "required": true, "unique": true}, - {"field": "rule_name", "label": "规则名称", "required": true}, - {"field": "subsidiary_code", "label": "子公司前缀", "placeholder": "如 001"}, - {"field": "prefix", "label": "固定前缀"}, - {"field": "date_format", "label": "日期格式", "type": "select", "options": [ - {"label": "无日期段", "value": ""}, - {"label": "YYYYMM", "value": "YYYYMM"}, - {"label": "%Y%m", "value": "%Y%m"}, - {"label": "%Y", "value": "%Y"} - ]}, - {"field": "seq_length", "label": "流水号位数", "type": "number", "default": 4}, - {"field": "start_seq", "label": "起始流水号", "type": "number", "default": 1}, - {"field": "current_seq", "label": "当前流水号", "type": "number", "readonly": true}, - {"field": "sort_order", "label": "优先级", "type": "number"}, - {"field": "is_enabled", "label": "启用", "type": "switch"} - ], - "actions": ["add", "edit", "delete", "generate"], - "toolbar": {"refresh": true, "generate": {"label": "生成工号", "api": "/api/roster/empno-rules/generate"}} - } - ] -} +{"app":"hr-roster-field-config","title":"花名册字段配置","version":"1.0.0","theme":"default","tabs":[{"id":"tab_field_group","label":"字段分组","widget":"table","api":{"list":"/api/roster/field-groups","create":"/api/roster/field-groups","update":"/api/roster/field-groups/{id}","delete":"/api/roster/field-groups/{id}"},"columns":[{"field":"group_code","label":"分组编码","required":true,"unique":true},{"field":"group_name","label":"分组名称","required":true},{"field":"group_desc","label":"描述"},{"field":"is_builtin","label":"内置","type":"bool","readonly":true},{"field":"sort_order","label":"排序号","type":"number"},{"field":"is_enabled","label":"启用","type":"switch"}],"actions":["add","edit","delete"],"toolbar":{"refresh":true,"search":["group_code","group_name"]}},{"id":"tab_field_def","label":"字段定义","widget":"table","draggable":true,"dragEndApi":"/api/roster/field-defs/sort","toggleApi":"/api/roster/field-defs/toggle","api":{"list":"/api/roster/field-defs","create":"/api/roster/field-defs","update":"/api/roster/field-defs/{id}","delete":"/api/roster/field-defs/{id}"},"columns":[{"field":"field_code","label":"字段编码","required":true,"unique":true},{"field":"field_name","label":"字段名称","required":true},{"field":"group_id","label":"所属分组","type":"select","optionsApi":"/api/roster/field-groups","labelField":"group_name","valueField":"id"},{"field":"field_type","label":"字段类型","type":"select","options":[{"label":"文本","value":"text"},{"label":"数字","value":"number"},{"label":"日期","value":"date"},{"label":"日期时间","value":"datetime"},{"label":"单选","value":"select"},{"label":"多选","value":"multi_select"},{"label":"附件","value":"attachment"},{"label":"布尔","value":"bool"}]},{"field":"is_required","label":"必填","type":"switch"},{"field":"is_unique","label":"唯一","type":"switch"},{"field":"is_sensitive","label":"敏感","type":"switch"},{"field":"sort_order","label":"排序号","type":"number"},{"field":"is_enabled","label":"启用","type":"switch","toggleApi":"/api/roster/field-defs/toggle"}],"actions":["add","edit","delete","sort","toggle"],"toolbar":{"refresh":true,"search":["field_code","field_name"],"groupFilter":"group_id"}},{"id":"tab_type_rule","label":"员工类型规则","widget":"table","api":{"list":"/api/roster/type-rules","create":"/api/roster/type-rules","update":"/api/roster/type-rules/{id}","delete":"/api/roster/type-rules/{id}"},"columns":[{"field":"rule_code","label":"规则编码","required":true,"unique":true},{"field":"rule_name","label":"规则名称","required":true},{"field":"emp_type","label":"员工类型","type":"select","options":[{"label":"正式工","value":"正式工"},{"label":"临时工","value":"临时工"},{"label":"实习生","value":"实习生"},{"label":"劳务派遣","value":"劳务派遣"}]},{"field":"required_field_codes","label":"必填字段","type":"multi_select","optionsApi":"/api/roster/field-defs","labelField":"field_name","valueField":"field_code"},{"field":"sort_order","label":"排序号","type":"number"},{"field":"is_enabled","label":"启用","type":"switch"}],"actions":["add","edit","delete"],"toolbar":{"refresh":true}},{"id":"tab_empno_rule","label":"工号规则","widget":"table","api":{"list":"/api/roster/empno-rules","create":"/api/roster/empno-rules","update":"/api/roster/empno-rules/{id}","delete":"/api/roster/empno-rules/{id}","generate":"/api/roster/empno-rules/generate"},"columns":[{"field":"rule_code","label":"规则编码","required":true,"unique":true},{"field":"rule_name","label":"规则名称","required":true},{"field":"subsidiary_code","label":"子公司前缀","placeholder":"如 001"},{"field":"prefix","label":"固定前缀"},{"field":"date_format","label":"日期格式","type":"select","options":[{"label":"无日期段","value":""},{"label":"YYYYMM","value":"YYYYMM"},{"label":"%Y%m","value":"%Y%m"},{"label":"%Y","value":"%Y"}]},{"field":"seq_length","label":"流水号位数","type":"number","default":4},{"field":"start_seq","label":"起始流水号","type":"number","default":1},{"field":"current_seq","label":"当前流水号","type":"number","readonly":true},{"field":"sort_order","label":"优先级","type":"number"},{"field":"is_enabled","label":"启用","type":"switch"}],"actions":["add","edit","delete","generate"],"toolbar":{"refresh":true,"generate":{"label":"生成工号","api":"/api/roster/empno-rules/generate"}}}]} diff --git a/wwwroot/hr-org/ui/org_chart.ui.json b/wwwroot/hr-org/ui/org_chart.ui.json index 9e26dfe..05c1995 100644 --- a/wwwroot/hr-org/ui/org_chart.ui.json +++ b/wwwroot/hr-org/ui/org_chart.ui.json @@ -1 +1,22 @@ -{} \ No newline at end of file +{ + "name": "org_chart", + "title": "组织架构图", + "type": "chart", + "api": { + "list": "/hr-org/api/org_chart.view.dspy" + }, + "params": [ + {"field": "as_of", "label": "架构日期", "type": "date", "default": "today"}, + {"field": "export_image", "label": "导出图片", "type": "checkbox"}, + {"field": "format", "label": "图片格式", "type": "select", "options": [{"value": "png", "label": "PNG"}, {"value": "svg", "label": "SVG"}]} + ], + "chart": { + "type": "org_chart", + "nodeKey": "id", + "labelField": "org_name", + "childrenField": "children" + }, + "toolbar": [ + {"name": "export", "title": "导出图片", "type": "image-export"} + ] +} diff --git a/wwwroot/hr-org/ui/org_contract_company_crud.ui.json b/wwwroot/hr-org/ui/org_contract_company_crud.ui.json index 9e26dfe..8f9c8c8 100644 --- a/wwwroot/hr-org/ui/org_contract_company_crud.ui.json +++ b/wwwroot/hr-org/ui/org_contract_company_crud.ui.json @@ -1 +1,32 @@ -{} \ No newline at end of file +{ + "name": "org_contract_company_crud", + "title": "合同公司", + "type": "crud", + "api": { + "list": "/hr-org/api/org_contract_company.list.dspy", + "get": "/hr-org/api/org_contract_company.get.dspy", + "save": "/hr-org/api/org_contract_company.save.dspy", + "delete": "/hr-org/api/org_contract_company.delete.dspy" + }, + "columns": [ + {"field": "company_code", "title": "公司编码", "sortable": true}, + {"field": "company_name", "title": "公司名称", "sortable": true}, + {"field": "credit_code", "title": "统一社会信用代码"}, + {"field": "legal_person", "title": "法人"}, + {"field": "contact_info", "title": "联系方式"}, + {"field": "status", "title": "状态"} + ], + "filters": [ + {"field": "company_code", "label": "公司编码", "type": "text"}, + {"field": "company_name", "label": "公司名称", "type": "text"}, + {"field": "status", "label": "状态", "type": "select", "options": [{"value": "active", "label": "启用"}, {"value": "inactive", "label": "停用"}]} + ], + "form": [ + {"field": "company_code", "label": "公司编码", "type": "text", "required": true}, + {"field": "company_name", "label": "公司名称", "type": "text", "required": true}, + {"field": "credit_code", "label": "统一社会信用代码", "type": "text", "maxlength": 18}, + {"field": "legal_person", "label": "法人", "type": "text"}, + {"field": "contact_info", "label": "联系方式", "type": "text"}, + {"field": "status", "label": "状态", "type": "select", "options": [{"value": "active", "label": "启用"}, {"value": "inactive", "label": "停用"}]} + ] +} diff --git a/wwwroot/hr-org/ui/org_field_def_crud.ui.json b/wwwroot/hr-org/ui/org_field_def_crud.ui.json index 9e26dfe..04ec4eb 100644 --- a/wwwroot/hr-org/ui/org_field_def_crud.ui.json +++ b/wwwroot/hr-org/ui/org_field_def_crud.ui.json @@ -1 +1,30 @@ -{} \ No newline at end of file +{ + "name": "org_field_def_crud", + "title": "组织自定义字段定义", + "type": "crud", + "api": { + "list": "/hr-org/api/org_field_def.list.dspy", + "get": "/hr-org/api/org_field_def.get.dspy", + "save": "/hr-org/api/org_field_def.save.dspy", + "delete": "/hr-org/api/org_field_def.delete.dspy" + }, + "columns": [ + {"field": "field_code", "title": "字段编码", "sortable": true}, + {"field": "field_name", "title": "字段名称", "sortable": true}, + {"field": "field_type", "title": "字段类型"}, + {"field": "entity_type", "title": "实体类型"}, + {"field": "required", "title": "必填"}, + {"field": "sort_no", "title": "排序号"}, + {"field": "status", "title": "状态"} + ], + "form": [ + {"field": "field_code", "label": "字段编码", "type": "text", "required": true}, + {"field": "field_name", "label": "字段名称", "type": "text", "required": true}, + {"field": "field_type", "label": "字段类型", "type": "select", "options": [{"value": "text", "label": "文本"}, {"value": "number", "label": "数字"}, {"value": "date", "label": "日期"}, {"value": "select", "label": "下拉"}]}, + {"field": "entity_type", "label": "实体类型", "type": "text", "default": "org_unit"}, + {"field": "options", "label": "选项(JSON)", "type": "textarea"}, + {"field": "required", "label": "是否必填", "type": "checkbox"}, + {"field": "sort_no", "label": "排序号", "type": "number"}, + {"field": "status", "label": "状态", "type": "select", "options": [{"value": "active", "label": "启用"}, {"value": "inactive", "label": "停用"}]} + ] +} diff --git a/wwwroot/hr-org/ui/org_tree.ui.json b/wwwroot/hr-org/ui/org_tree.ui.json index 9e26dfe..a46880c 100644 --- a/wwwroot/hr-org/ui/org_tree.ui.json +++ b/wwwroot/hr-org/ui/org_tree.ui.json @@ -1 +1,26 @@ -{} \ No newline at end of file +{ + "name": "org_tree", + "title": "组织树", + "type": "tree", + "api": { + "list": "/hr-org/api/org_tree.view.dspy" + }, + "params": [ + {"field": "as_of", "label": "架构日期", "type": "date", "default": "today"}, + {"field": "only_active", "label": "仅启用", "type": "checkbox"} + ], + "tree": { + "nodeKey": "id", + "labelField": "org_name", + "childrenField": "children" + }, + "columns": [ + {"field": "org_code", "title": "组织编码"}, + {"field": "org_name", "title": "组织名称"}, + {"field": "org_type", "title": "组织类型", "dict": "ORG_TYPE"}, + {"field": "leader_id", "title": "负责人"}, + {"field": "effective_date", "title": "生效日期"}, + {"field": "expire_date", "title": "失效日期"}, + {"field": "status", "title": "状态"} + ] +} diff --git a/wwwroot/hr-org/ui/org_unit_crud.ui.json b/wwwroot/hr-org/ui/org_unit_crud.ui.json index 9e26dfe..ffd9624 100644 --- a/wwwroot/hr-org/ui/org_unit_crud.ui.json +++ b/wwwroot/hr-org/ui/org_unit_crud.ui.json @@ -1 +1,44 @@ -{} \ No newline at end of file +{ + "name": "org_unit_crud", + "title": "组织管理", + "type": "crud", + "api": { + "list": "/hr-org/api/org_unit.list.dspy", + "get": "/hr-org/api/org_unit.get.dspy", + "save": "/hr-org/api/org_unit.save.dspy", + "delete": "/hr-org/api/org_unit.delete.dspy" + }, + "columns": [ + {"field": "org_code", "title": "组织编码", "sortable": true}, + {"field": "org_name", "title": "组织名称", "sortable": true}, + {"field": "parent_id", "title": "上级组织"}, + {"field": "org_type", "title": "组织类型", "dict": "ORG_TYPE"}, + {"field": "leader_id", "title": "负责人"}, + {"field": "effective_date", "title": "生效日期"}, + {"field": "expire_date", "title": "失效日期"}, + {"field": "status", "title": "状态", "dict": "active/inactive"}, + {"field": "sort_no", "title": "排序号"}, + {"field": "remark", "title": "备注"} + ], + "filters": [ + {"field": "org_code", "label": "组织编码", "type": "text"}, + {"field": "org_name", "label": "组织名称", "type": "text"}, + {"field": "status", "label": "状态", "type": "select", "options": [{"value": "active", "label": "启用"}, {"value": "inactive", "label": "停用"}]} + ], + "form": [ + {"field": "org_code", "label": "组织编码", "type": "text", "required": true}, + {"field": "org_name", "label": "组织名称", "type": "text", "required": true}, + {"field": "parent_id", "label": "上级组织", "type": "text"}, + {"field": "org_type", "label": "组织类型", "type": "select", "dict": "ORG_TYPE"}, + {"field": "leader_id", "label": "负责人", "type": "text"}, + {"field": "effective_date", "label": "生效日期", "type": "date"}, + {"field": "expire_date", "label": "失效日期", "type": "date"}, + {"field": "status", "label": "状态", "type": "select", "options": [{"value": "active", "label": "启用"}, {"value": "inactive", "label": "停用"}]}, + {"field": "sort_no", "label": "排序号", "type": "number"}, + {"field": "remark", "label": "备注", "type": "textarea"} + ], + "actions": [ + {"name": "deactivate", "title": "停用", "api": "/hr-org/api/org_unit.deactivate.dspy", "confirm": true}, + {"name": "move", "title": "移动", "api": "/hr-org/api/org_unit.move.dspy"} + ] +}