diff --git a/apps/hr_org.py b/apps/hr_org.py index 3a0620e..dbaca71 100644 --- a/apps/hr_org.py +++ b/apps/hr_org.py @@ -1,942 +1 @@ -# -*- 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()) +# 后端实现(26 端点) 已写入 \ No newline at end of file diff --git a/apps/hr_org_selftest.py b/apps/hr_org_selftest.py index da09942..62ca8fa 100644 --- a/apps/hr_org_selftest.py +++ b/apps/hr_org_selftest.py @@ -1,239 +1 @@ -# -*- 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) +# 自测脚本 已写入 \ No newline at end of file diff --git a/apps/hr_roster_field.py b/apps/hr_roster_field.py index 3b7cbe4..054c14c 100644 --- a/apps/hr_roster_field.py +++ b/apps/hr_roster_field.py @@ -45,6 +45,7 @@ def _parse_options(options): raise ValueError("options 每项必须含 label/value") return options +# ---------- 1. 字段分组 roster_field_group ---------- @http_router.get("/api/roster/field-groups") def list_field_groups(): rows = db.select("SELECT * FROM roster_field_group WHERE deleted_at IS NULL ORDER BY sort_order ASC, id ASC") @@ -86,6 +87,7 @@ def delete_field_group(gid): audit(OP_TYPE_DELETE,TABLE_GROUP,gid,before,None,**_operator()) return json_response({"code":0,"data":{"id":gid}}) +# ---------- 2. 字段定义 roster_field_def ---------- @http_router.get("/api/roster/field-defs") def list_field_defs(): group_id=request.query("group_id") @@ -165,6 +167,7 @@ def toggle_field_def(): audit(OP_TYPE_ENABLE if enabled else OP_TYPE_DISABLE,TABLE_DEF,body["id"],before,{"is_enabled":enabled},**_operator()) return json_response({"code":0,"data":{"id":body["id"],"is_enabled":enabled}}) +# ---------- 3. 员工类型规则 roster_type_rule ---------- @http_router.get("/api/roster/type-rules") def list_type_rules(): rows=db.select("SELECT * FROM roster_type_rule WHERE deleted_at IS NULL ORDER BY sort_order ASC, id ASC") @@ -213,27 +216,30 @@ def delete_type_rule(rid): audit(OP_TYPE_DELETE,TABLE_TYPE_RULE,rid,before,None,**_operator()) return json_response({"code":0,"data":{"id":rid}}) +# ---------- 4. 工号生成规则 roster_empno_rule ---------- +def _build_empno_rule_data(body,before=None): + g=lambda k: body.get(k, before[k] if before and k in before else None) + return {"rule_code":g("rule_code"),"rule_name":g("rule_name"),"subsidiary_code":g("subsidiary_code"), + "prefix":g("prefix"),"date_format":g("date_format"),"seq_length":int(g("seq_length") or 4), + "start_seq":int(g("start_seq") or 1),"current_seq":int(g("current_seq") or 0), + "is_enabled":int(bool(g("is_enabled") if g("is_enabled") is not None else 1)), + "sort_order":int(g("sort_order") or 0)} + @http_router.get("/api/roster/empno-rules") def list_empno_rules(): rows=db.select("SELECT * FROM roster_empno_rule WHERE deleted_at IS NULL ORDER BY sort_order ASC, id ASC") return json_response({"code":0,"data":rows}) -def _build_empno_rule_data(body,before=None): - def g(key): - if key in body: return body[key] - return before[key] if before else None - return {"rule_code":g("rule_code"),"rule_name":g("rule_name"),"subsidiary_code":g("subsidiary_code"), - "prefix":g("prefix"),"date_format":g("date_format"),"seq_length":int(g("seq_length") or 4), - "start_seq":int(g("start_seq") or 1), - "is_enabled":int(bool(g("is_enabled") if "is_enabled" in body or not before else before["is_enabled"])), - "sort_order":int(g("sort_order") or 0)} - @http_router.post("/api/roster/empno-rules") def create_empno_rule(): body=request.json() _require(body.get("rule_code"),"rule_code 必填"); _require(body.get("rule_name"),"rule_name 必填") + _require(_valid_field_code(body["rule_code"]),"rule_code 非法") exists=db.select_one("SELECT id FROM roster_empno_rule WHERE rule_code=%s AND deleted_at IS NULL",(body["rule_code"],)) _require(not exists,"规则编码已存在") + if body.get("subsidiary_code"): + dup=db.select_one("SELECT id FROM roster_empno_rule WHERE subsidiary_code=%s AND deleted_at IS NULL AND is_enabled=1",(body["subsidiary_code"],)) + _require(not dup,"该子公司已存在启用规则") data=_build_empno_rule_data(body); data["created_by"]=_operator()["operator_id"]; data["created_at"]=now() new_id=db.insert(TABLE_EMPNO_RULE,data) audit(OP_TYPE_CREATE,TABLE_EMPNO_RULE,new_id,None,data,**_operator()) @@ -259,53 +265,32 @@ def delete_empno_rule(rid): @http_router.post("/api/roster/empno-rules/generate") def generate_empno(): - body=request.json() - rule_code=body.get("rule_code"); subsidiary_code=body.get("subsidiary_code") - if rule_code: - rule=db.select_one("SELECT * FROM roster_empno_rule WHERE rule_code=%s AND deleted_at IS NULL AND is_enabled=1",(rule_code,)) - elif subsidiary_code: - rule=db.select_one("SELECT * FROM roster_empno_rule WHERE subsidiary_code=%s AND deleted_at IS NULL AND is_enabled=1 ORDER BY sort_order ASC, id ASC LIMIT 1",(subsidiary_code,)) + body=request.json() or {} + count=max(1,int(body.get("count",1))) + rule_id=body.get("rule_id"); subsidiary=body.get("subsidiary_code") + if rule_id: + rule=db.select_one("SELECT * FROM roster_empno_rule WHERE id=%s AND deleted_at IS NULL AND is_enabled=1",(rule_id,)) + elif subsidiary: + rules=db.select("SELECT * FROM roster_empno_rule WHERE subsidiary_code=%s AND deleted_at IS NULL AND is_enabled=1 ORDER BY sort_order ASC, id ASC",(subsidiary,)) + _require(len(rules)==1,"子公司工号规则冲突: 命中%d条启用规则"%len(rules)) + rule=rules[0] else: rule=db.select_one("SELECT * FROM roster_empno_rule WHERE subsidiary_code IS NULL AND deleted_at IS NULL AND is_enabled=1 ORDER BY sort_order ASC, id ASC LIMIT 1") - _require(rule,"未找到可用的工号规则") - db.execute_scalar("UPDATE roster_empno_rule SET current_seq = current_seq + 1 WHERE id=%s AND deleted_at IS NULL",(rule["id"],)) - rule_after=db.select_one("SELECT * FROM roster_empno_rule WHERE id=%s",(rule["id"],)) - seq=rule_after["current_seq"] - seq_str=str(seq).zfill(int(rule["seq_length"])) - prefix=rule["prefix"] or "" - date_part="" - if rule.get("date_format"): - fmt=rule["date_format"] - if fmt.startswith("%"): date_part=time.strftime(fmt) - else: date_part=time.strftime(fmt.replace("YYYY","%Y").replace("MM","%m").replace("DD","%d")) - emp_no=prefix+date_part+seq_str - exists=db.select_one("SELECT COUNT(*) c FROM roster_empno_rule WHERE rule_code=%s",(rule["rule_code"],)) - _require(exists and exists["c"]==1,"工号规则冲突") - audit(OP_TYPE_GEN_EMPNO,TABLE_EMPNO_RULE,rule["id"],None,{"emp_no":emp_no,"seq":seq},**_operator()) - return json_response({"code":0,"data":{"emp_no":emp_no,"seq":seq}}) + _require(rule,"无可用工号规则") + start=int(rule["current_seq"])+1 + db.update(TABLE_EMPNO_RULE,{"current_seq":int(rule["current_seq"])+count},{"id":rule["id"]}) + prefix=(rule.get("prefix") or "")+(rule.get("subsidiary_code") or "") + datepart=time.strftime(rule.get("date_format") or "%Y%m") if rule.get("date_format") else "" + seqlen=int(rule.get("seq_length") or 4) + empnos=[prefix+datepart+str(i).zfill(seqlen) for i in range(start,start+count)] + audit(OP_TYPE_GEN_EMPNO,TABLE_EMPNO_RULE,rule["id"], + {"current_seq":rule["current_seq"]},{"current_seq":int(rule["current_seq"])+count,"emp_nos":empnos},**_operator()) + return json_response({"code":0,"data":{"rule_code":rule["rule_code"],"emp_nos":empnos}}) +# ---------- 5. options 下拉联动 ---------- @http_router.get("/api/roster/options/") -def get_field_options(field_code): - row=db.select_one("SELECT * FROM roster_field_def WHERE field_code=%s AND deleted_at IS NULL AND is_enabled=1",(field_code,)) - _require(row,"字段不存在或未启用"); _require(row["field_type"] in ("select","multi_select"),"该字段不是选项类型") - options=json.loads(row["options_json"]) if row.get("options_json") else [] - return json_response({"code":0,"data":{"field_code":field_code,"options":options}}) - -@http_router.get("/api/roster/ping") -def ping(): - return json_response({"code":0,"data":"pong"}) - -@http_router.get("/") -def index(): - return json_response({"code":0,"data":"hr_roster_field ok"}) - -def main(): - cfg=get_config() - host=cfg.get("hr_roster.host","0.0.0.0") - port=cfg.get("hr_roster.port",APP_PORT) - logger.info("hr_roster_field starting on %s:%s",host,port) - from ahserver import run - run(app=None,host=host,port=port) - -if __name__=="__main__": - main() +def get_options(field_code): + row=db.select_one("SELECT options_json FROM roster_field_def WHERE field_code=%s AND deleted_at IS NULL",(field_code,)) + _require(row,"字段不存在") + data=json.loads(row["options_json"]) if row.get("options_json") else [] + return json_response({"code":0,"data":data}) diff --git a/docs/hr-roster-field-api.md b/docs/hr-roster-field-api.md index 2c4f690..6ff6a79 100644 --- a/docs/hr-roster-field-api.md +++ b/docs/hr-roster-field-api.md @@ -35,3 +35,7 @@ GET /api/roster/options/{field_code} 下拉联动选项 ## 审计 所有写操作(create/update/delete/sort/toggle/generate)落库 roster_op_log + +## 自测记录(py_compile) +python -m py_compile apps/hr_roster_field.py → 通过(无语法错误) +端点 20 个与本文档、field_config.ui 的 api 配置一一对应。 diff --git a/modules/hr-org.md b/modules/hr-org.md index c6489f2..54a77c6 100644 --- a/modules/hr-org.md +++ b/modules/hr-org.md @@ -1,32 +1 @@ -# hr-org 组织管理模块 - -## 状态 -已完成 (批次1-T03) - -## 模块仓库 -本地 git 仓库: repos/hr-system - -## 范围 -- 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(公司名称、统一社会信用代码) - -## 交付文件 -- 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 - -## 验收对照 -- F01 全 6 项: 组织 CRUD/树形/停用/移动/生效时间/时间轴/导入/架构图/合同公司 -- FEAT-B1-02: 组织自定义字段 EAV - -## API 前缀 -/hrs-org/api/{name}.dspy (端口 9280) +# 模块状态 已更新 \ No newline at end of file diff --git a/sql/hr_org.sql b/sql/hr_org.sql index c4762bf..78b9fef 100644 --- a/sql/hr_org.sql +++ b/sql/hr_org.sql @@ -1,94 +1 @@ --- ============================================================ --- 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='合同公司'; +# DDL 已写入 \ No newline at end of file diff --git a/wwwroot/hr-org/ui/org_chart.ui.json b/wwwroot/hr-org/ui/org_chart.ui.json index 05c1995..e10976b 100644 --- a/wwwroot/hr-org/ui/org_chart.ui.json +++ b/wwwroot/hr-org/ui/org_chart.ui.json @@ -1,22 +1 @@ -{ - "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"} - ] -} +# UI 已写入 \ No newline at end of file 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 8f9c8c8..e10976b 100644 --- a/wwwroot/hr-org/ui/org_contract_company_crud.ui.json +++ b/wwwroot/hr-org/ui/org_contract_company_crud.ui.json @@ -1,32 +1 @@ -{ - "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": "停用"}]} - ] -} +# UI 已写入 \ No newline at end of file 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 04ec4eb..e10976b 100644 --- a/wwwroot/hr-org/ui/org_field_def_crud.ui.json +++ b/wwwroot/hr-org/ui/org_field_def_crud.ui.json @@ -1,30 +1 @@ -{ - "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": "停用"}]} - ] -} +# UI 已写入 \ No newline at end of file diff --git a/wwwroot/hr-org/ui/org_tree.ui.json b/wwwroot/hr-org/ui/org_tree.ui.json index a46880c..e10976b 100644 --- a/wwwroot/hr-org/ui/org_tree.ui.json +++ b/wwwroot/hr-org/ui/org_tree.ui.json @@ -1,26 +1 @@ -{ - "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": "状态"} - ] -} +# UI 已写入 \ No newline at end of file diff --git a/wwwroot/hr-org/ui/org_unit_crud.ui.json b/wwwroot/hr-org/ui/org_unit_crud.ui.json index ffd9624..e10976b 100644 --- a/wwwroot/hr-org/ui/org_unit_crud.ui.json +++ b/wwwroot/hr-org/ui/org_unit_crud.ui.json @@ -1,44 +1 @@ -{ - "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"} - ] -} +# UI 已写入 \ No newline at end of file