提交
This commit is contained in:
1 parent
246ad2228e
commit
8e2dd8b9f0
186 files changed
+38771
-2521
No files matched your search
@@ -0,0 +1,5 @@
|
||||
- generic [ref=e1]:
|
||||
- generic "Toggle devtools panel" [ref=e2] [cursor=pointer]:
|
||||
- img [ref=e3]
|
||||
- generic "Toggle Component Inspector" [ref=e8] [cursor=pointer]:
|
||||
- img [ref=e9]
|
||||
@@ -0,0 +1,119 @@
|
||||
- generic [active] [ref=e13]:
|
||||
- generic [ref=e64]:
|
||||
- complementary [ref=e65]:
|
||||
- generic [ref=e67]:
|
||||
- generic [ref=e68] [cursor=pointer]:
|
||||
- img "中科兴皖" [ref=e70]
|
||||
- paragraph [ref=e71]: 中科兴皖
|
||||
- menubar [ref=e75]:
|
||||
- menuitem "工作台" [ref=e76] [cursor=pointer]:
|
||||
- generic [ref=e78]: 工作台
|
||||
- menuitem "运营支撑" [expanded] [ref=e79]:
|
||||
- generic [ref=e80] [cursor=pointer]:
|
||||
- img [ref=e83]
|
||||
- generic [ref=e85]: 运营支撑
|
||||
- img [ref=e87]
|
||||
- menu [ref=e89]:
|
||||
- menuitem "洁净室管理" [ref=e90] [cursor=pointer]:
|
||||
- generic [ref=e91]: 洁净室管理
|
||||
- menuitem "库位管理" [ref=e92] [cursor=pointer]:
|
||||
- generic [ref=e93]: 库位管理
|
||||
- menuitem "展馆预约" [ref=e94] [cursor=pointer]:
|
||||
- generic [ref=e95]: 展馆预约
|
||||
- menuitem "demo" [ref=e96] [cursor=pointer]:
|
||||
- generic [ref=e97]: demo
|
||||
- menuitem "基础信息管理" [expanded] [ref=e98]:
|
||||
- generic [ref=e99] [cursor=pointer]:
|
||||
- img [ref=e102]
|
||||
- generic [ref=e104]: 基础信息管理
|
||||
- img [ref=e106]
|
||||
- menu [ref=e108]:
|
||||
- menuitem "客户管理" [ref=e109] [cursor=pointer]:
|
||||
- generic [ref=e110]: 客户管理
|
||||
- menuitem "细胞样本类型管理" [ref=e111] [cursor=pointer]:
|
||||
- generic [ref=e112]: 细胞样本类型管理
|
||||
- menuitem "采集地区及医院管理" [ref=e113] [cursor=pointer]:
|
||||
- generic [ref=e114]: 采集地区及医院管理
|
||||
- menuitem "采集协议号管理" [ref=e115] [cursor=pointer]:
|
||||
- generic [ref=e116]: 采集协议号管理
|
||||
- menuitem "样本编码管理" [ref=e117] [cursor=pointer]:
|
||||
- generic [ref=e118]: 样本编码管理
|
||||
- menuitem "细胞存储管理" [expanded] [ref=e119]:
|
||||
- generic [ref=e120] [cursor=pointer]:
|
||||
- img [ref=e123]
|
||||
- generic [ref=e125]: 细胞存储管理
|
||||
- img [ref=e127]
|
||||
- menu [ref=e129]:
|
||||
- menuitem "采集协议单" [ref=e130] [cursor=pointer]:
|
||||
- generic [ref=e131]: 采集协议单
|
||||
- menuitem "采集协议单审核" [ref=e132] [cursor=pointer]:
|
||||
- generic [ref=e133]: 采集协议单审核
|
||||
- menuitem "收款确认(财务)" [ref=e134] [cursor=pointer]:
|
||||
- generic [ref=e135]: 收款确认(财务)
|
||||
- menuitem "退费确认(财务)" [ref=e136] [cursor=pointer]:
|
||||
- generic [ref=e137]: 退费确认(财务)
|
||||
- menuitem "分期缴费预警" [ref=e138] [cursor=pointer]:
|
||||
- generic [ref=e139]: 分期缴费预警
|
||||
- menuitem "工作流管理" [expanded] [ref=e140]:
|
||||
- generic [ref=e141] [cursor=pointer]:
|
||||
- img [ref=e144]
|
||||
- generic [ref=e147]: 工作流管理
|
||||
- img [ref=e149]
|
||||
- menu [ref=e151]:
|
||||
- menuitem "流程配置" [ref=e152] [cursor=pointer]:
|
||||
- generic [ref=e153]: 流程配置
|
||||
- menuitem "待办中心" [ref=e154] [cursor=pointer]:
|
||||
- generic [ref=e155]: 待办中心
|
||||
- menuitem "已办中心" [ref=e156] [cursor=pointer]:
|
||||
- generic [ref=e157]: 已办中心
|
||||
- menuitem "我的申请" [ref=e158] [cursor=pointer]:
|
||||
- generic [ref=e159]: 我的申请
|
||||
- menuitem "请假申请" [ref=e160] [cursor=pointer]:
|
||||
- generic [ref=e161]: 请假申请
|
||||
- main [ref=e162]:
|
||||
- generic [ref=e164]:
|
||||
- generic [ref=e165]:
|
||||
- generic [ref=e166]:
|
||||
- img [ref=e169] [cursor=pointer]
|
||||
- img [ref=e173] [cursor=pointer]
|
||||
- navigation "breadcrumb" [ref=e175]:
|
||||
- list [ref=e176]:
|
||||
- listitem [ref=e177]:
|
||||
- generic [ref=e179]: 工作台
|
||||
- generic [ref=e180]:
|
||||
- img [ref=e183] [cursor=pointer]
|
||||
- img [ref=e187] [cursor=pointer]
|
||||
- generic [ref=e188]:
|
||||
- list [ref=e190]:
|
||||
- listitem [ref=e191] [cursor=pointer]: 工作台
|
||||
- img [ref=e195] [cursor=pointer]
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]:
|
||||
- heading "工作台" [level=1] [ref=e201]
|
||||
- paragraph [ref=e202]: 以下为固定参考页,新增管理端界面请对照布局与类名;视觉以文档为准。
|
||||
- generic [ref=e203]:
|
||||
- heading "快速入口" [level=2] [ref=e205]: 快速入口
|
||||
- generic [ref=e207]:
|
||||
- paragraph [ref=e208]: "详见仓库根目录 `.cursorrules` 中「页面级 UI 模板(固定五页)」。"
|
||||
- generic [ref=e209]:
|
||||
- button "商品单位" [ref=e210] [cursor=pointer]:
|
||||
- generic [ref=e211]: 商品单位
|
||||
- button "用户管理" [ref=e212] [cursor=pointer]:
|
||||
- generic [ref=e213]: 用户管理
|
||||
- button "商品分类" [ref=e214] [cursor=pointer]:
|
||||
- generic [ref=e215]: 商品分类
|
||||
- button "租户管理" [ref=e216] [cursor=pointer]:
|
||||
- generic [ref=e217]: 租户管理
|
||||
- button "客户管理" [ref=e218] [cursor=pointer]:
|
||||
- generic [ref=e219]: 客户管理
|
||||
- generic [ref=e1]:
|
||||
- generic "Toggle devtools panel" [ref=e2] [cursor=pointer]:
|
||||
- img [ref=e3]
|
||||
- generic "Toggle Component Inspector" [ref=e8] [cursor=pointer]:
|
||||
- img [ref=e9]
|
||||
- alert [ref=e220]:
|
||||
- img [ref=e222]
|
||||
- generic [ref=e224]:
|
||||
- heading "登录成功" [level=2] [ref=e225]
|
||||
- paragraph [ref=e227]: 欢迎回来,系统管理员!
|
||||
- img [ref=e229] [cursor=pointer]
|
||||
@@ -0,0 +1,29 @@
|
||||
- generic [active] [ref=e13]:
|
||||
- generic [ref=e231]:
|
||||
- generic [ref=e233]:
|
||||
- img "中科兴皖" [ref=e235]
|
||||
- heading "一体化管理门户" [level=1] [ref=e236]
|
||||
- paragraph [ref=e237]: 安全 · 高效 · 可扩展
|
||||
- generic [ref=e238]:
|
||||
- generic [ref=e240]: 系统管理
|
||||
- generic [ref=e242]: 权限配置
|
||||
- generic [ref=e244]: 数据统计
|
||||
- generic [ref=e246]:
|
||||
- generic [ref=e247]:
|
||||
- heading "欢迎登录" [level=2] [ref=e248]
|
||||
- paragraph [ref=e249]: 请输入您的账号信息
|
||||
- generic [ref=e250]:
|
||||
- generic [ref=e254]:
|
||||
- img [ref=e257]
|
||||
- textbox "请输入用户名" [ref=e259]
|
||||
- generic [ref=e263]:
|
||||
- img [ref=e266]
|
||||
- textbox "请输入密码" [ref=e269]
|
||||
- button "登 录" [ref=e272] [cursor=pointer]:
|
||||
- generic [ref=e273]: 登 录
|
||||
- generic [ref=e274]: 中科兴皖 © 2026
|
||||
- generic [ref=e1]:
|
||||
- generic "Toggle devtools panel" [ref=e2] [cursor=pointer]:
|
||||
- img [ref=e3]
|
||||
- generic "Toggle Component Inspector" [ref=e8] [cursor=pointer]:
|
||||
- img [ref=e9]
|
||||
@@ -0,0 +1,29 @@
|
||||
- generic [active] [ref=e13]:
|
||||
- generic [ref=e231]:
|
||||
- generic [ref=e233]:
|
||||
- img "中科兴皖" [ref=e235]
|
||||
- heading "一体化管理门户" [level=1] [ref=e236]
|
||||
- paragraph [ref=e237]: 安全 · 高效 · 可扩展
|
||||
- generic [ref=e238]:
|
||||
- generic [ref=e240]: 系统管理
|
||||
- generic [ref=e242]: 权限配置
|
||||
- generic [ref=e244]: 数据统计
|
||||
- generic [ref=e246]:
|
||||
- generic [ref=e247]:
|
||||
- heading "欢迎登录" [level=2] [ref=e248]
|
||||
- paragraph [ref=e249]: 请输入您的账号信息
|
||||
- generic [ref=e250]:
|
||||
- generic [ref=e254]:
|
||||
- img [ref=e257]
|
||||
- textbox "请输入用户名" [ref=e259]
|
||||
- generic [ref=e263]:
|
||||
- img [ref=e266]
|
||||
- textbox "请输入密码" [ref=e269]
|
||||
- button "登 录" [ref=e272] [cursor=pointer]:
|
||||
- generic [ref=e273]: 登 录
|
||||
- generic [ref=e274]: 中科兴皖 © 2026
|
||||
- generic [ref=e1]:
|
||||
- generic "Toggle devtools panel" [ref=e2] [cursor=pointer]:
|
||||
- img [ref=e3]
|
||||
- generic "Toggle Component Inspector" [ref=e8] [cursor=pointer]:
|
||||
- img [ref=e9]
|
||||
@@ -0,0 +1,119 @@
|
||||
- generic [active] [ref=e13]:
|
||||
- generic [ref=e280]:
|
||||
- complementary [ref=e281]:
|
||||
- generic [ref=e283]:
|
||||
- generic [ref=e284] [cursor=pointer]:
|
||||
- img "中科兴皖" [ref=e286]
|
||||
- paragraph [ref=e287]: 中科兴皖
|
||||
- menubar [ref=e291]:
|
||||
- menuitem "工作台" [ref=e292] [cursor=pointer]:
|
||||
- generic [ref=e294]: 工作台
|
||||
- menuitem "运营支撑" [expanded] [ref=e295]:
|
||||
- generic [ref=e296] [cursor=pointer]:
|
||||
- img [ref=e299]
|
||||
- generic [ref=e301]: 运营支撑
|
||||
- img [ref=e303]
|
||||
- menu [ref=e305]:
|
||||
- menuitem "洁净室管理" [ref=e306] [cursor=pointer]:
|
||||
- generic [ref=e307]: 洁净室管理
|
||||
- menuitem "库位管理" [ref=e308] [cursor=pointer]:
|
||||
- generic [ref=e309]: 库位管理
|
||||
- menuitem "展馆预约" [ref=e310] [cursor=pointer]:
|
||||
- generic [ref=e311]: 展馆预约
|
||||
- menuitem "demo" [ref=e312] [cursor=pointer]:
|
||||
- generic [ref=e313]: demo
|
||||
- menuitem "基础信息管理" [expanded] [ref=e314]:
|
||||
- generic [ref=e315] [cursor=pointer]:
|
||||
- img [ref=e318]
|
||||
- generic [ref=e320]: 基础信息管理
|
||||
- img [ref=e322]
|
||||
- menu [ref=e324]:
|
||||
- menuitem "客户管理" [ref=e325] [cursor=pointer]:
|
||||
- generic [ref=e326]: 客户管理
|
||||
- menuitem "细胞样本类型管理" [ref=e327] [cursor=pointer]:
|
||||
- generic [ref=e328]: 细胞样本类型管理
|
||||
- menuitem "采集地区及医院管理" [ref=e329] [cursor=pointer]:
|
||||
- generic [ref=e330]: 采集地区及医院管理
|
||||
- menuitem "采集协议号管理" [ref=e331] [cursor=pointer]:
|
||||
- generic [ref=e332]: 采集协议号管理
|
||||
- menuitem "样本编码管理" [ref=e333] [cursor=pointer]:
|
||||
- generic [ref=e334]: 样本编码管理
|
||||
- menuitem "细胞存储管理" [expanded] [ref=e335]:
|
||||
- generic [ref=e336] [cursor=pointer]:
|
||||
- img [ref=e339]
|
||||
- generic [ref=e341]: 细胞存储管理
|
||||
- img [ref=e343]
|
||||
- menu [ref=e345]:
|
||||
- menuitem "采集协议单" [ref=e346] [cursor=pointer]:
|
||||
- generic [ref=e347]: 采集协议单
|
||||
- menuitem "采集协议单审核" [ref=e348] [cursor=pointer]:
|
||||
- generic [ref=e349]: 采集协议单审核
|
||||
- menuitem "收款确认(财务)" [ref=e350] [cursor=pointer]:
|
||||
- generic [ref=e351]: 收款确认(财务)
|
||||
- menuitem "退费确认(财务)" [ref=e352] [cursor=pointer]:
|
||||
- generic [ref=e353]: 退费确认(财务)
|
||||
- menuitem "分期缴费预警" [ref=e354] [cursor=pointer]:
|
||||
- generic [ref=e355]: 分期缴费预警
|
||||
- menuitem "工作流管理" [expanded] [ref=e356]:
|
||||
- generic [ref=e357] [cursor=pointer]:
|
||||
- img [ref=e360]
|
||||
- generic [ref=e363]: 工作流管理
|
||||
- img [ref=e365]
|
||||
- menu [ref=e367]:
|
||||
- menuitem "流程配置" [ref=e368] [cursor=pointer]:
|
||||
- generic [ref=e369]: 流程配置
|
||||
- menuitem "待办中心" [ref=e370] [cursor=pointer]:
|
||||
- generic [ref=e371]: 待办中心
|
||||
- menuitem "已办中心" [ref=e372] [cursor=pointer]:
|
||||
- generic [ref=e373]: 已办中心
|
||||
- menuitem "我的申请" [ref=e374] [cursor=pointer]:
|
||||
- generic [ref=e375]: 我的申请
|
||||
- menuitem "请假申请" [ref=e376] [cursor=pointer]:
|
||||
- generic [ref=e377]: 请假申请
|
||||
- main [ref=e378]:
|
||||
- generic [ref=e380]:
|
||||
- generic [ref=e381]:
|
||||
- generic [ref=e382]:
|
||||
- img [ref=e385] [cursor=pointer]
|
||||
- img [ref=e389] [cursor=pointer]
|
||||
- navigation "breadcrumb" [ref=e391]:
|
||||
- list [ref=e392]:
|
||||
- listitem [ref=e393]:
|
||||
- generic [ref=e395]: 工作台
|
||||
- generic [ref=e396]:
|
||||
- img [ref=e399] [cursor=pointer]
|
||||
- img [ref=e403] [cursor=pointer]
|
||||
- generic [ref=e404]:
|
||||
- list [ref=e406]:
|
||||
- listitem [ref=e407] [cursor=pointer]: 工作台
|
||||
- img [ref=e411] [cursor=pointer]
|
||||
- generic [ref=e415]:
|
||||
- generic [ref=e416]:
|
||||
- heading "工作台" [level=1] [ref=e417]
|
||||
- paragraph [ref=e418]: 以下为固定参考页,新增管理端界面请对照布局与类名;视觉以文档为准。
|
||||
- generic [ref=e419]:
|
||||
- heading "快速入口" [level=2] [ref=e421]: 快速入口
|
||||
- generic [ref=e423]:
|
||||
- paragraph [ref=e424]: "详见仓库根目录 `.cursorrules` 中「页面级 UI 模板(固定五页)」。"
|
||||
- generic [ref=e425]:
|
||||
- button "商品单位" [ref=e426] [cursor=pointer]:
|
||||
- generic [ref=e427]: 商品单位
|
||||
- button "用户管理" [ref=e428] [cursor=pointer]:
|
||||
- generic [ref=e429]: 用户管理
|
||||
- button "商品分类" [ref=e430] [cursor=pointer]:
|
||||
- generic [ref=e431]: 商品分类
|
||||
- button "租户管理" [ref=e432] [cursor=pointer]:
|
||||
- generic [ref=e433]: 租户管理
|
||||
- button "客户管理" [ref=e434] [cursor=pointer]:
|
||||
- generic [ref=e435]: 客户管理
|
||||
- generic [ref=e1]:
|
||||
- generic "Toggle devtools panel" [ref=e2] [cursor=pointer]:
|
||||
- img [ref=e3]
|
||||
- generic "Toggle Component Inspector" [ref=e8] [cursor=pointer]:
|
||||
- img [ref=e9]
|
||||
- alert [ref=e436]:
|
||||
- img [ref=e438]
|
||||
- generic [ref=e440]:
|
||||
- heading "登录成功" [level=2] [ref=e441]
|
||||
- paragraph [ref=e443]: 欢迎回来,系统管理员!
|
||||
- img [ref=e445] [cursor=pointer]
|
||||
@@ -0,0 +1,29 @@
|
||||
- generic [active] [ref=e13]:
|
||||
- generic [ref=e447]:
|
||||
- generic [ref=e449]:
|
||||
- img "中科兴皖" [ref=e451]
|
||||
- heading "一体化管理门户" [level=1] [ref=e452]
|
||||
- paragraph [ref=e453]: 安全 · 高效 · 可扩展
|
||||
- generic [ref=e454]:
|
||||
- generic [ref=e456]: 系统管理
|
||||
- generic [ref=e458]: 权限配置
|
||||
- generic [ref=e460]: 数据统计
|
||||
- generic [ref=e462]:
|
||||
- generic [ref=e463]:
|
||||
- heading "欢迎登录" [level=2] [ref=e464]
|
||||
- paragraph [ref=e465]: 请输入您的账号信息
|
||||
- generic [ref=e466]:
|
||||
- generic [ref=e470]:
|
||||
- img [ref=e473]
|
||||
- textbox "请输入用户名" [ref=e475]
|
||||
- generic [ref=e479]:
|
||||
- img [ref=e482]
|
||||
- textbox "请输入密码" [ref=e485]
|
||||
- button "登 录" [ref=e488] [cursor=pointer]:
|
||||
- generic [ref=e489]: 登 录
|
||||
- generic [ref=e490]: 中科兴皖 © 2026
|
||||
- generic [ref=e1]:
|
||||
- generic "Toggle devtools panel" [ref=e2] [cursor=pointer]:
|
||||
- img [ref=e3]
|
||||
- generic "Toggle Component Inspector" [ref=e8] [cursor=pointer]:
|
||||
- img [ref=e9]
|
||||
@@ -0,0 +1,29 @@
|
||||
- generic [active] [ref=e13]:
|
||||
- generic [ref=e447]:
|
||||
- generic [ref=e449]:
|
||||
- img "中科兴皖" [ref=e451]
|
||||
- heading "一体化管理门户" [level=1] [ref=e452]
|
||||
- paragraph [ref=e453]: 安全 · 高效 · 可扩展
|
||||
- generic [ref=e454]:
|
||||
- generic [ref=e456]: 系统管理
|
||||
- generic [ref=e458]: 权限配置
|
||||
- generic [ref=e460]: 数据统计
|
||||
- generic [ref=e462]:
|
||||
- generic [ref=e463]:
|
||||
- heading "欢迎登录" [level=2] [ref=e464]
|
||||
- paragraph [ref=e465]: 请输入您的账号信息
|
||||
- generic [ref=e466]:
|
||||
- generic [ref=e470]:
|
||||
- img [ref=e473]
|
||||
- textbox "请输入用户名" [ref=e475]
|
||||
- generic [ref=e479]:
|
||||
- img [ref=e482]
|
||||
- textbox "请输入密码" [ref=e485]
|
||||
- button "登 录" [ref=e488] [cursor=pointer]:
|
||||
- generic [ref=e489]: 登 录
|
||||
- generic [ref=e490]: 中科兴皖 © 2026
|
||||
- generic [ref=e1]:
|
||||
- generic "Toggle devtools panel" [ref=e2] [cursor=pointer]:
|
||||
- img [ref=e3]
|
||||
- generic "Toggle Component Inspector" [ref=e8] [cursor=pointer]:
|
||||
- img [ref=e9]
|
||||
@@ -0,0 +1,119 @@
|
||||
- generic [active] [ref=e13]:
|
||||
- generic [ref=e496]:
|
||||
- complementary [ref=e497]:
|
||||
- generic [ref=e499]:
|
||||
- generic [ref=e500] [cursor=pointer]:
|
||||
- img "中科兴皖" [ref=e502]
|
||||
- paragraph [ref=e503]: 中科兴皖
|
||||
- menubar [ref=e507]:
|
||||
- menuitem "工作台" [ref=e508] [cursor=pointer]:
|
||||
- generic [ref=e510]: 工作台
|
||||
- menuitem "运营支撑" [expanded] [ref=e511]:
|
||||
- generic [ref=e512] [cursor=pointer]:
|
||||
- img [ref=e515]
|
||||
- generic [ref=e517]: 运营支撑
|
||||
- img [ref=e519]
|
||||
- menu [ref=e521]:
|
||||
- menuitem "洁净室管理" [ref=e522] [cursor=pointer]:
|
||||
- generic [ref=e523]: 洁净室管理
|
||||
- menuitem "库位管理" [ref=e524] [cursor=pointer]:
|
||||
- generic [ref=e525]: 库位管理
|
||||
- menuitem "展馆预约" [ref=e526] [cursor=pointer]:
|
||||
- generic [ref=e527]: 展馆预约
|
||||
- menuitem "demo" [ref=e528] [cursor=pointer]:
|
||||
- generic [ref=e529]: demo
|
||||
- menuitem "基础信息管理" [expanded] [ref=e530]:
|
||||
- generic [ref=e531] [cursor=pointer]:
|
||||
- img [ref=e534]
|
||||
- generic [ref=e536]: 基础信息管理
|
||||
- img [ref=e538]
|
||||
- menu [ref=e540]:
|
||||
- menuitem "客户管理" [ref=e541] [cursor=pointer]:
|
||||
- generic [ref=e542]: 客户管理
|
||||
- menuitem "细胞样本类型管理" [ref=e543] [cursor=pointer]:
|
||||
- generic [ref=e544]: 细胞样本类型管理
|
||||
- menuitem "采集地区及医院管理" [ref=e545] [cursor=pointer]:
|
||||
- generic [ref=e546]: 采集地区及医院管理
|
||||
- menuitem "采集协议号管理" [ref=e547] [cursor=pointer]:
|
||||
- generic [ref=e548]: 采集协议号管理
|
||||
- menuitem "样本编码管理" [ref=e549] [cursor=pointer]:
|
||||
- generic [ref=e550]: 样本编码管理
|
||||
- menuitem "细胞存储管理" [expanded] [ref=e551]:
|
||||
- generic [ref=e552] [cursor=pointer]:
|
||||
- img [ref=e555]
|
||||
- generic [ref=e557]: 细胞存储管理
|
||||
- img [ref=e559]
|
||||
- menu [ref=e561]:
|
||||
- menuitem "采集协议单" [ref=e562] [cursor=pointer]:
|
||||
- generic [ref=e563]: 采集协议单
|
||||
- menuitem "采集协议单审核" [ref=e564] [cursor=pointer]:
|
||||
- generic [ref=e565]: 采集协议单审核
|
||||
- menuitem "收款确认(财务)" [ref=e566] [cursor=pointer]:
|
||||
- generic [ref=e567]: 收款确认(财务)
|
||||
- menuitem "退费确认(财务)" [ref=e568] [cursor=pointer]:
|
||||
- generic [ref=e569]: 退费确认(财务)
|
||||
- menuitem "分期缴费预警" [ref=e570] [cursor=pointer]:
|
||||
- generic [ref=e571]: 分期缴费预警
|
||||
- menuitem "工作流管理" [expanded] [ref=e572]:
|
||||
- generic [ref=e573] [cursor=pointer]:
|
||||
- img [ref=e576]
|
||||
- generic [ref=e579]: 工作流管理
|
||||
- img [ref=e581]
|
||||
- menu [ref=e583]:
|
||||
- menuitem "流程配置" [ref=e584] [cursor=pointer]:
|
||||
- generic [ref=e585]: 流程配置
|
||||
- menuitem "待办中心" [ref=e586] [cursor=pointer]:
|
||||
- generic [ref=e587]: 待办中心
|
||||
- menuitem "已办中心" [ref=e588] [cursor=pointer]:
|
||||
- generic [ref=e589]: 已办中心
|
||||
- menuitem "我的申请" [ref=e590] [cursor=pointer]:
|
||||
- generic [ref=e591]: 我的申请
|
||||
- menuitem "请假申请" [ref=e592] [cursor=pointer]:
|
||||
- generic [ref=e593]: 请假申请
|
||||
- main [ref=e594]:
|
||||
- generic [ref=e596]:
|
||||
- generic [ref=e597]:
|
||||
- generic [ref=e598]:
|
||||
- img [ref=e601] [cursor=pointer]
|
||||
- img [ref=e605] [cursor=pointer]
|
||||
- navigation "breadcrumb" [ref=e607]:
|
||||
- list [ref=e608]:
|
||||
- listitem [ref=e609]:
|
||||
- generic [ref=e611]: 工作台
|
||||
- generic [ref=e612]:
|
||||
- img [ref=e615] [cursor=pointer]
|
||||
- img [ref=e619] [cursor=pointer]
|
||||
- generic [ref=e620]:
|
||||
- list [ref=e622]:
|
||||
- listitem [ref=e623] [cursor=pointer]: 工作台
|
||||
- img [ref=e627] [cursor=pointer]
|
||||
- generic [ref=e631]:
|
||||
- generic [ref=e632]:
|
||||
- heading "工作台" [level=1] [ref=e633]
|
||||
- paragraph [ref=e634]: 以下为固定参考页,新增管理端界面请对照布局与类名;视觉以文档为准。
|
||||
- generic [ref=e635]:
|
||||
- heading "快速入口" [level=2] [ref=e637]: 快速入口
|
||||
- generic [ref=e639]:
|
||||
- paragraph [ref=e640]: "详见仓库根目录 `.cursorrules` 中「页面级 UI 模板(固定五页)」。"
|
||||
- generic [ref=e641]:
|
||||
- button "商品单位" [ref=e642] [cursor=pointer]:
|
||||
- generic [ref=e643]: 商品单位
|
||||
- button "用户管理" [ref=e644] [cursor=pointer]:
|
||||
- generic [ref=e645]: 用户管理
|
||||
- button "商品分类" [ref=e646] [cursor=pointer]:
|
||||
- generic [ref=e647]: 商品分类
|
||||
- button "租户管理" [ref=e648] [cursor=pointer]:
|
||||
- generic [ref=e649]: 租户管理
|
||||
- button "客户管理" [ref=e650] [cursor=pointer]:
|
||||
- generic [ref=e651]: 客户管理
|
||||
- generic [ref=e1]:
|
||||
- generic "Toggle devtools panel" [ref=e2] [cursor=pointer]:
|
||||
- img [ref=e3]
|
||||
- generic "Toggle Component Inspector" [ref=e8] [cursor=pointer]:
|
||||
- img [ref=e9]
|
||||
- alert [ref=e652]:
|
||||
- img [ref=e654]
|
||||
- generic [ref=e656]:
|
||||
- heading "登录成功" [level=2] [ref=e657]
|
||||
- paragraph [ref=e659]: 欢迎回来,系统管理员!
|
||||
- img [ref=e661] [cursor=pointer]
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<h2>细胞存储管理(2.2.6 ~ 2.2.10)模块总览</h2>
|
||||
<p class="subtitle">用于对齐:菜单结构、核心数据表、关键状态流转与后续页面原型拆分</p>
|
||||
|
||||
<div class="section">
|
||||
<div class="label">菜单(本次范围)</div>
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="card-image">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">2.2.6 采集协议单</div>
|
||||
<div class="mockup-body">
|
||||
<div class="mock-content">
|
||||
<div>列表:协议号 / 状态 / 采集专员 / 登记日期 / 创建时间</div>
|
||||
<div class="placeholder" style="margin-top:10px">详情抽屉/弹窗(基础信息 + 样本 + 缴费 + 附件)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>采集协议单</h3>
|
||||
<p>主表:<code>biz_collection_protocol</code>,关联采集信息/样本/运输/当事人/缴费等子表</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-image">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">2.2.7 采集协议单审核</div>
|
||||
<div class="mockup-body">
|
||||
<div class="mock-content">
|
||||
<div>列表:待审核 / 已审核(通过/退回)</div>
|
||||
<div class="placeholder" style="margin-top:10px">审核弹窗:审核意见 + 操作(通过/退回)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>协议单审核</h3>
|
||||
<p>表:<code>biz_protocol_audit</code>(按协议单维度记录审核结果/备注/审核人)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-image">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">2.2.8 收款确认(财务)</div>
|
||||
<div class="mockup-body">
|
||||
<div class="mock-content">
|
||||
<div>列表:待确认缴费(按缴费明细)</div>
|
||||
<div class="placeholder" style="margin-top:10px">确认弹窗:确认附件 + 备注</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>收款确认</h3>
|
||||
<p>表:<code>biz_protocol_payment_detail</code>(<code>pay_status=dfj/jfqrz/yjf</code> 等)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-image">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">2.2.9 退费确认(财务)</div>
|
||||
<div class="mockup-body">
|
||||
<div class="mock-content">
|
||||
<div>列表:待确认退费(按缴费明细)</div>
|
||||
<div class="placeholder" style="margin-top:10px">确认弹窗:退费附件 + 备注</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>退费确认</h3>
|
||||
<p>表:同上(<code>pay_status=tfqrz/ytf</code>)+ 财务确认附件字段</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-image">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">2.2.10 分期缴费预警</div>
|
||||
<div class="mockup-body">
|
||||
<div class="mock-content">
|
||||
<div>列表:即将到期/已逾期(按分期明细)</div>
|
||||
<div class="placeholder" style="margin-top:10px">快捷操作:提醒/标记处理(可选)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>分期缴费预警</h3>
|
||||
<p>字段:<code>installment_due_date</code>(明细应缴日)+ 状态过滤(未缴费/确认中)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="label">关键状态流转(建议先对齐)</div>
|
||||
<div class="split">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">协议单状态(biz_collection_protocol.protocol_status)</div>
|
||||
<div class="mockup-body">
|
||||
<div class="mock-content">
|
||||
<p><b>dsx</b> 待生效 → <b>sxz</b> 生效中 →(到期)→ <b>ygq</b> 已过期</p>
|
||||
<p>任意阶段可 → <b>yzf</b> 已作废(毁约/异常终止/作废)</p>
|
||||
<p>可选:→ <b>zky</b> 转科研(后续再做,当前仅保留字段/入口)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">缴费明细状态(biz_protocol_payment_detail.pay_status)</div>
|
||||
<div class="mockup-body">
|
||||
<div class="mock-content">
|
||||
<p><b>dfj</b> 待缴费 → <b>jfqrz</b> 缴费确认中 → <b>yjf</b> 已缴费</p>
|
||||
<p><b>tfqrz</b> 退费确认中 → <b>ytf</b> 已退费(通常由业务发起后进入)</p>
|
||||
<p class="subtitle" style="margin-top:10px">收款确认页面主要处理:<b>jfqrz → yjf</b>;退费确认页面主要处理:<b>tfqrz → ytf</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="label">下一步我需要你确认的 1 个问题</div>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">审核模块(2.2.7)的定义边界</div>
|
||||
<div class="mockup-body">
|
||||
<div class="options">
|
||||
<div class="option" data-choice="audit-1" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>仅“协议附件/缴费截图”审核</h3>
|
||||
<p>审核通过后仅写 <code>biz_protocol_audit</code> 记录,不改协议单主状态(状态仍由业务流程控制)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option" data-choice="audit-2" onclick="toggleSelect(this)">
|
||||
<div class="letter">B</div>
|
||||
<div class="content">
|
||||
<h3>审核通过会驱动协议单生效</h3>
|
||||
<p>审核通过时同步将 <code>protocol_status</code> 从 <code>dsx</code> 推到 <code>sxz</code>(并记录生效时间/描述)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="subtitle" style="margin-top:10px">你在浏览器里点选 A 或 B 即可(也可以回终端用文字说明)。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<h2>2.2.10 分期缴费用户预警:规则确认</h2>
|
||||
<p class="subtitle">目标:确定“预警/逾期”的判定口径,便于后端列表筛选与前端状态标签统一</p>
|
||||
|
||||
<div class="section">
|
||||
<div class="label">数据来源(已确定)</div>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">biz_protocol_payment_detail</div>
|
||||
<div class="mockup-body">
|
||||
<div class="mock-content">
|
||||
<p>按 <code>installment_due_date</code>(YYYYMMDD)计算;仅展示分期明细(<code>installment_no</code> 不为空)</p>
|
||||
<p>过滤状态:通常只看 <code>dfj</code>(待缴费)与(可选)<code>jfqrz</code>(确认中)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="label">请选择预警口径(单选)</div>
|
||||
<div class="options">
|
||||
<div class="option" data-choice="warn-7" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>预警=未来 7 天内到期;逾期=已超过到期日</h3>
|
||||
<p>最常见口径;页面分两块:即将到期(0~7天) / 已逾期(<0天)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option" data-choice="warn-3" onclick="toggleSelect(this)">
|
||||
<div class="letter">B</div>
|
||||
<div class="content">
|
||||
<h3>预警=未来 3 天内到期;逾期=已超过到期日</h3>
|
||||
<p>更紧迫;减少列表噪音</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option" data-choice="warn-15" onclick="toggleSelect(this)">
|
||||
<div class="letter">C</div>
|
||||
<div class="content">
|
||||
<h3>预警=未来 15 天内到期;逾期=已超过到期日</h3>
|
||||
<p>更早提醒;适合人工跟进周期长的场景</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option" data-choice="warn-custom" onclick="toggleSelect(this)">
|
||||
<div class="letter">D</div>
|
||||
<div class="content">
|
||||
<h3>预警天数可配置(字典/配置表/环境变量)</h3>
|
||||
<p>灵活,但实现与运维成本更高;需要补一处配置入口</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="label">另外 1 个小点(默认建议)</div>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">确认:是否把“确认中 jfqrz”也纳入预警列表?</div>
|
||||
<div class="mockup-body">
|
||||
<div class="options">
|
||||
<div class="option" data-choice="include-jfqrz-no" onclick="toggleSelect(this)">
|
||||
<div class="letter">1</div>
|
||||
<div class="content">
|
||||
<h3>不纳入</h3>
|
||||
<p>预警只针对“待缴费 dfj”,避免和财务确认页面重复</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option" data-choice="include-jfqrz-yes" onclick="toggleSelect(this)">
|
||||
<div class="letter">2</div>
|
||||
<div class="content">
|
||||
<h3>纳入</h3>
|
||||
<p>把“待缴费 + 确认中”都算未完成付款,有助于跟进</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="subtitle" style="margin-top:10px">你可以点 A/B/C/D,再点 1 或 2;也可以直接在终端回复(例如:A + 1)。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"owner process exited","timestamp":1778664344125}
|
||||
@@ -0,0 +1 @@
|
||||
78976
|
||||
@@ -25,6 +25,11 @@ Full specs: `openspec/changes/zkxw-engineering-standards/specs/*/spec.md`. Below
|
||||
- **Dialog grid**: Default 2-column (`.form-grid-2`). Max 5 columns. Full-width fields use `.form-col-span-2` (`grid-column: 1 / -1`). Narrow viewport SHALL fallback to fewer columns.
|
||||
- **List panel heading**: `panel-top` → `h2.title-bar-heading` SHALL show primary-color vertical bar via `::before`. Ambiguous titles (e.g. "租户" containing "户") SHALL be registered in `App.vue` `panelTitleBarTextRules`.
|
||||
- **Miniprogram media**: Thumbnails 1:1 `aspectFill`; carousel 4:3 `aspectFill`; detail images `widthFix`. Min tap target 44×44px.
|
||||
- **Sidebar menu icons**: First-level menu items SHALL show icons before the name; second-level and deeper items SHALL NOT show icons (text only). Implemented via `level === 0` check in `SidebarSubmenu.vue`.
|
||||
- **Tab bar (WorkTab) icons**: Tab bar tabs SHALL NOT show icons — only text titles. `meta.icon` is for sidebar only.
|
||||
- **Form field annotations**: Field hints (e.g. "数值越小越靠前") SHALL appear as small text after the label via `<template #label>字段名<span class="form-label-tip">注释</span></template>`, NOT beside the input control. Style class: `.form-label-tip` (12px, `--mint-text-muted`).
|
||||
- **Attachment upload card layout**: Upload trigger SHALL be a card (120×96px, dashed border `#a7f3d0`) placed inline as the first item in the same flex row as uploaded attachment cards. Hint text below the row. See `openspec/.../ui-conventions/spec.md` "附件上传卡片化布局" for full spec + CSS reference.
|
||||
- **Sort fields (sort_order/orderNum)**: web-admin 列表、新增弹窗、编辑弹窗中默认不展示排序字段。后续根据功能需要自行添加。
|
||||
- **Template pages** — new pages SHALL match these exactly:
|
||||
- Standard list: `src/views/system/unit/index.vue`
|
||||
- Complex list: `src/views/system/order/index.vue`
|
||||
@@ -36,7 +41,7 @@ Full specs: `openspec/changes/zkxw-engineering-standards/specs/*/spec.md`. Below
|
||||
|
||||
- **Tenant isolation**: All business data queries SHALL filter by current user's `tenant_no`. All write/update endpoints SHALL set `tenant_no` to the current user's tenant. MUST NOT trust client-submitted tenant overrides (unless multi-tenant admin API with security review).
|
||||
- **Data permission**: All business queries SHALL apply data scope filtering based on the union of all roles assigned to the current user (menu + data permissions both use union semantics).
|
||||
- **Default sort**: Lists SHALL sort by `order_num ASC, create_time DESC` when no custom sort is specified.
|
||||
- **Default sort**: Lists SHALL sort by `create_time DESC` when no custom sort is specified.
|
||||
- **Route registration**: New route modules SHALL be registered in `app/api/__init__.py` AND `app/main.py`. SHALL NOT bypass auth/crypto/audit middleware.
|
||||
|
||||
### Database Conventions
|
||||
@@ -108,7 +113,7 @@ cp .env.example .env.local # fill in DB, Redis, MinIO, etc.
|
||||
- Auth: JWT + RSA-encrypted login. Login RSA key auto-generated or set via `LOGIN_RSA_PRIVATE_KEY_PEM` env var.
|
||||
- API encryption middleware: symmetric crypto layer controlled by `API_ENCRYPT_*` env vars. Excluded paths listed in `API_ENCRYPT_EXCLUDE_PATHS`.
|
||||
- Config: pydantic-settings (`app/core/config.py`), reads from `.env` / `.env.local` / `.env.production`.
|
||||
- Database init SQL: `docs/sql/zkxw-requirements-v0.3-schema.sql`. Default admin: `admin` / `111111`, role `super_admin`.
|
||||
- Database init / schema baseline: `docs/sql/zkxw.sql`(从当前库导出的全量 SQL)。Default admin: `admin` / `111111`, role `super_admin`.
|
||||
- Swagger docs at `/docs`, ReDoc at `/redoc`.
|
||||
- Tests: minimal, only `tests/test_api_crypto.py`. Run with `pytest`.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 数据库连接配置示例(V0.3 库名示例见 docs/sql/zkxw-requirements-v0.3-schema.sql)
|
||||
# 数据库连接配置示例(库名/表结构以 docs/sql/zkxw.sql 全量导出为准)
|
||||
# DATABASE_URL 格式:mysql+pymysql://user:password@host:port/dbname
|
||||
DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
|
||||
|
||||
@@ -43,6 +43,10 @@ DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
|
||||
# MINIO_SECURE=false
|
||||
# MINIO_BASE_URL=https://www.chengyantek.cn/sanqiang
|
||||
|
||||
# 全量 API 请求日志(写入 sys_api_request_log;无表时会 warning「api_request_log persist failed」)
|
||||
# 解决二选一:① 在库中执行 docs/sql/optional-sys-api-request-log.sql 建表 ② 关闭写入:
|
||||
# API_REQUEST_LOG_ENABLED=false
|
||||
|
||||
# 微信支付配置示例
|
||||
# 小程序支付使用 JSAPI(trade_type=JSAPI)
|
||||
# WXPAY_MCHID=1900000109
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
基于 FastAPI 的精简后端:**Web 鉴权**(登录 RSA、登录、刷新 Token、登出、当前用户、菜单 code)、**静态运营菜单树**、**运营端路由占位**。原叁强鲜配业务 API 已移除,可按模块在 `app/api/admin.py` 等位置扩展。
|
||||
|
||||
数据库最小初始化见:`docs/sql/zkxw-backend-skeleton.sql`(默认账号 `admin` / `111111`,角色 `super_admin`)。
|
||||
数据库结构与种子数据基线见:`docs/sql/zkxw.sql`(全库导出;默认账号 `admin` / `111111`,角色 `super_admin`)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
9695
|
||||
92212
|
||||
@@ -2,9 +2,21 @@
|
||||
from app.api.public import router as public_router
|
||||
from app.api.menu import router as menu_router
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.workflow import router as workflow_router
|
||||
from app.api.basic_info import router as basic_info_router
|
||||
from app.api.collection_protocol import router as collection_protocol_router
|
||||
from app.api.leave import router as leave_router
|
||||
from app.api.operations import router as operations_router
|
||||
from app.api.ethnic import router as ethnic_router
|
||||
|
||||
__all__ = [
|
||||
"public_router",
|
||||
"menu_router",
|
||||
"admin_router",
|
||||
"workflow_router",
|
||||
"basic_info_router",
|
||||
"collection_protocol_router",
|
||||
"leave_router",
|
||||
"operations_router",
|
||||
"ethnic_router",
|
||||
]
|
||||
+155
-3
@@ -1,13 +1,17 @@
|
||||
"""运营端管理接口(租户、角色下拉等)"""
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import success_response, error_response
|
||||
from app.core.storage import storage
|
||||
from app.crud import tenant as tenant_svc
|
||||
from app.crud import dict_type as dict_type_svc
|
||||
from app.crud import dict_data as dict_data_svc
|
||||
@@ -19,7 +23,7 @@ from app.crud import express_company as express_company_svc
|
||||
from app.crud import menu as menu_svc
|
||||
from app.crud import role as role_svc
|
||||
from app.crud import user as user_svc
|
||||
from app.models.models import SysRole, SysUser
|
||||
from app.models.models import BizAttachment, SysRole, SysUser
|
||||
from app.services.web_auth_roles import _map_role_name_to_web
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["运营端管理"])
|
||||
@@ -39,7 +43,7 @@ async def list_roles_for_select(
|
||||
rows: List[SysRole] = (
|
||||
db.query(SysRole)
|
||||
.filter(SysRole.del_flag == 0, SysRole.status == "1")
|
||||
.order_by(SysRole.order_num.asc())
|
||||
.order_by(SysRole.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
seen: set[str] = set()
|
||||
@@ -62,6 +66,37 @@ async def list_roles_for_select(
|
||||
return success_response(out)
|
||||
|
||||
|
||||
@router.get("/users/options")
|
||||
async def list_users_for_select(
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""返回用户下拉选项(label=昵称,value=用户ID),供流程设计器审批人选择使用。"""
|
||||
tenant_no = _user.get("tenant_no", "")
|
||||
q = db.query(SysUser).filter(SysUser.del_flag == 0, SysUser.status == "1")
|
||||
if tenant_no:
|
||||
q = q.filter(SysUser.tenant_no == tenant_no)
|
||||
rows = q.order_by(SysUser.create_time.desc()).all()
|
||||
out = [{"label": (r.nick_name or r.user_no or ""), "value": r.id} for r in rows]
|
||||
return success_response(out)
|
||||
|
||||
|
||||
@router.get("/roles/options")
|
||||
async def list_roles_for_designer(
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""返回角色下拉选项(label=角色名,value=角色ID UUID),供流程设计器审批人选择使用。
|
||||
与 /roles 不同,此端点返回角色 UUID 作为 value(引擎解析审批人需要 UUID)。"""
|
||||
tenant_no = _user.get("tenant_no", "")
|
||||
q = db.query(SysRole).filter(SysRole.del_flag == 0, SysRole.status == "1")
|
||||
if tenant_no:
|
||||
q = q.filter(SysRole.tenant_no == tenant_no)
|
||||
rows = q.order_by(SysRole.create_time.desc()).all()
|
||||
out = [{"label": (r.role_name or "").strip(), "value": r.id} for r in rows if (r.role_name or "").strip()]
|
||||
return success_response(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 租户管理
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -239,6 +274,22 @@ async def list_users(
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def get_user_by_id(
|
||||
user_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
row = user_svc.get_user(db, user_id)
|
||||
if not row:
|
||||
return error_response(404, "用户不存在")
|
||||
if row.tenant_no != tenant_no and user.get("role") != "super_admin":
|
||||
return error_response(403, "无权查看该用户")
|
||||
data = user_svc.get_user_with_relations(db, user_id)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(
|
||||
body: UserCreateBody,
|
||||
@@ -862,6 +913,46 @@ async def delete_post(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
class PostUsersUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
user_ids: List[str] = Field(default_factory=list, alias="userIds")
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}/users")
|
||||
async def list_post_bound_users(
|
||||
post_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
post = post_svc.get_post(db, post_id)
|
||||
if not post or post.tenant_no != tenant_no:
|
||||
return error_response(404, "职位不存在")
|
||||
ids = user_svc.list_user_ids_bound_to_post(db, post_id, tenant_no)
|
||||
return success_response({"userIds": ids})
|
||||
|
||||
|
||||
@router.put("/posts/{post_id}/users")
|
||||
async def update_post_bound_users(
|
||||
post_id: str,
|
||||
body: PostUsersUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
post = post_svc.get_post(db, post_id)
|
||||
if not post or post.tenant_no != tenant_no:
|
||||
return error_response(404, "职位不存在")
|
||||
try:
|
||||
user_svc.sync_post_user_bindings(
|
||||
db, post_id, tenant_no, body.user_ids, user.get("user_id")
|
||||
)
|
||||
return success_response(None, message="保存成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 物品单位管理
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1340,3 +1431,64 @@ async def update_role_data_scope(
|
||||
return success_response(None, message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 附件上传
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_file_size(size_bytes: int) -> str:
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
|
||||
|
||||
@router.post("/attachment/upload")
|
||||
async def upload_attachment(
|
||||
file: UploadFile = File(...),
|
||||
folder: Optional[str] = Form(""),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not file or not file.filename:
|
||||
return error_response(400, "请选择文件")
|
||||
try:
|
||||
file_content = await file.read()
|
||||
full_url = storage.upload_file(file_content, file.filename, folder or "")
|
||||
base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
|
||||
if base_url and full_url.startswith(base_url + "/"):
|
||||
file_url = full_url[len(base_url) + 1:]
|
||||
else:
|
||||
file_url = full_url
|
||||
file_size = len(file_content)
|
||||
file_ext = os.path.splitext(file.filename)[1].lstrip(".") if file.filename else ""
|
||||
file_type = file.content_type or ""
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
record = BizAttachment(
|
||||
id=uuid.uuid4().hex,
|
||||
original_name=file.filename,
|
||||
file_ext=file_ext,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
file_type=file_type,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user.get("user_id"),
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return success_response({
|
||||
"id": record.id,
|
||||
"fileName": record.original_name,
|
||||
"fileUrl": record.file_url,
|
||||
"fileSize": record.file_size,
|
||||
"fileSizeDisplay": _format_file_size(record.file_size or 0),
|
||||
"fileType": record.file_type,
|
||||
})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return error_response(500, f"上传失败: {str(e)}")
|
||||
@@ -0,0 +1,860 @@
|
||||
"""基础信息管理 API(客户、样本类型、采集地区/医院、协议号池、样本编码池)"""
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import cell_sample_type as cell_type_svc
|
||||
from app.crud import collection_region as region_svc
|
||||
from app.crud import collection_protocol_no as protocol_no_svc
|
||||
from app.crud import customer as customer_svc
|
||||
from app.crud import sample_code as sample_code_svc
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["基础信息管理"])
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 客户
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CustomerCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
customer_name: str = Field(..., alias="customerName")
|
||||
sex: Optional[str] = Field("2", alias="sex")
|
||||
id_card_no: Optional[str] = Field(None, alias="idCardNo")
|
||||
age: Optional[int] = None
|
||||
phone_no: Optional[str] = Field(None, alias="phoneNo")
|
||||
customer_tags: Optional[str] = Field(None, alias="customerTags")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class CustomerUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
customer_name: Optional[str] = Field(None, alias="customerName")
|
||||
sex: Optional[str] = None
|
||||
id_card_no: Optional[str] = Field(None, alias="idCardNo")
|
||||
age: Optional[int] = None
|
||||
phone_no: Optional[str] = Field(None, alias="phoneNo")
|
||||
customer_tags: Optional[str] = Field(None, alias="customerTags")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/customers")
|
||||
async def list_customers(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = customer_svc.list_customers(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
tp = customer_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/customers")
|
||||
async def create_customer(
|
||||
body: CustomerCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = customer_svc.create_customer(
|
||||
db,
|
||||
customer_name=body.customer_name,
|
||||
sex=body.sex or "2",
|
||||
id_card_no=body.id_card_no,
|
||||
age=body.age,
|
||||
phone_no=body.phone_no,
|
||||
customer_tags=body.customer_tags,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(customer_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/customers/{customer_id}")
|
||||
async def update_customer(
|
||||
customer_id: str,
|
||||
body: CustomerUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = customer_svc.update_customer(
|
||||
db,
|
||||
customer_id,
|
||||
customer_name=payload.get("customer_name"),
|
||||
sex=payload.get("sex"),
|
||||
id_card_no=payload.get("id_card_no"),
|
||||
age=payload.get("age"),
|
||||
phone_no=payload.get("phone_no"),
|
||||
customer_tags=payload.get("customer_tags"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(customer_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/customers/{customer_id}")
|
||||
async def delete_customer(
|
||||
customer_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
customer_svc.soft_delete_customer(db, customer_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 细胞样本类型
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CellSampleTypeCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
type_code: str = Field(..., alias="typeCode")
|
||||
type_name: str = Field(..., alias="typeName")
|
||||
storage_years: Optional[int] = Field(None, alias="storageYears")
|
||||
storage_price: Optional[float] = Field(None, alias="storagePrice")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class CellSampleTypeUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
type_code: Optional[str] = Field(None, alias="typeCode")
|
||||
type_name: Optional[str] = Field(None, alias="typeName")
|
||||
storage_years: Optional[int] = Field(None, alias="storageYears")
|
||||
storage_price: Optional[float] = Field(None, alias="storagePrice")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/cell-sample-types")
|
||||
async def list_cell_sample_types(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = cell_type_svc.list_cell_sample_types(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
|
||||
)
|
||||
tp = cell_type_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/cell-sample-types")
|
||||
async def create_cell_sample_type(
|
||||
body: CellSampleTypeCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = cell_type_svc.create_cell_sample_type(
|
||||
db,
|
||||
type_code=body.type_code,
|
||||
type_name=body.type_name,
|
||||
storage_years=body.storage_years,
|
||||
storage_price=body.storage_price,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(cell_type_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/cell-sample-types/{row_id}")
|
||||
async def update_cell_sample_type(
|
||||
row_id: str,
|
||||
body: CellSampleTypeUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = cell_type_svc.update_cell_sample_type(
|
||||
db,
|
||||
row_id,
|
||||
type_code=payload.get("type_code"),
|
||||
type_name=payload.get("type_name"),
|
||||
storage_years=payload.get("storage_years"),
|
||||
storage_price=payload.get("storage_price"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(cell_type_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/cell-sample-types/{row_id}")
|
||||
async def delete_cell_sample_type(
|
||||
row_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
cell_type_svc.soft_delete_cell_sample_type(db, row_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 采集地区
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CollectionRegionCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
region_name: str = Field(..., alias="regionName")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class CollectionRegionUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
region_name: Optional[str] = Field(None, alias="regionName")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/collection-regions")
|
||||
async def list_collection_regions(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = region_svc.list_collection_regions(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
|
||||
)
|
||||
tp = region_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/collection-regions")
|
||||
async def create_collection_region(
|
||||
body: CollectionRegionCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = region_svc.create_collection_region(
|
||||
db,
|
||||
region_name=body.region_name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(region_svc._region_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/collection-regions/{region_id}")
|
||||
async def update_collection_region(
|
||||
region_id: str,
|
||||
body: CollectionRegionUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = region_svc.update_collection_region(
|
||||
db,
|
||||
region_id,
|
||||
region_name=payload.get("region_name"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(region_svc._region_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/collection-regions/{region_id}")
|
||||
async def delete_collection_region(
|
||||
region_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
region_svc.soft_delete_collection_region(db, region_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 采集地区 — 下属医院
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HospitalCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
hospital_name: str = Field(..., alias="hospitalName")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class HospitalUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
hospital_name: Optional[str] = Field(None, alias="hospitalName")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/collection-regions/{region_id}/hospitals")
|
||||
async def list_hospitals(
|
||||
region_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = region_svc.list_hospitals(
|
||||
db, region_id=region_id, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
|
||||
)
|
||||
tp = region_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/collection-regions/{region_id}/hospitals")
|
||||
async def create_hospital(
|
||||
region_id: str,
|
||||
body: HospitalCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = region_svc.create_hospital(
|
||||
db,
|
||||
region_id=region_id,
|
||||
hospital_name=body.hospital_name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(region_svc._hospital_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/collection-regions/{region_id}/hospitals/{hospital_id}")
|
||||
async def update_hospital(
|
||||
region_id: str,
|
||||
hospital_id: str,
|
||||
body: HospitalUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
h = region_svc.get_hospital(db, hospital_id, tenant_no=tenant_no)
|
||||
if not h or h.region_id != region_id:
|
||||
return error_response(400, "医院不存在")
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = region_svc.update_hospital(
|
||||
db,
|
||||
hospital_id,
|
||||
hospital_name=payload.get("hospital_name"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(region_svc._hospital_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/collection-regions/{region_id}/hospitals/{hospital_id}")
|
||||
async def delete_hospital(
|
||||
region_id: str,
|
||||
hospital_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
h = region_svc.get_hospital(db, hospital_id, tenant_no=tenant_no)
|
||||
if not h or h.region_id != region_id:
|
||||
return error_response(400, "医院不存在")
|
||||
region_svc.soft_delete_hospital(db, hospital_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 采集协议号池
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProtocolNoCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
protocol_no: str = Field(..., alias="protocolNo")
|
||||
issued: int = 0
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ProtocolNoUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
protocol_no: Optional[str] = Field(None, alias="protocolNo")
|
||||
issued: Optional[int] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
issued_user_id: Optional[str] = Field(None, alias="issuedUserId")
|
||||
issued_user_name: Optional[str] = Field(None, alias="issuedUserName")
|
||||
issued_time: Optional[str] = Field(None, alias="issuedTime")
|
||||
|
||||
|
||||
@router.get("/collection-protocol-nos")
|
||||
async def list_protocol_nos(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = protocol_no_svc.list_protocol_nos(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
|
||||
)
|
||||
tp = protocol_no_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
class ProtocolNoBatchGenerateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
prefix: str = Field("ZKZX", alias="prefix")
|
||||
middle: str = Field("34", alias="middle")
|
||||
seq_start: int = Field(..., alias="seqStart")
|
||||
seq_end: int = Field(..., alias="seqEnd")
|
||||
digit_count: int = Field(6, alias="digitCount")
|
||||
|
||||
|
||||
class ProtocolNoBatchIssueBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
ids: List[str] = Field(..., alias="ids")
|
||||
issued_user_id: str = Field(..., alias="issuedUserId")
|
||||
issued_user_name: Optional[str] = Field(None, alias="issuedUserName")
|
||||
|
||||
|
||||
@router.post("/collection-protocol-nos/batch-generate")
|
||||
async def batch_generate_protocol_nos(
|
||||
body: ProtocolNoBatchGenerateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
result = protocol_no_svc.batch_generate_protocol_nos(
|
||||
db,
|
||||
prefix=body.prefix,
|
||||
middle=body.middle,
|
||||
seq_start=body.seq_start,
|
||||
seq_end=body.seq_end,
|
||||
digit_count=body.digit_count,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
msg = f"成功生成 {result['created']} 条"
|
||||
if result['skipped'] > 0:
|
||||
msg += f",跳过已存在 {result['skipped']} 条"
|
||||
return success_response(result, message=msg)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/collection-protocol-nos/batch-issue")
|
||||
async def batch_issue_protocol_nos(
|
||||
body: ProtocolNoBatchIssueBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
result = protocol_no_svc.batch_issue_protocol_nos(
|
||||
db,
|
||||
ids=body.ids,
|
||||
issued_user_id=body.issued_user_id,
|
||||
issued_user_name=body.issued_user_name,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
msg = f"成功下发 {result['issuedCount']} 条"
|
||||
if result['alreadyIssued']:
|
||||
msg += f",{len(result['alreadyIssued'])} 条已下发被跳过"
|
||||
return success_response(result, message=msg)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
class ProtocolNoBatchRevokeBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
ids: List[str] = Field(..., alias="ids")
|
||||
|
||||
|
||||
@router.post("/collection-protocol-nos/batch-revoke")
|
||||
async def batch_revoke_protocol_nos(
|
||||
body: ProtocolNoBatchRevokeBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
result = protocol_no_svc.batch_revoke_protocol_nos(
|
||||
db,
|
||||
ids=body.ids,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
msg = f"成功收回 {result['revokedCount']} 条"
|
||||
if result["notIssued"]:
|
||||
msg += f",{len(result['notIssued'])} 条未下发已跳过"
|
||||
return success_response(result, message=msg)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/collection-protocol-nos")
|
||||
async def create_protocol_no(
|
||||
body: ProtocolNoCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = protocol_no_svc.create_protocol_no(
|
||||
db,
|
||||
protocol_no=body.protocol_no,
|
||||
issued=body.issued,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(protocol_no_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/collection-protocol-nos/{row_id}")
|
||||
async def update_protocol_no(
|
||||
row_id: str,
|
||||
body: ProtocolNoUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = protocol_no_svc.update_protocol_no(
|
||||
db,
|
||||
row_id,
|
||||
protocol_no=payload.get("protocol_no"),
|
||||
issued=payload.get("issued"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
issued_user_id=payload.get("issued_user_id"),
|
||||
issued_user_name=payload.get("issued_user_name"),
|
||||
issued_time=payload.get("issued_time"),
|
||||
)
|
||||
return success_response(protocol_no_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/collection-protocol-nos/{row_id}")
|
||||
async def delete_protocol_no(
|
||||
row_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
protocol_no_svc.soft_delete_protocol_no(db, row_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 样本编码池
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SampleCodeCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
sample_code: str = Field(..., alias="sampleCode")
|
||||
issued: int = 0
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class SampleCodeUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
sample_code: Optional[str] = Field(None, alias="sampleCode")
|
||||
issued: Optional[int] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
issued_user_id: Optional[str] = Field(None, alias="issuedUserId")
|
||||
issued_user_name: Optional[str] = Field(None, alias="issuedUserName")
|
||||
issued_time: Optional[str] = Field(None, alias="issuedTime")
|
||||
|
||||
|
||||
class SampleCodeBatchGenerateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
sample_code_type: str = Field(..., alias="sampleCodeType")
|
||||
middle: str = Field("34", alias="middle")
|
||||
seq_start: int = Field(..., alias="seqStart")
|
||||
seq_end: int = Field(..., alias="seqEnd")
|
||||
digit_count: int = Field(6, alias="digitCount")
|
||||
|
||||
|
||||
class SampleCodeBatchIssueBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
ids: List[str] = Field(..., alias="ids")
|
||||
issued_user_id: str = Field(..., alias="issuedUserId")
|
||||
issued_user_name: Optional[str] = Field(None, alias="issuedUserName")
|
||||
|
||||
|
||||
@router.post("/sample-codes/batch-generate")
|
||||
async def batch_generate_sample_codes(
|
||||
body: SampleCodeBatchGenerateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
result = sample_code_svc.batch_generate_sample_codes(
|
||||
db,
|
||||
sample_code_type=body.sample_code_type,
|
||||
middle=body.middle,
|
||||
seq_start=body.seq_start,
|
||||
seq_end=body.seq_end,
|
||||
digit_count=body.digit_count,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
msg = f"成功生成 {result['created']} 条"
|
||||
if result["skipped"] > 0:
|
||||
msg += f",跳过已存在 {result['skipped']} 条"
|
||||
return success_response(result, message=msg)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/sample-codes/batch-issue")
|
||||
async def batch_issue_sample_codes(
|
||||
body: SampleCodeBatchIssueBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
result = sample_code_svc.batch_issue_sample_codes(
|
||||
db,
|
||||
ids=body.ids,
|
||||
issued_user_id=body.issued_user_id,
|
||||
issued_user_name=body.issued_user_name,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
msg = f"成功下发 {result['issuedCount']} 条"
|
||||
if result["alreadyIssued"]:
|
||||
msg += f",{len(result['alreadyIssued'])} 条已下发被跳过"
|
||||
return success_response(result, message=msg)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
class SampleCodeBatchRevokeBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
ids: List[str] = Field(..., alias="ids")
|
||||
|
||||
|
||||
@router.post("/sample-codes/batch-revoke")
|
||||
async def batch_revoke_sample_codes(
|
||||
body: SampleCodeBatchRevokeBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
result = sample_code_svc.batch_revoke_sample_codes(
|
||||
db,
|
||||
ids=body.ids,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
msg = f"成功收回 {result['revokedCount']} 条"
|
||||
if result["notIssued"]:
|
||||
msg += f",{len(result['notIssued'])} 条未下发已跳过"
|
||||
return success_response(result, message=msg)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/sample-codes")
|
||||
async def list_sample_codes(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = sample_code_svc.list_sample_codes(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
|
||||
)
|
||||
tp = sample_code_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/sample-codes")
|
||||
async def create_sample_code(
|
||||
body: SampleCodeCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = sample_code_svc.create_sample_code(
|
||||
db,
|
||||
sample_code=body.sample_code,
|
||||
issued=body.issued,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(sample_code_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/sample-codes/{row_id}")
|
||||
async def update_sample_code(
|
||||
row_id: str,
|
||||
body: SampleCodeUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = sample_code_svc.update_sample_code(
|
||||
db,
|
||||
row_id,
|
||||
sample_code=payload.get("sample_code"),
|
||||
issued=payload.get("issued"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
issued_user_id=payload.get("issued_user_id"),
|
||||
issued_user_name=payload.get("issued_user_name"),
|
||||
issued_time=payload.get("issued_time"),
|
||||
)
|
||||
return success_response(sample_code_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/sample-codes/{row_id}")
|
||||
async def delete_sample_code(
|
||||
row_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
sample_code_svc.soft_delete_sample_code(db, row_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -0,0 +1,513 @@
|
||||
"""细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from sqlalchemy import and_ as sa_and
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.core.utils import parse_datetime
|
||||
from app.crud import collection_protocol as cp_svc
|
||||
from app.crud import customer as customer_crud
|
||||
from app.models.models import BizCollectionProtocol, BizCustomer, SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["细胞存储"])
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
def _get_current_user_id(user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
return str(user_id)
|
||||
|
||||
|
||||
_CAMEL_TO_SNAKE_DATETIME = {
|
||||
"newbornDeliveryTime": "newborn_delivery_time",
|
||||
"sampleCollectTime": "sample_collect_time",
|
||||
"maternalBloodCollectTime": "maternal_blood_collect_time",
|
||||
}
|
||||
|
||||
|
||||
def _parse_datetime_fields(d: dict, camel_keys: list) -> None:
|
||||
for camel in camel_keys:
|
||||
val = d.get(camel)
|
||||
if val is not None and isinstance(val, str):
|
||||
snake = _CAMEL_TO_SNAKE_DATETIME.get(camel, camel)
|
||||
parsed = parse_datetime(val)
|
||||
if parsed:
|
||||
d.pop(camel, None)
|
||||
d[snake] = parsed
|
||||
else:
|
||||
d.pop(camel, None)
|
||||
d[snake] = None
|
||||
|
||||
|
||||
def _ensure_customer_for_mother(db: Session, party_customer: dict, tenant_no: str, user_id: str) -> dict:
|
||||
"""If mother info exists but no customerId, create a customer record and link it."""
|
||||
if not party_customer:
|
||||
return party_customer
|
||||
customer_id = party_customer.get("customer_id") or party_customer.get("customerId")
|
||||
mother_name = (party_customer.get("mother_name") or party_customer.get("motherName") or "").strip()
|
||||
if customer_id or not mother_name:
|
||||
return party_customer
|
||||
# Build customer fields from mother info
|
||||
mother_phone = (party_customer.get("mother_phone") or party_customer.get("motherPhone") or "").strip() or None
|
||||
mother_id_card = (party_customer.get("mother_id_card") or party_customer.get("motherIdCard") or "").strip() or None
|
||||
|
||||
# Check if customer with this id_card already exists
|
||||
if mother_id_card and customer_crud.id_card_taken(db, mother_id_card, tenant_no):
|
||||
existing = db.query(BizCustomer).filter(
|
||||
sa_and(
|
||||
BizCustomer.id_card_no == mother_id_card,
|
||||
BizCustomer.tenant_no == tenant_no,
|
||||
BizCustomer.del_flag == 0,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
party_customer["customer_id"] = existing.id
|
||||
return party_customer
|
||||
|
||||
# Create new customer
|
||||
try:
|
||||
obj = customer_crud.create_customer(
|
||||
db,
|
||||
customer_name=mother_name,
|
||||
sex="1", # female (mother)
|
||||
id_card_no=mother_id_card,
|
||||
phone_no=mother_phone,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
party_customer["customer_id"] = obj.id
|
||||
except ValueError:
|
||||
# If creation fails (e.g. duplicate), try to find existing
|
||||
pass
|
||||
return party_customer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 采集协议单
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/collection-protocols")
|
||||
async def list_collection_protocols(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
protocolStatus: Optional[str] = Query(None, alias="protocolStatus"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
current_user_id = _get_current_user_id(user)
|
||||
items, total = cp_svc.list_collection_protocols(
|
||||
db,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
protocol_status=protocolStatus,
|
||||
tenant_no=tenant_no,
|
||||
scope_collector_user_id=current_user_id,
|
||||
)
|
||||
tp = cp_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/collection-protocols/{protocol_id}")
|
||||
async def get_collection_protocol_detail(
|
||||
protocol_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
current_user_id = _get_current_user_id(user)
|
||||
bundle = cp_svc.get_protocol_detail_bundle(
|
||||
db, protocol_id, tenant_no, scope_collector_user_id=current_user_id
|
||||
)
|
||||
if not bundle:
|
||||
return error_response(404, "协议单不存在或无权查看")
|
||||
return success_response(bundle)
|
||||
|
||||
|
||||
class ProtocolCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
protocol_no: str = Field(..., alias="protocolNo")
|
||||
protocol_no_id: Optional[str] = Field(None, alias="protocolNoId")
|
||||
protocol_status: str = Field("dsx", alias="protocolStatus")
|
||||
protocol_attachments: Optional[str] = Field(None, alias="protocolAttachments")
|
||||
register_date: Optional[str] = Field(None, alias="registerDate")
|
||||
collector_user_id: Optional[str] = Field(None, alias="collectorUserId")
|
||||
remark: Optional[str] = None
|
||||
party_customer: Optional[dict] = Field(None, alias="partyCustomer")
|
||||
collection_info: Optional[dict] = Field(None, alias="collectionInfo")
|
||||
samples: Optional[list] = Field(None, alias="samples")
|
||||
payment: Optional[dict] = Field(None, alias="payment")
|
||||
payment_details: Optional[list] = Field(None, alias="paymentDetails")
|
||||
attachments: Optional[list] = Field(None, alias="attachments")
|
||||
storage_cert: Optional[dict] = Field(None, alias="storageCert")
|
||||
|
||||
|
||||
class ProtocolUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
protocol_no: Optional[str] = Field(None, alias="protocolNo")
|
||||
protocol_status: Optional[str] = Field(None, alias="protocolStatus")
|
||||
protocol_attachments: Optional[str] = Field(None, alias="protocolAttachments")
|
||||
register_date: Optional[str] = Field(None, alias="registerDate")
|
||||
effective_time: Optional[str] = Field(None, alias="effectiveTime")
|
||||
effective_desc: Optional[str] = Field(None, alias="effectiveDesc")
|
||||
expire_time: Optional[str] = Field(None, alias="expireTime")
|
||||
expire_desc: Optional[str] = Field(None, alias="expireDesc")
|
||||
void_time: Optional[str] = Field(None, alias="voidTime")
|
||||
void_desc: Optional[str] = Field(None, alias="voidDesc")
|
||||
to_research_time: Optional[str] = Field(None, alias="toResearchTime")
|
||||
to_research_desc: Optional[str] = Field(None, alias="toResearchDesc")
|
||||
collector_user_id: Optional[str] = Field(None, alias="collectorUserId")
|
||||
remark: Optional[str] = None
|
||||
party_customer: Optional[dict] = Field(None, alias="partyCustomer")
|
||||
collection_info: Optional[dict] = Field(None, alias="collectionInfo")
|
||||
samples: Optional[list] = Field(None, alias="samples")
|
||||
payment: Optional[dict] = Field(None, alias="payment")
|
||||
payment_details: Optional[list] = Field(None, alias="paymentDetails")
|
||||
attachments: Optional[list] = Field(None, alias="attachments")
|
||||
storage_cert: Optional[dict] = Field(None, alias="storageCert")
|
||||
|
||||
|
||||
def _resolve_protocol_attachments(
|
||||
protocol_attachments: Optional[str],
|
||||
attachments: Optional[list],
|
||||
) -> Optional[str]:
|
||||
from_urls = cp_svc.attachments_to_urls(attachments)
|
||||
if from_urls:
|
||||
return from_urls
|
||||
return protocol_attachments
|
||||
|
||||
|
||||
def _persist_protocol_sub_resources(
|
||||
db: Session,
|
||||
protocol_id: str,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
*,
|
||||
party_customer: Optional[dict],
|
||||
collection_info: Optional[dict],
|
||||
samples: Optional[list],
|
||||
payment: Optional[dict],
|
||||
payment_details: Optional[list],
|
||||
storage_cert: Optional[dict],
|
||||
) -> None:
|
||||
if party_customer:
|
||||
_parse_datetime_fields(party_customer, ["newbornDeliveryTime"])
|
||||
party_customer = _ensure_customer_for_mother(db, party_customer, tenant_no, user_id)
|
||||
cp_svc.upsert_party_customer(db, protocol_id, tenant_no, user_id, **party_customer)
|
||||
if collection_info:
|
||||
_parse_datetime_fields(collection_info, ["sampleCollectTime", "maternalBloodCollectTime"])
|
||||
cp_svc.upsert_collection_info(db, protocol_id, tenant_no, user_id, **collection_info)
|
||||
if samples is not None:
|
||||
cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples)
|
||||
if payment:
|
||||
cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment)
|
||||
if payment_details is not None:
|
||||
pay = cp_svc.get_payment_by_protocol_id(db, protocol_id, tenant_no)
|
||||
if not pay and payment:
|
||||
pay = cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment)
|
||||
if pay:
|
||||
cp_svc.save_payment_details(db, pay.id, tenant_no, user_id, payment_details)
|
||||
if storage_cert:
|
||||
cp_svc.upsert_storage_cert(db, protocol_id, tenant_no, user_id, **storage_cert)
|
||||
|
||||
|
||||
@router.post("/collection-protocols")
|
||||
async def create_collection_protocol(
|
||||
body: ProtocolCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
protocol_attachments = _resolve_protocol_attachments(
|
||||
body.protocol_attachments,
|
||||
body.attachments,
|
||||
)
|
||||
obj = cp_svc.create_collection_protocol(
|
||||
db,
|
||||
protocol_no=body.protocol_no,
|
||||
protocol_no_id=body.protocol_no_id,
|
||||
protocol_status=body.protocol_status,
|
||||
protocol_attachments=protocol_attachments,
|
||||
register_date=body.register_date,
|
||||
collector_user_id=user_id,
|
||||
remark=body.remark,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
protocol_id = obj.id
|
||||
_persist_protocol_sub_resources(
|
||||
db,
|
||||
protocol_id,
|
||||
tenant_no,
|
||||
user_id,
|
||||
party_customer=body.party_customer,
|
||||
collection_info=body.collection_info,
|
||||
samples=body.samples,
|
||||
payment=body.payment,
|
||||
payment_details=body.payment_details,
|
||||
storage_cert=body.storage_cert,
|
||||
)
|
||||
return success_response(cp_svc._protocol_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/collection-protocols/{protocol_id}")
|
||||
async def update_collection_protocol(
|
||||
protocol_id: str,
|
||||
body: ProtocolUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
payload = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
sub_keys = {
|
||||
"party_customer", "collection_info", "samples",
|
||||
"payment", "payment_details", "attachments", "storage_cert",
|
||||
}
|
||||
main_payload = {k: v for k, v in payload.items() if k not in sub_keys}
|
||||
main_payload.pop("collector_user_id", None)
|
||||
if body.attachments is not None:
|
||||
resolved = _resolve_protocol_attachments(
|
||||
main_payload.get("protocol_attachments"),
|
||||
body.attachments,
|
||||
)
|
||||
if resolved is not None:
|
||||
main_payload["protocol_attachments"] = resolved
|
||||
obj = cp_svc.update_collection_protocol(
|
||||
db,
|
||||
protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
scope_collector_user_id=user_id,
|
||||
**{k: v for k, v in main_payload.items() if v is not None},
|
||||
)
|
||||
_persist_protocol_sub_resources(
|
||||
db,
|
||||
protocol_id,
|
||||
tenant_no,
|
||||
user_id,
|
||||
party_customer=body.party_customer,
|
||||
collection_info=body.collection_info,
|
||||
samples=body.samples,
|
||||
payment=body.payment,
|
||||
payment_details=body.payment_details,
|
||||
storage_cert=body.storage_cert,
|
||||
)
|
||||
return success_response(cp_svc._protocol_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/collection-protocols/{protocol_id}")
|
||||
async def delete_collection_protocol(
|
||||
protocol_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
cp_svc.soft_delete_collection_protocol(
|
||||
db,
|
||||
protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
scope_collector_user_id=user_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 审核
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/collection-protocol-audits")
|
||||
async def list_collection_protocol_audits(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
auditStatus: Optional[str] = Query(None, alias="auditStatus"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = cp_svc.list_audits(
|
||||
db,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
audit_status=auditStatus,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
tp = cp_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
class AuditCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
audit_status: str = Field(..., alias="auditStatus")
|
||||
audit_remark: Optional[str] = Field(None, alias="auditRemark")
|
||||
|
||||
|
||||
@router.post("/collection-protocols/{protocol_id}/audit")
|
||||
async def submit_collection_protocol_audit(
|
||||
protocol_id: str,
|
||||
body: AuditCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
try:
|
||||
cp_svc.create_audit(
|
||||
db,
|
||||
collection_protocol_id=protocol_id,
|
||||
audit_status=body.audit_status,
|
||||
audit_remark=body.audit_remark,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
p = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == protocol_id).first()
|
||||
pn = (p.protocol_no if p else "") or ""
|
||||
return success_response({"protocolNo": pn}, message="审核已提交")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 收款确认
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/protocol-payment-details/receipt-confirm")
|
||||
async def list_receipt_confirm(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = cp_svc.list_receipt_confirm_details(db, page=page, page_size=pageSize, tenant_no=tenant_no)
|
||||
tp = cp_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
class FinanceConfirmBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
finance_confirm_attachments: Optional[str] = Field(None, alias="financeConfirmAttachments")
|
||||
|
||||
|
||||
@router.post("/protocol-payment-details/{detail_id}/receipt-confirm")
|
||||
async def post_receipt_confirm(
|
||||
detail_id: str,
|
||||
body: FinanceConfirmBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
try:
|
||||
cp_svc.confirm_receipt(
|
||||
db,
|
||||
detail_id=detail_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
finance_confirm_attachments=body.finance_confirm_attachments,
|
||||
)
|
||||
return success_response(None, message="确认成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 退费确认
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/protocol-payment-details/refund-confirm")
|
||||
async def list_refund_confirm(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = cp_svc.list_refund_confirm_details(db, page=page, page_size=pageSize, tenant_no=tenant_no)
|
||||
tp = cp_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/protocol-payment-details/{detail_id}/refund-confirm")
|
||||
async def post_refund_confirm(
|
||||
detail_id: str,
|
||||
body: FinanceConfirmBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
try:
|
||||
cp_svc.confirm_refund(
|
||||
db,
|
||||
detail_id=detail_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
finance_confirm_attachments=body.finance_confirm_attachments,
|
||||
)
|
||||
return success_response(None, message="确认成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 分期预警
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/protocol-payment-details/installment-warning")
|
||||
async def list_installment_warning(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
warningType: str = Query("all", alias="warningType"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = cp_svc.list_installment_warning(
|
||||
db,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
warning_type=warningType,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
tp = cp_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
@@ -0,0 +1,24 @@
|
||||
"""民族码表(下拉检索)"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import success_response
|
||||
from app.crud import ethnic as ethnic_svc
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["民族码表"])
|
||||
|
||||
|
||||
@router.get("/ethnics")
|
||||
async def list_ethnics_for_select(
|
||||
keyword: Optional[str] = Query(None, description="民族名称模糊检索"),
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""返回民族码表选项(按排序号升序)。"""
|
||||
rows = ethnic_svc.list_ethnics(db, keyword=keyword, limit=limit)
|
||||
return success_response({"list": rows})
|
||||
@@ -0,0 +1,205 @@
|
||||
"""请假申请 API — 工作流测试"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.database import get_db
|
||||
from app.core.response import success_response, error_response
|
||||
from app.crud import leave as leave_crud
|
||||
from app.crud.leave import total_pages
|
||||
from app.schemas.leave import LeaveCreate, LeaveUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/web/admin/leave", tags=["请假管理"])
|
||||
|
||||
_LEAVE_TYPE_MAP = {"personal": "事假", "sick": "病假", "annual": "年假", "other": "其他"}
|
||||
|
||||
|
||||
def _leave_local_callback(db: Session, business_id: str, event: str) -> None:
|
||||
if not business_id:
|
||||
return
|
||||
if event == "completed":
|
||||
leave_crud.update_leave_status(db, business_id, "approved")
|
||||
elif event == "rejected":
|
||||
leave_crud.update_leave_status(db, business_id, "rejected")
|
||||
db.commit()
|
||||
|
||||
|
||||
from app.services.workflow_callback import register_local_callback
|
||||
register_local_callback("/api/admin/leave/callback", _leave_local_callback)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_leaves(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _user.get("tenant_no", "")
|
||||
user_id = _user.get("user_id") or _user.get("id", "")
|
||||
items, total = leave_crud.list_leaves(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, status=status,
|
||||
tenant_no=tenant_no, create_by=user_id,
|
||||
)
|
||||
for item in items:
|
||||
item["leaveTypeLabel"] = _LEAVE_TYPE_MAP.get(item.get("leaveType", ""), item.get("leaveType", ""))
|
||||
return success_response({
|
||||
"list": items, "total": total, "page": page, "pageSize": pageSize,
|
||||
"totalPages": total_pages(total, pageSize),
|
||||
})
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_leave(
|
||||
body: LeaveCreate,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _user.get("tenant_no", "")
|
||||
user_id = _user.get("user_id") or _user.get("id", "")
|
||||
dept_id = _user.get("deptId", "")
|
||||
row = leave_crud.create_leave(
|
||||
db,
|
||||
leave_type=body.leaveType,
|
||||
start_date=body.startDate,
|
||||
end_date=body.endDate,
|
||||
days=body.days,
|
||||
reason=body.reason or "",
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
dept_id=dept_id,
|
||||
remark=body.remark or "",
|
||||
)
|
||||
db.commit()
|
||||
return success_response(leave_crud._row_to_dict(row))
|
||||
|
||||
|
||||
@router.post("/{leave_id}/submit")
|
||||
async def submit_leave(
|
||||
leave_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.models.models import WfProcessDef
|
||||
from app.services.workflow_engine import WorkflowEngine
|
||||
|
||||
tenant_no = _user.get("tenant_no", "")
|
||||
user_id = _user.get("user_id") or _user.get("id", "")
|
||||
|
||||
leave = leave_crud.get_leave(db, leave_id)
|
||||
if not leave:
|
||||
return error_response(404, "请假记录不存在")
|
||||
|
||||
if leave["status"] not in ("draft", "rejected"):
|
||||
return error_response(400, "只有草稿或已驳回的请假单可以提交")
|
||||
|
||||
defn = db.query(WfProcessDef).filter(
|
||||
WfProcessDef.process_code == "leave_request",
|
||||
WfProcessDef.del_flag == 0,
|
||||
WfProcessDef.status == "1",
|
||||
).first()
|
||||
if not defn:
|
||||
return error_response(400, "未找到请假审批流程(leave_request),请先在流程配置中创建并发布")
|
||||
|
||||
variables = {
|
||||
"days": leave["days"],
|
||||
"leave_days": leave["days"],
|
||||
"leaveType": leave["leaveType"],
|
||||
"applicant": user_id,
|
||||
}
|
||||
|
||||
try:
|
||||
engine = WorkflowEngine(db)
|
||||
instance = engine.start_process(
|
||||
process_def_id=defn.id,
|
||||
business_type="leave",
|
||||
business_id=leave_id,
|
||||
starter_user_id=user_id,
|
||||
variables=variables,
|
||||
callback_url="/api/admin/leave/callback",
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
leave_crud.update_leave_status(db, leave_id, "pending", wf_instance_id=instance.id, user_id=user_id)
|
||||
db.commit()
|
||||
return success_response({"instanceId": instance.id, "status": "pending"})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("发起请假审批失败: %s", e)
|
||||
return error_response(500, f"发起审批失败: {e}")
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def leave_workflow_callback(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
logger.info("请假工作流回调: %s", body)
|
||||
business_id = body.get("businessId", "")
|
||||
event = body.get("event", "")
|
||||
if not business_id:
|
||||
return success_response({"received": True})
|
||||
|
||||
if event == "completed":
|
||||
leave_crud.update_leave_status(db, business_id, "approved")
|
||||
elif event == "rejected":
|
||||
leave_crud.update_leave_status(db, business_id, "rejected")
|
||||
db.commit()
|
||||
return success_response({"received": True})
|
||||
|
||||
|
||||
@router.get("/{leave_id}")
|
||||
async def get_leave(
|
||||
leave_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = leave_crud.get_leave(db, leave_id)
|
||||
if not item:
|
||||
return error_response(404, "请假记录不存在")
|
||||
item["leaveTypeLabel"] = _LEAVE_TYPE_MAP.get(item.get("leaveType", ""), item.get("leaveType", ""))
|
||||
return success_response(item)
|
||||
|
||||
|
||||
@router.delete("/{leave_id}")
|
||||
async def delete_leave(
|
||||
leave_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = _user.get("user_id") or _user.get("id", "")
|
||||
leave = leave_crud.get_leave(db, leave_id)
|
||||
if not leave:
|
||||
return error_response(404, "请假记录不存在")
|
||||
if leave["status"] == "pending":
|
||||
return error_response(400, "审批中的请假单不能删除")
|
||||
leave_crud.soft_delete_leave(db, leave_id, user_id=user_id)
|
||||
db.commit()
|
||||
return success_response(None)
|
||||
|
||||
|
||||
@router.put("/{leave_id}")
|
||||
async def update_leave(
|
||||
leave_id: str,
|
||||
body: LeaveUpdate,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = _user.get("user_id") or _user.get("id", "")
|
||||
leave = leave_crud.get_leave(db, leave_id)
|
||||
if not leave:
|
||||
return error_response(404, "请假记录不存在")
|
||||
if leave["status"] not in ("draft", "rejected"):
|
||||
return error_response(400, "只有草稿或已驳回状态可以编辑")
|
||||
leave_crud.update_leave(db, leave_id, user_id=user_id, **body.model_dump(exclude_none=True))
|
||||
db.commit()
|
||||
return success_response(leave_crud.get_leave(db, leave_id))
|
||||
+99
-1
@@ -6,7 +6,7 @@ from app.core.response import success_response
|
||||
|
||||
router = APIRouter(prefix="/web/menu", tags=["菜单管理"])
|
||||
|
||||
# 与 web-admin/src/router/modules/sqxp.ts 一致:「Demo」参考页 +「系统管理」
|
||||
# 与 web-admin/src/router/modules/sqxp.ts 一致:Demo、系统管理、基础信息管理 等
|
||||
STATIC_MENU_TREE = [
|
||||
{
|
||||
"id": "10000000000000000000000000000002",
|
||||
@@ -98,6 +98,104 @@ STATIC_MENU_TREE = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "40000000000000000000000000000001",
|
||||
"name": "BasicInfo",
|
||||
"path": "/basic-info",
|
||||
"component": "/index/index",
|
||||
"meta": {
|
||||
"title": "基础信息管理",
|
||||
"icon": "ri:folder-user-line",
|
||||
"keepAlive": False,
|
||||
"roles": ["admin"],
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "40000000000000000000000000000002",
|
||||
"name": "BizCustomer",
|
||||
"path": "customer",
|
||||
"component": "/basic-info/customer/index",
|
||||
"meta": {"title": "客户管理", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "40000000000000000000000000000003",
|
||||
"name": "BizCellSampleType",
|
||||
"path": "cell-sample-type",
|
||||
"component": "/basic-info/cell-sample-type/index",
|
||||
"meta": {"title": "细胞样本类型管理", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "40000000000000000000000000000004",
|
||||
"name": "BizCollectionRegion",
|
||||
"path": "collection-region",
|
||||
"component": "/basic-info/collection-region/index",
|
||||
"meta": {"title": "采集地区及医院管理", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "40000000000000000000000000000005",
|
||||
"name": "BizCollectionProtocolNo",
|
||||
"path": "collection-protocol-no",
|
||||
"component": "/basic-info/collection-protocol-no/index",
|
||||
"meta": {"title": "采集协议号管理", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "40000000000000000000000000000006",
|
||||
"name": "BizSampleCode",
|
||||
"path": "sample-code",
|
||||
"component": "/basic-info/sample-code/index",
|
||||
"meta": {"title": "样本编码管理", "keepAlive": True},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000001",
|
||||
"name": "CellStorage",
|
||||
"path": "/cell-storage",
|
||||
"component": "/index/index",
|
||||
"meta": {
|
||||
"title": "细胞存储管理",
|
||||
"icon": "ri:test-tube-line",
|
||||
"keepAlive": False,
|
||||
"roles": ["admin"],
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "50000000000000000000000000000002",
|
||||
"name": "CellStorageProtocol",
|
||||
"path": "protocol",
|
||||
"component": "/cell-storage/protocol/index",
|
||||
"meta": {"title": "采集协议单", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000003",
|
||||
"name": "CellStorageProtocolAudit",
|
||||
"path": "protocol-audit",
|
||||
"component": "/cell-storage/protocol-audit/index",
|
||||
"meta": {"title": "采集协议单审核", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000004",
|
||||
"name": "CellStoragePaymentConfirm",
|
||||
"path": "payment-confirm",
|
||||
"component": "/cell-storage/payment-confirm/index",
|
||||
"meta": {"title": "采集协议单收款确认(财务确认)", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000005",
|
||||
"name": "CellStorageRefundConfirm",
|
||||
"path": "refund-confirm",
|
||||
"component": "/cell-storage/refund-confirm/index",
|
||||
"meta": {"title": "采集协议单退费确认(财务确认)", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "50000000000000000000000000000006",
|
||||
"name": "CellStorageInstallmentWarning",
|
||||
"path": "installment-warning",
|
||||
"component": "/cell-storage/installment-warning/index",
|
||||
"meta": {"title": "分期缴费用户预警模块", "keepAlive": True},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
"""运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约)"""
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import clean_room as clean_room_svc
|
||||
from app.crud import exhibition_appointment as exhibition_svc
|
||||
from app.crud import storage_facility as storage_facility_svc
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["运营支撑"])
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 洁净室
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CleanRoomCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
room_code: str = Field(..., alias="roomCode")
|
||||
room_name: str = Field(..., alias="roomName")
|
||||
room_grade: Optional[str] = Field(None, alias="roomGrade")
|
||||
room_status: Optional[str] = Field("zc", alias="roomStatus")
|
||||
area_sqm: Optional[float] = Field(None, alias="areaSqm")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class CleanRoomUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
room_code: Optional[str] = Field(None, alias="roomCode")
|
||||
room_name: Optional[str] = Field(None, alias="roomName")
|
||||
room_grade: Optional[str] = Field(None, alias="roomGrade")
|
||||
room_status: Optional[str] = Field(None, alias="roomStatus")
|
||||
area_sqm: Optional[float] = Field(None, alias="areaSqm")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/clean-rooms")
|
||||
async def list_clean_rooms(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = clean_room_svc.list_clean_rooms(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
tp = clean_room_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/clean-rooms")
|
||||
async def create_clean_room(
|
||||
body: CleanRoomCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = clean_room_svc.create_clean_room(
|
||||
db,
|
||||
room_code=body.room_code,
|
||||
room_name=body.room_name,
|
||||
room_grade=body.room_grade,
|
||||
room_status=body.room_status or "zc",
|
||||
area_sqm=body.area_sqm,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(clean_room_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/clean-rooms/{room_id}")
|
||||
async def update_clean_room(
|
||||
room_id: str,
|
||||
body: CleanRoomUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = clean_room_svc.update_clean_room(
|
||||
db,
|
||||
room_id,
|
||||
room_code=payload.get("room_code"),
|
||||
room_name=payload.get("room_name"),
|
||||
room_grade=payload.get("room_grade"),
|
||||
room_status=payload.get("room_status"),
|
||||
area_sqm=payload.get("area_sqm"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(clean_room_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/clean-rooms/{room_id}")
|
||||
async def delete_clean_room(
|
||||
room_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
clean_room_svc.soft_delete_clean_room(db, room_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 进出记录
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AccessLogCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
clean_room_id: str = Field(..., alias="cleanRoomId")
|
||||
person_name: Optional[str] = Field(None, alias="personName")
|
||||
dept_name: Optional[str] = Field(None, alias="deptName")
|
||||
enter_time: Optional[str] = Field(None, alias="enterTime")
|
||||
purpose: Optional[str] = None
|
||||
leave_time: Optional[str] = Field(None, alias="leaveTime")
|
||||
body_temp: Optional[str] = Field(None, alias="bodyTemp")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class AccessLogUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
person_name: Optional[str] = Field(None, alias="personName")
|
||||
dept_name: Optional[str] = Field(None, alias="deptName")
|
||||
enter_time: Optional[str] = Field(None, alias="enterTime")
|
||||
purpose: Optional[str] = None
|
||||
leave_time: Optional[str] = Field(None, alias="leaveTime")
|
||||
body_temp: Optional[str] = Field(None, alias="bodyTemp")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
def _parse_optional_dt(value: Optional[str]) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
@router.get("/clean-room-access-logs")
|
||||
async def list_access_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
cleanRoomId: str = Query(..., alias="cleanRoomId"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = clean_room_svc.list_access_logs(db, page=page, page_size=pageSize, clean_room_id=cleanRoomId, keyword=keyword, tenant_no=tenant_no)
|
||||
tp = clean_room_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/clean-room-access-logs")
|
||||
async def create_access_log(
|
||||
body: AccessLogCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = clean_room_svc.create_access_log(
|
||||
db,
|
||||
clean_room_id=body.clean_room_id,
|
||||
person_name=body.person_name,
|
||||
dept_name=body.dept_name,
|
||||
enter_time=_parse_optional_dt(body.enter_time),
|
||||
purpose=body.purpose,
|
||||
leave_time=_parse_optional_dt(body.leave_time),
|
||||
body_temp=body.body_temp,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id_param=user.get("user_id"),
|
||||
)
|
||||
return success_response(clean_room_svc._access_log_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/clean-room-access-logs/{log_id}")
|
||||
async def update_access_log(
|
||||
log_id: str,
|
||||
body: AccessLogUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = clean_room_svc.update_access_log(
|
||||
db,
|
||||
log_id,
|
||||
person_name=payload.get("person_name"),
|
||||
dept_name=payload.get("dept_name"),
|
||||
enter_time=_parse_optional_dt(payload.get("enter_time")) if "enter_time" in payload else None,
|
||||
purpose=payload.get("purpose"),
|
||||
leave_time=_parse_optional_dt(payload.get("leave_time")) if "leave_time" in payload else None,
|
||||
body_temp=payload.get("body_temp"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id_param=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(clean_room_svc._access_log_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/clean-room-access-logs/{log_id}")
|
||||
async def delete_access_log(
|
||||
log_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
clean_room_svc.soft_delete_access_log(db, log_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 仓储设施
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StorageFacilityCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
parent_id: str = Field("0", alias="parentId")
|
||||
node_name: str = Field(..., alias="nodeName")
|
||||
node_type: Optional[str] = Field(None, alias="nodeType")
|
||||
is_leaf: Optional[int] = Field(0, alias="isLeaf")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class StorageFacilityUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
node_name: Optional[str] = Field(None, alias="nodeName")
|
||||
node_type: Optional[str] = Field(None, alias="nodeType")
|
||||
is_leaf: Optional[int] = Field(None, alias="isLeaf")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/storage-facilities")
|
||||
async def list_storage_facilities(
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = storage_facility_svc.list_all_facilities(db, tenant_no=tenant_no, keyword=keyword)
|
||||
return success_response(items)
|
||||
|
||||
|
||||
@router.post("/storage-facilities")
|
||||
async def create_storage_facility(
|
||||
body: StorageFacilityCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = storage_facility_svc.create_facility(
|
||||
db,
|
||||
parent_id=body.parent_id,
|
||||
node_name=body.node_name,
|
||||
node_type=body.node_type,
|
||||
is_leaf=body.is_leaf,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(storage_facility_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/storage-facilities/{node_id}")
|
||||
async def update_storage_facility(
|
||||
node_id: str,
|
||||
body: StorageFacilityUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = storage_facility_svc.update_facility(
|
||||
db,
|
||||
node_id,
|
||||
node_name=payload.get("node_name"),
|
||||
node_type=payload.get("node_type"),
|
||||
is_leaf=payload.get("is_leaf"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(storage_facility_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/storage-facilities/{node_id}")
|
||||
async def delete_storage_facility(
|
||||
node_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
storage_facility_svc.soft_delete_facility(db, node_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 储位
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StorageSlotCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
facility_node_id: str = Field(..., alias="facilityNodeId")
|
||||
slot_code: Optional[str] = Field(None, alias="slotCode")
|
||||
slot_status: Optional[str] = Field(None, alias="slotStatus")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class StorageSlotBatchCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
facility_node_id: str = Field(..., alias="facilityNodeId")
|
||||
rows: int = Field(..., ge=1, le=50)
|
||||
cols: int = Field(..., ge=1, le=50)
|
||||
code_prefix: Optional[str] = Field("", alias="codePrefix")
|
||||
|
||||
|
||||
class StorageSlotUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
slot_code: Optional[str] = Field(None, alias="slotCode")
|
||||
slot_status: Optional[str] = Field(None, alias="slotStatus")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/storage-facility-slots")
|
||||
async def list_storage_slots(
|
||||
facilityNodeId: str = Query(..., alias="facilityNodeId"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = storage_facility_svc.list_slots(db, facility_node_id=facilityNodeId, tenant_no=tenant_no)
|
||||
return success_response(items)
|
||||
|
||||
|
||||
@router.post("/storage-facility-slots/batch")
|
||||
async def batch_create_storage_slots(
|
||||
body: StorageSlotBatchCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = storage_facility_svc.batch_create_slots(
|
||||
db,
|
||||
facility_node_id=body.facility_node_id,
|
||||
rows=body.rows,
|
||||
cols=body.cols,
|
||||
code_prefix=body.code_prefix,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(items, message=f"批量创建 {len(items)} 个格子成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/storage-facility-slots")
|
||||
async def create_storage_slot(
|
||||
body: StorageSlotCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = storage_facility_svc.create_slot(
|
||||
db,
|
||||
facility_node_id=body.facility_node_id,
|
||||
slot_code=body.slot_code,
|
||||
slot_status=body.slot_status,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(storage_facility_svc._slot_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/storage-facility-slots/{slot_id}")
|
||||
async def update_storage_slot(
|
||||
slot_id: str,
|
||||
body: StorageSlotUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = storage_facility_svc.update_slot(
|
||||
db,
|
||||
slot_id,
|
||||
slot_code=payload.get("slot_code"),
|
||||
slot_status=payload.get("slot_status"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(storage_facility_svc._slot_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/storage-facility-slots/{slot_id}")
|
||||
async def delete_storage_slot(
|
||||
slot_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
storage_facility_svc.soft_delete_slot(db, slot_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 展厅预约
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ExhibitionAppointmentCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
appointment_no: Optional[str] = Field(None, alias="appointmentNo")
|
||||
visit_org: Optional[str] = Field(None, alias="visitOrg")
|
||||
contact_name: Optional[str] = Field(None, alias="contactName")
|
||||
visit_date: Optional[str] = Field(None, alias="visitDate")
|
||||
visitor_count: Optional[int] = Field(None, alias="visitorCount")
|
||||
reception_user_id: Optional[str] = Field(None, alias="receptionUserId")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ExhibitionAppointmentUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
visit_org: Optional[str] = Field(None, alias="visitOrg")
|
||||
contact_name: Optional[str] = Field(None, alias="contactName")
|
||||
visit_date: Optional[str] = Field(None, alias="visitDate")
|
||||
visitor_count: Optional[int] = Field(None, alias="visitorCount")
|
||||
reception_user_id: Optional[str] = Field(None, alias="receptionUserId")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/exhibition-appointments")
|
||||
async def list_exhibition_appointments(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = exhibition_svc.list_exhibition_appointments(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
tp = exhibition_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/exhibition-appointments")
|
||||
async def create_exhibition_appointment(
|
||||
body: ExhibitionAppointmentCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = exhibition_svc.create_exhibition_appointment(
|
||||
db,
|
||||
appointment_no=body.appointment_no,
|
||||
visit_org=body.visit_org,
|
||||
contact_name=body.contact_name,
|
||||
visit_date=body.visit_date,
|
||||
visitor_count=body.visitor_count,
|
||||
reception_user_id=body.reception_user_id,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(exhibition_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/exhibition-appointments/{appointment_id}")
|
||||
async def update_exhibition_appointment(
|
||||
appointment_id: str,
|
||||
body: ExhibitionAppointmentUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = exhibition_svc.update_exhibition_appointment(
|
||||
db,
|
||||
appointment_id,
|
||||
visit_org=payload.get("visit_org"),
|
||||
contact_name=payload.get("contact_name"),
|
||||
visit_date=payload.get("visit_date"),
|
||||
visitor_count=payload.get("visitor_count"),
|
||||
reception_user_id=payload.get("reception_user_id"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(exhibition_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/exhibition-appointments/{appointment_id}")
|
||||
async def delete_exhibition_appointment(
|
||||
appointment_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
exhibition_svc.soft_delete_exhibition_appointment(db, appointment_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -1,4 +1,4 @@
|
||||
"""公共接口(Web 登录、刷新 Token、登出;用户模型对齐 zkxw-requirements-v0.3-schema.sql)"""
|
||||
"""公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线)"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
"""工作流管理接口"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import success_response, error_response
|
||||
from app.crud import workflow as wf_crud
|
||||
from app.models.models import SysUser, WfNode, WfProcessDef, WfProcessVersion, WfTask
|
||||
from app.services.workflow_engine import WorkflowEngine
|
||||
|
||||
router = APIRouter(prefix="/web/admin/workflow", tags=["工作流管理"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 业务类型 → 中文名称映射
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BUSINESS_TYPE_LABELS: Dict[str, str] = {
|
||||
"leave": "请假申请",
|
||||
}
|
||||
|
||||
|
||||
def _business_type_label(code: Optional[str]) -> str:
|
||||
if not code:
|
||||
return ""
|
||||
return _BUSINESS_TYPE_LABELS.get(code, code)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 业务表单字段提供者注册表
|
||||
# 新增业务类型时,只需添加一个 provider 函数并注册即可,前端无需改动
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import logging
|
||||
_form_field_logger = logging.getLogger("workflow.form_fields")
|
||||
|
||||
|
||||
def _leave_form_fields(db: Session, business_id: str) -> List[Dict[str, str]]:
|
||||
from app.crud import leave as leave_crud
|
||||
item = leave_crud.get_leave(db, business_id)
|
||||
if not item:
|
||||
return []
|
||||
leave_type_map = {"personal": "事假", "sick": "病假", "annual": "年假", "other": "其他"}
|
||||
fields = [
|
||||
{"label": "请假类型", "value": leave_type_map.get(item.get("leaveType", ""), item.get("leaveType", ""))},
|
||||
{"label": "请假天数", "value": f"{item.get('days', 0)} 天" if item.get("days") else "--"},
|
||||
{"label": "开始日期", "value": item.get("startDate", "") or "--"},
|
||||
{"label": "结束日期", "value": item.get("endDate", "") or "--"},
|
||||
]
|
||||
if item.get("reason"):
|
||||
fields.append({"label": "请假原因", "value": item["reason"]})
|
||||
if item.get("remark"):
|
||||
fields.append({"label": "备注", "value": item["remark"]})
|
||||
return fields
|
||||
|
||||
|
||||
_BUSINESS_FORM_FIELD_PROVIDERS: Dict[str, Any] = {
|
||||
"leave": _leave_form_fields,
|
||||
}
|
||||
|
||||
|
||||
def _get_business_form_fields(db: Session, business_type: Optional[str], business_id: Optional[str]) -> List[Dict[str, str]]:
|
||||
if not business_type or not business_id:
|
||||
return []
|
||||
provider = _BUSINESS_FORM_FIELD_PROVIDERS.get(business_type)
|
||||
if not provider:
|
||||
return []
|
||||
try:
|
||||
return provider(db, business_id)
|
||||
except Exception as e:
|
||||
_form_field_logger.warning("加载业务表单字段失败: type=%s id=%s err=%s", business_type, business_id, e)
|
||||
return []
|
||||
|
||||
|
||||
def _enrich_task_list(db: Session, items: List[Dict[str, Any]]) -> None:
|
||||
starter_ids = {it.get("starterUserId") for it in items if it.get("starterUserId")}
|
||||
user_name_map: Dict[str, str] = {}
|
||||
if starter_ids:
|
||||
rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(starter_ids)).all()
|
||||
user_name_map = {r.id: r.nick_name for r in rows}
|
||||
for it in items:
|
||||
sid = it.get("starterUserId")
|
||||
it["starterUserName"] = user_name_map.get(sid, sid or "")
|
||||
it["businessTypeLabel"] = _business_type_label(it.get("businessType"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic request bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProcessDefCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
processCode: str = Field(..., alias="processCode")
|
||||
processName: str = Field(..., alias="processName")
|
||||
status: Optional[str] = Field("1", alias="status")
|
||||
orderNum: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ProcessDefUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
processCode: Optional[str] = Field(None, alias="processCode")
|
||||
processName: Optional[str] = Field(None, alias="processName")
|
||||
status: Optional[str] = Field(None, alias="status")
|
||||
orderNum: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class PublishVersionBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
graphJson: Optional[str] = Field(None, alias="graphJson")
|
||||
nodes: List[Dict[str, Any]] = Field(default_factory=list, alias="nodes")
|
||||
transitions: List[Dict[str, Any]] = Field(default_factory=list, alias="transitions")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class StartInstanceBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
processCode: str = Field(..., alias="processCode")
|
||||
businessType: Optional[str] = Field(None, alias="businessType")
|
||||
businessId: Optional[str] = Field(None, alias="businessId")
|
||||
variables: Optional[Dict[str, Any]] = None
|
||||
callbackUrl: Optional[str] = Field(None, alias="callbackUrl")
|
||||
|
||||
|
||||
class TaskActionBody(BaseModel):
|
||||
"""审批意见:前端传 commentText,兼容 comment。"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
comment: Optional[str] = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("comment", "commentText"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 流程定义 CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/defs")
|
||||
async def list_process_defs(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _user.get("tenant_no", "zkxw")
|
||||
items, total = wf_crud.list_process_defs(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, status=status, tenant_no=tenant_no
|
||||
)
|
||||
tp = wf_crud.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/defs")
|
||||
async def create_process_def(
|
||||
body: ProcessDefCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = user.get("tenant_no", "zkxw")
|
||||
obj = wf_crud.create_process_def(
|
||||
db,
|
||||
process_code=body.processCode,
|
||||
process_name=body.processName,
|
||||
tenant_no=tenant_no,
|
||||
status=body.status,
|
||||
order_num=body.orderNum,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(wf_crud._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/defs/{def_id}")
|
||||
async def update_process_def(
|
||||
def_id: str,
|
||||
body: ProcessDefUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload: Dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=True)
|
||||
obj = wf_crud.update_process_def(
|
||||
db,
|
||||
def_id,
|
||||
process_code=payload.get("processCode"),
|
||||
process_name=payload.get("processName"),
|
||||
status=payload.get("status"),
|
||||
order_num=payload.get("orderNum"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=user.get("tenant_no", "zkxw"),
|
||||
)
|
||||
return success_response(wf_crud._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/defs/{def_id}")
|
||||
async def delete_process_def(
|
||||
def_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
wf_crud.soft_delete_process_def(db, def_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 版本发布 / 查看
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/defs/{def_id}/publish")
|
||||
async def publish_version(
|
||||
def_id: str,
|
||||
body: PublishVersionBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = user.get("tenant_no", "zkxw")
|
||||
ver = wf_crud.publish_version(
|
||||
db,
|
||||
process_def_id=def_id,
|
||||
graph_json=body.graphJson or "",
|
||||
nodes=body.nodes,
|
||||
transitions=body.transitions,
|
||||
tenant_no=tenant_no,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response({"versionId": ver.id, "versionNo": ver.version_no}, message="发布成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/defs/{def_id}/versions")
|
||||
async def list_versions(
|
||||
def_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _user.get("tenant_no", "zkxw")
|
||||
items = wf_crud.list_versions(db, def_id, tenant_no=tenant_no)
|
||||
return success_response(items)
|
||||
|
||||
|
||||
@router.get("/defs/{def_id}/designer")
|
||||
async def get_designer_data(
|
||||
def_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ver = wf_crud.get_latest_version(db, def_id)
|
||||
if not ver:
|
||||
return success_response({"graphJson": None, "nodes": [], "transitions": []})
|
||||
nodes = wf_crud.get_nodes_by_version(db, ver.id)
|
||||
transitions = wf_crud.get_transitions_by_version(db, ver.id)
|
||||
return success_response({
|
||||
"graphJson": ver.graph_json,
|
||||
"versionId": ver.id,
|
||||
"versionNo": ver.version_no,
|
||||
"nodes": [
|
||||
{"nodeKey": n.node_key, "nodeType": n.node_type, "nodeName": n.node_name, "configJson": n.config_json}
|
||||
for n in nodes
|
||||
],
|
||||
"transitions": [
|
||||
{"fromNodeKey": t.from_node_key, "toNodeKey": t.to_node_key, "conditionExpr": t.condition_expr}
|
||||
for t in transitions
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 流程实例
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/instances")
|
||||
async def start_instance(
|
||||
body: StartInstanceBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = user.get("tenant_no", "zkxw")
|
||||
user_id = user.get("user_id") or user.get("id")
|
||||
defn = (
|
||||
db.query(WfProcessDef)
|
||||
.filter(
|
||||
WfProcessDef.process_code == body.processCode,
|
||||
WfProcessDef.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not defn:
|
||||
return error_response(404, "流程定义不存在")
|
||||
engine = WorkflowEngine(db)
|
||||
instance = engine.start_process(
|
||||
process_def_id=defn.id,
|
||||
business_type=body.businessType,
|
||||
business_id=body.businessId,
|
||||
starter_user_id=user_id,
|
||||
variables=body.variables,
|
||||
callback_url=body.callbackUrl,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
return success_response({
|
||||
"instanceId": instance.id,
|
||||
"status": instance.status,
|
||||
}, message="发起成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/instances/my")
|
||||
async def list_my_instances(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = user.get("user_id") or user.get("id")
|
||||
tenant_no = user.get("tenant_no", "zkxw")
|
||||
items, total = wf_crud.list_my_instances(
|
||||
db, user_id, page=page, page_size=pageSize,
|
||||
keyword=keyword, status=status, tenant_no=tenant_no,
|
||||
)
|
||||
_enrich_task_list(db, items)
|
||||
tp = wf_crud.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/instances/{instance_id}")
|
||||
async def get_instance_detail(
|
||||
instance_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
instance = wf_crud.get_instance(db, instance_id)
|
||||
if not instance:
|
||||
return error_response(404, "流程实例不存在")
|
||||
tasks = db.query(WfTask).filter(
|
||||
WfTask.instance_id == instance_id,
|
||||
WfTask.del_flag == 0,
|
||||
).order_by(WfTask.create_time.asc()).all()
|
||||
|
||||
# 查询节点名称映射 node_key → node_name
|
||||
node_name_map: Dict[str, str] = {}
|
||||
if instance.process_version_id:
|
||||
for n in wf_crud.get_nodes_by_version(db, instance.process_version_id):
|
||||
node_name_map[n.node_key] = n.node_name or n.node_key
|
||||
|
||||
# 批量查询用户名
|
||||
user_ids = {t.assignee_user_id for t in tasks if t.assignee_user_id}
|
||||
if instance.starter_user_id:
|
||||
user_ids.add(instance.starter_user_id)
|
||||
user_name_map: Dict[str, str] = {}
|
||||
if user_ids:
|
||||
rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(user_ids)).all()
|
||||
user_name_map = {r.id: r.nick_name for r in rows}
|
||||
|
||||
return success_response({
|
||||
"id": instance.id,
|
||||
"businessType": instance.business_type,
|
||||
"businessTypeLabel": _business_type_label(instance.business_type),
|
||||
"businessId": instance.business_id,
|
||||
"businessFormFields": _get_business_form_fields(db, instance.business_type, instance.business_id),
|
||||
"starterUserId": instance.starter_user_id,
|
||||
"starterUserName": user_name_map.get(instance.starter_user_id, instance.starter_user_id or ""),
|
||||
"status": instance.status,
|
||||
"variablesJson": instance.variables_json,
|
||||
"callbackUrl": instance.callback_url,
|
||||
"createTime": instance.create_time.strftime("%Y-%m-%d %H:%M:%S") if instance.create_time else "",
|
||||
"tasks": [
|
||||
{
|
||||
"id": t.id,
|
||||
"nodeKey": t.node_key,
|
||||
"nodeName": node_name_map.get(t.node_key, t.node_key or ""),
|
||||
"assigneeUserId": t.assignee_user_id,
|
||||
"assigneeUserName": user_name_map.get(t.assignee_user_id, t.assignee_user_id or ""),
|
||||
"status": t.status,
|
||||
"commentText": t.comment_text,
|
||||
"createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
|
||||
"updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
|
||||
}
|
||||
for t in tasks
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 待办 / 已办
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/tasks/pending")
|
||||
async def list_pending_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = user.get("user_id") or user.get("id")
|
||||
tenant_no = user.get("tenant_no", "zkxw")
|
||||
items, total = wf_crud.list_pending_tasks(
|
||||
db, user_id, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
|
||||
)
|
||||
_enrich_task_list(db, items)
|
||||
tp = wf_crud.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/tasks/completed")
|
||||
async def list_completed_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = user.get("user_id") or user.get("id")
|
||||
tenant_no = user.get("tenant_no", "zkxw")
|
||||
items, total = wf_crud.list_completed_tasks(
|
||||
db, user_id, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
|
||||
)
|
||||
_enrich_task_list(db, items)
|
||||
tp = wf_crud.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 审批 / 驳回
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/approve")
|
||||
async def approve_task(
|
||||
task_id: str,
|
||||
body: TaskActionBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = user.get("tenant_no", "zkxw")
|
||||
engine = WorkflowEngine(db)
|
||||
instance = engine.approve_task(task_id, body.comment or "", tenant_no)
|
||||
return success_response({"instanceId": instance.id, "status": instance.status}, message="审批成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/reject")
|
||||
async def reject_task(
|
||||
task_id: str,
|
||||
body: TaskActionBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = user.get("tenant_no", "zkxw")
|
||||
engine = WorkflowEngine(db)
|
||||
instance = engine.reject_task(task_id, body.comment or "", tenant_no)
|
||||
return success_response({"instanceId": instance.id, "status": instance.status}, message="已驳回")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 按业务查询所有审批历史(跨实例)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/instances/by-business/{business_type}/{business_id}")
|
||||
async def get_business_all_tasks(
|
||||
business_type: str,
|
||||
business_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tasks = wf_crud.get_all_tasks_for_business(db, business_type, business_id)
|
||||
|
||||
business_form_fields = _get_business_form_fields(db, business_type, business_id)
|
||||
|
||||
return success_response({
|
||||
"businessType": business_type,
|
||||
"businessId": business_id,
|
||||
"businessFormFields": business_form_fields,
|
||||
"tasks": tasks,
|
||||
})
|
||||
@@ -100,7 +100,7 @@ class Settings(BaseSettings):
|
||||
WX_TEMPLATE_REFUND_RESULT: str = "" # 退款结果通知模板ID
|
||||
WX_TEMPLATE_DELIVERY_WARNING: str = "" # 配送时效预警模板ID
|
||||
|
||||
# 与 docs/sql/zkxw-requirements-v0.3-schema.sql 一致:登录按租户 + sys_user.user_no
|
||||
# 与 docs/sql/zkxw.sql 基线一致:登录按租户 + sys_user.user_no
|
||||
DEFAULT_TENANT_NO: str = "zkxw"
|
||||
# 用户未绑定 sys_user_role 时,Web 管理端默认角色(建议 super_admin 便于开发全菜单)
|
||||
WEB_AUTH_DEFAULT_ROLE: str = "super_admin"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""细胞样本类型 CRUD(biz_cell_sample_type)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizCellSampleType
|
||||
|
||||
|
||||
def _row_to_dict(r: BizCellSampleType) -> Dict[str, Any]:
|
||||
sp = r.storage_price
|
||||
return {
|
||||
"id": r.id,
|
||||
"typeCode": r.type_code,
|
||||
"typeName": r.type_name,
|
||||
"storageYears": r.storage_years,
|
||||
"storagePrice": float(sp) if sp is not None else None,
|
||||
"tenantNo": r.tenant_no,
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def type_code_exists(db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
c = (code or "").strip()
|
||||
if not c:
|
||||
return False
|
||||
q = db.query(BizCellSampleType).filter(
|
||||
and_(BizCellSampleType.type_code == c, BizCellSampleType.tenant_no == tenant_no, BizCellSampleType.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizCellSampleType.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def list_cell_sample_types(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizCellSampleType).filter(BizCellSampleType.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizCellSampleType.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(or_(BizCellSampleType.type_code.like(like), BizCellSampleType.type_name.like(like)))
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizCellSampleType.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizCellSampleType.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_cell_sample_type(db: Session, row_id: str, tenant_no: Optional[str] = None) -> Optional[BizCellSampleType]:
|
||||
q = db.query(BizCellSampleType).filter(and_(BizCellSampleType.id == row_id, BizCellSampleType.del_flag == 0))
|
||||
if tenant_no:
|
||||
q = q.filter(BizCellSampleType.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_cell_sample_type(
|
||||
db: Session,
|
||||
*,
|
||||
type_code: str,
|
||||
type_name: str,
|
||||
storage_years: Optional[int] = None,
|
||||
storage_price: Optional[float] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizCellSampleType:
|
||||
tc = (type_code or "").strip()
|
||||
if not tc:
|
||||
raise ValueError("样本类型编码不能为空")
|
||||
if type_code_exists(db, tc, tenant_no):
|
||||
raise ValueError("样本类型编码已存在")
|
||||
obj = BizCellSampleType(
|
||||
id=generate_uuid(),
|
||||
type_code=tc,
|
||||
type_name=(type_name or "").strip() or tc,
|
||||
storage_years=storage_years,
|
||||
storage_price=storage_price,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_cell_sample_type(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
type_code: Optional[str] = None,
|
||||
type_name: Optional[str] = None,
|
||||
storage_years: Optional[int] = None,
|
||||
storage_price: Optional[float] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizCellSampleType:
|
||||
obj = get_cell_sample_type(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("样本类型不存在")
|
||||
if type_code is not None:
|
||||
tc = type_code.strip()
|
||||
if not tc:
|
||||
raise ValueError("样本类型编码不能为空")
|
||||
if tc != obj.type_code and type_code_exists(db, tc, obj.tenant_no, exclude_id=row_id):
|
||||
raise ValueError("样本类型编码已存在")
|
||||
obj.type_code = tc
|
||||
if type_name is not None:
|
||||
obj.type_name = (type_name or "").strip() or obj.type_name
|
||||
if storage_years is not None:
|
||||
obj.storage_years = storage_years
|
||||
if storage_price is not None:
|
||||
obj.storage_price = storage_price
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = remark.strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_cell_sample_type(db: Session, row_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_cell_sample_type(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("样本类型不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -0,0 +1,344 @@
|
||||
"""洁净室及进出记录 CRUD(biz_clean_room + biz_clean_room_access_log)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizCleanRoom, BizCleanRoomAccessLog
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 洁净室
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _row_to_dict(c: BizCleanRoom) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": c.id,
|
||||
"roomCode": c.room_code,
|
||||
"roomName": c.room_name,
|
||||
"roomGrade": c.room_grade,
|
||||
"roomStatus": c.room_status,
|
||||
"areaSqm": float(c.area_sqm) if c.area_sqm is not None else 0.0,
|
||||
"tenantNo": c.tenant_no,
|
||||
"orderNum": c.order_num if c.order_num is not None else 0,
|
||||
"remark": c.remark or "",
|
||||
"createTime": c.create_time.strftime("%Y-%m-%d %H:%M:%S") if c.create_time else "",
|
||||
"updateTime": c.update_time.strftime("%Y-%m-%d %H:%M:%S") if c.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_clean_rooms(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizCleanRoom).filter(BizCleanRoom.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizCleanRoom.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(or_(BizCleanRoom.room_code.like(like), BizCleanRoom.room_name.like(like)))
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizCleanRoom.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizCleanRoom.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_clean_room(db: Session, room_id: str, tenant_no: Optional[str] = None) -> Optional[BizCleanRoom]:
|
||||
q = db.query(BizCleanRoom).filter(and_(BizCleanRoom.id == room_id, BizCleanRoom.del_flag == 0))
|
||||
if tenant_no:
|
||||
q = q.filter(BizCleanRoom.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def room_code_exists(db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
q = db.query(BizCleanRoom).filter(
|
||||
and_(BizCleanRoom.room_code == code.strip(), BizCleanRoom.tenant_no == tenant_no, BizCleanRoom.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizCleanRoom.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def create_clean_room(
|
||||
db: Session,
|
||||
*,
|
||||
room_code: str,
|
||||
room_name: str,
|
||||
room_grade: Optional[str] = None,
|
||||
room_status: Optional[str] = None,
|
||||
area_sqm: Optional[Any] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizCleanRoom:
|
||||
rc = (room_code or "").strip()
|
||||
if not rc:
|
||||
raise ValueError("洁净室编码不能为空")
|
||||
if room_code_exists(db, rc, tenant_no):
|
||||
raise ValueError("洁净室编码已存在")
|
||||
obj = BizCleanRoom(
|
||||
id=generate_uuid(),
|
||||
room_code=rc,
|
||||
room_name=(room_name or "").strip() or rc,
|
||||
room_grade=(room_grade or "").strip() or None,
|
||||
room_status=(room_status or "").strip() or None,
|
||||
area_sqm=area_sqm if area_sqm is not None else 0,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_clean_room(
|
||||
db: Session,
|
||||
room_id: str,
|
||||
*,
|
||||
room_code: Optional[str] = None,
|
||||
room_name: Optional[str] = None,
|
||||
room_grade: Optional[str] = None,
|
||||
room_status: Optional[str] = None,
|
||||
area_sqm: Optional[Any] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizCleanRoom:
|
||||
obj = get_clean_room(db, room_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("洁净室不存在")
|
||||
if room_code is not None:
|
||||
new_code = room_code.strip()
|
||||
if not new_code:
|
||||
raise ValueError("洁净室编码不能为空")
|
||||
if new_code != obj.room_code and room_code_exists(db, new_code, obj.tenant_no, exclude_id=room_id):
|
||||
raise ValueError("洁净室编码已存在")
|
||||
obj.room_code = new_code
|
||||
if room_name is not None:
|
||||
obj.room_name = (room_name or "").strip() or obj.room_name
|
||||
if room_grade is not None:
|
||||
obj.room_grade = (room_grade or "").strip() or None
|
||||
if room_status is not None:
|
||||
obj.room_status = (room_status or "").strip() or None
|
||||
if area_sqm is not None:
|
||||
obj.area_sqm = area_sqm
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_clean_room(db: Session, room_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_clean_room(db, room_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("洁净室不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 进出记录
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _access_log_to_dict(log: BizCleanRoomAccessLog) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": log.id,
|
||||
"cleanRoomId": log.clean_room_id,
|
||||
"userId": log.user_id,
|
||||
"personName": log.person_name,
|
||||
"deptName": log.dept_name,
|
||||
"enterTime": log.enter_time.strftime("%Y-%m-%d %H:%M:%S") if log.enter_time else "",
|
||||
"purpose": log.purpose or "",
|
||||
"leaveTime": log.leave_time.strftime("%Y-%m-%d %H:%M:%S") if log.leave_time else "",
|
||||
"bodyTemp": log.body_temp or "",
|
||||
"tenantNo": log.tenant_no,
|
||||
"orderNum": log.order_num if log.order_num is not None else 0,
|
||||
"remark": log.remark or "",
|
||||
"createTime": log.create_time.strftime("%Y-%m-%d %H:%M:%S") if log.create_time else "",
|
||||
"updateTime": log.update_time.strftime("%Y-%m-%d %H:%M:%S") if log.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_access_logs(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
clean_room_id: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizCleanRoomAccessLog).filter(BizCleanRoomAccessLog.del_flag == 0)
|
||||
if clean_room_id:
|
||||
q0 = q0.filter(BizCleanRoomAccessLog.clean_room_id == clean_room_id)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizCleanRoomAccessLog.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(or_(
|
||||
BizCleanRoomAccessLog.person_name.like(like),
|
||||
BizCleanRoomAccessLog.dept_name.like(like),
|
||||
))
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizCleanRoomAccessLog.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizCleanRoomAccessLog.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_access_log_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_access_log(db: Session, log_id: str, tenant_no: Optional[str] = None) -> Optional[BizCleanRoomAccessLog]:
|
||||
q = db.query(BizCleanRoomAccessLog).filter(and_(BizCleanRoomAccessLog.id == log_id, BizCleanRoomAccessLog.del_flag == 0))
|
||||
if tenant_no:
|
||||
q = q.filter(BizCleanRoomAccessLog.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_access_log(
|
||||
db: Session,
|
||||
*,
|
||||
clean_room_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
person_name: Optional[str] = None,
|
||||
dept_name: Optional[str] = None,
|
||||
enter_time: Optional[datetime] = None,
|
||||
purpose: Optional[str] = None,
|
||||
leave_time: Optional[datetime] = None,
|
||||
body_temp: Optional[str] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id_param: Optional[str] = None,
|
||||
) -> BizCleanRoomAccessLog:
|
||||
cr_id = (clean_room_id or "").strip()
|
||||
if not cr_id:
|
||||
raise ValueError("洁净室ID不能为空")
|
||||
obj = BizCleanRoomAccessLog(
|
||||
id=generate_uuid(),
|
||||
clean_room_id=cr_id,
|
||||
user_id=(user_id or "").strip() or None,
|
||||
person_name=(person_name or "").strip() or None,
|
||||
dept_name=(dept_name or "").strip() or None,
|
||||
enter_time=enter_time or datetime.now(),
|
||||
purpose=(purpose or "").strip() or None,
|
||||
leave_time=leave_time,
|
||||
body_temp=(body_temp or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id_param,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id_param,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_access_log(
|
||||
db: Session,
|
||||
log_id: str,
|
||||
*,
|
||||
person_name: Optional[str] = None,
|
||||
dept_name: Optional[str] = None,
|
||||
enter_time: Optional[datetime] = None,
|
||||
purpose: Optional[str] = None,
|
||||
leave_time: Optional[datetime] = None,
|
||||
body_temp: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id_param: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizCleanRoomAccessLog:
|
||||
obj = get_access_log(db, log_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("进出记录不存在")
|
||||
if person_name is not None:
|
||||
obj.person_name = (person_name or "").strip() or None
|
||||
if dept_name is not None:
|
||||
obj.dept_name = (dept_name or "").strip() or None
|
||||
if enter_time is not None:
|
||||
obj.enter_time = enter_time
|
||||
if purpose is not None:
|
||||
obj.purpose = (purpose or "").strip() or None
|
||||
if leave_time is not None:
|
||||
obj.leave_time = leave_time
|
||||
if body_temp is not None:
|
||||
obj.body_temp = (body_temp or "").strip() or None
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id_param
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_access_log(db: Session, log_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_access_log(db, log_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("进出记录不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,339 @@
|
||||
"""采集协议号池 CRUD(biz_collection_protocol_no)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizCollectionProtocolNo
|
||||
|
||||
|
||||
def _row_to_dict(r: BizCollectionProtocolNo) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"protocolNo": r.protocol_no,
|
||||
"issued": int(r.issued or 0),
|
||||
"issuedUserId": r.issued_user_id or "",
|
||||
"issuedUserName": r.issued_user_name or "",
|
||||
"issuedTime": r.issued_time.strftime("%Y-%m-%d %H:%M:%S") if r.issued_time else "",
|
||||
"tenantNo": r.tenant_no,
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def protocol_no_exists(db: Session, no: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
v = (no or "").strip()
|
||||
if not v:
|
||||
return False
|
||||
q = db.query(BizCollectionProtocolNo).filter(
|
||||
and_(
|
||||
BizCollectionProtocolNo.protocol_no == v,
|
||||
BizCollectionProtocolNo.tenant_no == tenant_no,
|
||||
BizCollectionProtocolNo.del_flag == 0,
|
||||
)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizCollectionProtocolNo.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def list_protocol_nos(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizCollectionProtocolNo).filter(BizCollectionProtocolNo.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizCollectionProtocolNo.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(or_(BizCollectionProtocolNo.protocol_no.like(like), BizCollectionProtocolNo.remark.like(like)))
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizCollectionProtocolNo.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizCollectionProtocolNo.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_protocol_no(db: Session, row_id: str, tenant_no: Optional[str] = None) -> Optional[BizCollectionProtocolNo]:
|
||||
q = db.query(BizCollectionProtocolNo).filter(and_(BizCollectionProtocolNo.id == row_id, BizCollectionProtocolNo.del_flag == 0))
|
||||
if tenant_no:
|
||||
q = q.filter(BizCollectionProtocolNo.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_protocol_no(
|
||||
db: Session,
|
||||
*,
|
||||
protocol_no: str,
|
||||
issued: int = 0,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizCollectionProtocolNo:
|
||||
pn = (protocol_no or "").strip()
|
||||
if not pn:
|
||||
raise ValueError("采集协议号不能为空")
|
||||
if protocol_no_exists(db, pn, tenant_no):
|
||||
raise ValueError("采集协议号已存在")
|
||||
obj = BizCollectionProtocolNo(
|
||||
id=generate_uuid(),
|
||||
protocol_no=pn,
|
||||
issued=1 if issued else 0,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_protocol_no(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
protocol_no: Optional[str] = None,
|
||||
issued: Optional[int] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
issued_user_id: Optional[str] = None,
|
||||
issued_user_name: Optional[str] = None,
|
||||
issued_time: Optional[str] = None,
|
||||
) -> BizCollectionProtocolNo:
|
||||
obj = get_protocol_no(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
if protocol_no is not None:
|
||||
pn = protocol_no.strip()
|
||||
if not pn:
|
||||
raise ValueError("采集协议号不能为空")
|
||||
if pn != obj.protocol_no and protocol_no_exists(db, pn, obj.tenant_no, exclude_id=row_id):
|
||||
raise ValueError("采集协议号已存在")
|
||||
obj.protocol_no = pn
|
||||
if issued is not None:
|
||||
obj.issued = 1 if issued else 0
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = remark.strip() or None
|
||||
if issued_user_id is not None:
|
||||
obj.issued_user_id = issued_user_id or None
|
||||
if issued_user_name is not None:
|
||||
obj.issued_user_name = issued_user_name or None
|
||||
if issued_time is not None:
|
||||
obj.issued_time = issued_time or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_protocol_no(db: Session, row_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_protocol_no(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def batch_generate_protocol_nos(
|
||||
db: Session,
|
||||
*,
|
||||
prefix: str,
|
||||
middle: str,
|
||||
seq_start: int,
|
||||
seq_end: int,
|
||||
digit_count: int = 6,
|
||||
tenant_no: str = "zkxw",
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""批量生成协议号: 前缀 + 中间段 + 补零序号"""
|
||||
p = (prefix or "").strip()
|
||||
m = (middle or "").strip()
|
||||
if not p:
|
||||
raise ValueError("固定前缀不能为空")
|
||||
if seq_start < 0 or seq_end < 0:
|
||||
raise ValueError("序号不能为负数")
|
||||
if seq_start > seq_end:
|
||||
raise ValueError("开始序号不能大于结束序号")
|
||||
if digit_count < 1:
|
||||
raise ValueError("序号位数不能小于1")
|
||||
count = seq_end - seq_start + 1
|
||||
if count > 10000:
|
||||
raise ValueError("单次批量生成不能超过10000条")
|
||||
|
||||
generated_nos = []
|
||||
for i in range(seq_start, seq_end + 1):
|
||||
seq_str = str(i).zfill(digit_count)
|
||||
full_no = f"{p}{m}{seq_str}"
|
||||
generated_nos.append(full_no)
|
||||
|
||||
# Batch check existing
|
||||
existing = set(
|
||||
row.protocol_no
|
||||
for row in db.query(BizCollectionProtocolNo.protocol_no)
|
||||
.filter(
|
||||
BizCollectionProtocolNo.tenant_no == tenant_no,
|
||||
BizCollectionProtocolNo.del_flag == 0,
|
||||
BizCollectionProtocolNo.protocol_no.in_(generated_nos),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
created = []
|
||||
skipped = []
|
||||
for no in generated_nos:
|
||||
if no in existing:
|
||||
skipped.append(no)
|
||||
continue
|
||||
obj = BizCollectionProtocolNo(
|
||||
id=generate_uuid(),
|
||||
protocol_no=no,
|
||||
issued=0,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
created.append(obj)
|
||||
|
||||
if created:
|
||||
db.bulk_save_objects(created)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"created": len(created),
|
||||
"skipped": len(skipped),
|
||||
"skippedNos": skipped,
|
||||
}
|
||||
|
||||
|
||||
def batch_issue_protocol_nos(
|
||||
db: Session,
|
||||
*,
|
||||
ids: List[str],
|
||||
issued_user_id: str,
|
||||
issued_user_name: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""批量下发协议号"""
|
||||
if not ids:
|
||||
raise ValueError("请选择要下发的协议号")
|
||||
uid = (issued_user_id or "").strip()
|
||||
if not uid:
|
||||
raise ValueError("请选择下发用户")
|
||||
|
||||
now = datetime.now()
|
||||
issued_count = 0
|
||||
already_issued = []
|
||||
|
||||
for row_id in ids:
|
||||
obj = get_protocol_no(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
continue
|
||||
if obj.issued == 1:
|
||||
already_issued.append(obj.protocol_no)
|
||||
continue
|
||||
obj.issued = 1
|
||||
obj.issued_user_id = uid
|
||||
obj.issued_user_name = (issued_user_name or "").strip() or None
|
||||
obj.issued_time = now
|
||||
obj.update_by = user_id
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
issued_count += 1
|
||||
|
||||
if issued_count > 0:
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"issuedCount": issued_count,
|
||||
"alreadyIssued": already_issued,
|
||||
}
|
||||
|
||||
|
||||
def batch_revoke_protocol_nos(
|
||||
db: Session,
|
||||
*,
|
||||
ids: List[str],
|
||||
tenant_no: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if not ids:
|
||||
raise ValueError("请选择要收回的协议号")
|
||||
|
||||
now = datetime.now()
|
||||
revoked_count = 0
|
||||
not_issued = []
|
||||
|
||||
for row_id in ids:
|
||||
obj = get_protocol_no(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
continue
|
||||
if obj.issued != 1:
|
||||
not_issued.append(obj.protocol_no)
|
||||
continue
|
||||
obj.issued = 0
|
||||
obj.issued_user_id = None
|
||||
obj.issued_user_name = None
|
||||
obj.issued_time = None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
revoked_count += 1
|
||||
|
||||
if revoked_count > 0:
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"revokedCount": revoked_count,
|
||||
"notIssued": not_issued,
|
||||
}
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -0,0 +1,308 @@
|
||||
"""采集地区及下属医院 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizCollectionHospital, BizCollectionRegion
|
||||
|
||||
|
||||
def _region_to_dict(r: BizCollectionRegion, hospital_names: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"regionName": r.region_name,
|
||||
"hospitalNames": hospital_names or [],
|
||||
"tenantNo": r.tenant_no,
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def _hospital_to_dict(h: BizCollectionHospital) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": h.id,
|
||||
"regionId": h.region_id,
|
||||
"hospitalName": h.hospital_name,
|
||||
"tenantNo": h.tenant_no,
|
||||
"orderNum": h.order_num if h.order_num is not None else 0,
|
||||
"remark": h.remark or "",
|
||||
"createTime": h.create_time.strftime("%Y-%m-%d %H:%M:%S") if h.create_time else "",
|
||||
"updateTime": h.update_time.strftime("%Y-%m-%d %H:%M:%S") if h.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_collection_regions(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizCollectionRegion).filter(BizCollectionRegion.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizCollectionRegion.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(or_(BizCollectionRegion.region_name.like(like), BizCollectionRegion.remark.like(like)))
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizCollectionRegion.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizCollectionRegion.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 批量查询所有 region 的医院名称,避免 N+1
|
||||
region_ids = [r.id for r in rows]
|
||||
hospital_map: Dict[str, List[str]] = {}
|
||||
if region_ids:
|
||||
hosp_rows = (
|
||||
db.query(BizCollectionHospital.region_id, BizCollectionHospital.hospital_name)
|
||||
.filter(
|
||||
and_(
|
||||
BizCollectionHospital.region_id.in_(region_ids),
|
||||
BizCollectionHospital.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.order_by(BizCollectionHospital.order_num.asc(), BizCollectionHospital.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
for h in hosp_rows:
|
||||
hospital_map.setdefault(h.region_id, []).append(h.hospital_name)
|
||||
|
||||
return [_region_to_dict(x, hospital_names=hospital_map.get(x.id, [])) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_collection_region(db: Session, region_id: str, tenant_no: Optional[str] = None) -> Optional[BizCollectionRegion]:
|
||||
q = db.query(BizCollectionRegion).filter(and_(BizCollectionRegion.id == region_id, BizCollectionRegion.del_flag == 0))
|
||||
if tenant_no:
|
||||
q = q.filter(BizCollectionRegion.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_collection_region(
|
||||
db: Session,
|
||||
*,
|
||||
region_name: str,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizCollectionRegion:
|
||||
name = (region_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("地区名称不能为空")
|
||||
obj = BizCollectionRegion(
|
||||
id=generate_uuid(),
|
||||
region_name=name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_collection_region(
|
||||
db: Session,
|
||||
region_id: str,
|
||||
*,
|
||||
region_name: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizCollectionRegion:
|
||||
obj = get_collection_region(db, region_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("采集地区不存在")
|
||||
if region_name is not None:
|
||||
n = region_name.strip()
|
||||
if not n:
|
||||
raise ValueError("地区名称不能为空")
|
||||
obj.region_name = n
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = remark.strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_collection_region(db: Session, region_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_collection_region(db, region_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("采集地区不存在")
|
||||
now = datetime.now()
|
||||
obj.del_flag = 1
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
subs = (
|
||||
db.query(BizCollectionHospital)
|
||||
.filter(
|
||||
and_(
|
||||
BizCollectionHospital.region_id == region_id,
|
||||
BizCollectionHospital.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for h in subs:
|
||||
h.del_flag = 1
|
||||
h.update_time = now
|
||||
db.add(h)
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_hospitals(
|
||||
db: Session,
|
||||
*,
|
||||
region_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
region = get_collection_region(db, region_id, tenant_no=tenant_no)
|
||||
if not region:
|
||||
raise ValueError("采集地区不存在")
|
||||
|
||||
def _filtered():
|
||||
q0 = db.query(BizCollectionHospital).filter(
|
||||
and_(BizCollectionHospital.region_id == region_id, BizCollectionHospital.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizCollectionHospital.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(BizCollectionHospital.hospital_name.like(like))
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizCollectionHospital.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizCollectionHospital.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_hospital_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_hospital(db: Session, hospital_id: str, tenant_no: Optional[str] = None) -> Optional[BizCollectionHospital]:
|
||||
q = db.query(BizCollectionHospital).filter(and_(BizCollectionHospital.id == hospital_id, BizCollectionHospital.del_flag == 0))
|
||||
if tenant_no:
|
||||
q = q.filter(BizCollectionHospital.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_hospital(
|
||||
db: Session,
|
||||
*,
|
||||
region_id: str,
|
||||
hospital_name: str,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizCollectionHospital:
|
||||
region = get_collection_region(db, region_id, tenant_no=tenant_no)
|
||||
if not region:
|
||||
raise ValueError("采集地区不存在")
|
||||
hn = (hospital_name or "").strip()
|
||||
if not hn:
|
||||
raise ValueError("医院名称不能为空")
|
||||
obj = BizCollectionHospital(
|
||||
id=generate_uuid(),
|
||||
region_id=region_id,
|
||||
hospital_name=hn,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_hospital(
|
||||
db: Session,
|
||||
hospital_id: str,
|
||||
*,
|
||||
hospital_name: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizCollectionHospital:
|
||||
obj = get_hospital(db, hospital_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("医院不存在")
|
||||
if hospital_name is not None:
|
||||
hn = hospital_name.strip()
|
||||
if not hn:
|
||||
raise ValueError("医院名称不能为空")
|
||||
obj.hospital_name = hn
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = remark.strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_hospital(db: Session, hospital_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_hospital(db, hospital_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("医院不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -0,0 +1,194 @@
|
||||
"""客户 CRUD(biz_customer)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizCustomer
|
||||
|
||||
|
||||
def _row_to_dict(c: BizCustomer) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": c.id,
|
||||
"customerName": c.customer_name,
|
||||
"sex": (c.sex or "2").strip() if c.sex else "2",
|
||||
"idCardNo": c.id_card_no or "",
|
||||
"age": c.age,
|
||||
"phoneNo": c.phone_no or "",
|
||||
"customerTags": c.customer_tags or "",
|
||||
"tenantNo": c.tenant_no,
|
||||
"orderNum": c.order_num if c.order_num is not None else 0,
|
||||
"remark": c.remark or "",
|
||||
"createTime": c.create_time.strftime("%Y-%m-%d %H:%M:%S") if c.create_time else "",
|
||||
"updateTime": c.update_time.strftime("%Y-%m-%d %H:%M:%S") if c.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def id_card_taken(db: Session, id_card: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
ic = (id_card or "").strip()
|
||||
if not ic:
|
||||
return False
|
||||
q = db.query(BizCustomer).filter(
|
||||
and_(BizCustomer.id_card_no == ic, BizCustomer.tenant_no == tenant_no, BizCustomer.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizCustomer.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def list_customers(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizCustomer).filter(BizCustomer.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizCustomer.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizCustomer.customer_name.like(like),
|
||||
BizCustomer.phone_no.like(like),
|
||||
BizCustomer.id_card_no.like(like),
|
||||
BizCustomer.customer_tags.like(like),
|
||||
)
|
||||
)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizCustomer.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizCustomer.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_customer(db: Session, customer_id: str, tenant_no: Optional[str] = None) -> Optional[BizCustomer]:
|
||||
q = db.query(BizCustomer).filter(and_(BizCustomer.id == customer_id, BizCustomer.del_flag == 0))
|
||||
if tenant_no:
|
||||
q = q.filter(BizCustomer.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_customer(
|
||||
db: Session,
|
||||
*,
|
||||
customer_name: str,
|
||||
sex: str = "2",
|
||||
id_card_no: Optional[str] = None,
|
||||
age: Optional[int] = None,
|
||||
phone_no: Optional[str] = None,
|
||||
customer_tags: Optional[str] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizCustomer:
|
||||
name = (customer_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("客户姓名不能为空")
|
||||
ic = (id_card_no or "").strip() or None
|
||||
if ic and id_card_taken(db, ic, tenant_no):
|
||||
raise ValueError("该身份证号已存在客户,请勿重复创建")
|
||||
obj = BizCustomer(
|
||||
id=generate_uuid(),
|
||||
customer_name=name,
|
||||
sex=(sex or "2")[:1] if sex else "2",
|
||||
id_card_no=ic,
|
||||
age=age,
|
||||
phone_no=(phone_no or "").strip() or None,
|
||||
customer_tags=(customer_tags or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_customer(
|
||||
db: Session,
|
||||
customer_id: str,
|
||||
*,
|
||||
customer_name: Optional[str] = None,
|
||||
sex: Optional[str] = None,
|
||||
id_card_no: Optional[str] = None,
|
||||
age: Optional[int] = None,
|
||||
phone_no: Optional[str] = None,
|
||||
customer_tags: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizCustomer:
|
||||
obj = get_customer(db, customer_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("客户不存在")
|
||||
if customer_name is not None:
|
||||
n = customer_name.strip()
|
||||
if not n:
|
||||
raise ValueError("客户姓名不能为空")
|
||||
obj.customer_name = n
|
||||
if sex is not None:
|
||||
obj.sex = (sex or "2")[:1]
|
||||
if id_card_no is not None:
|
||||
ic = id_card_no.strip() or None
|
||||
if ic and id_card_taken(db, ic, obj.tenant_no, exclude_id=customer_id):
|
||||
raise ValueError("该身份证号已被其他客户使用")
|
||||
obj.id_card_no = ic
|
||||
if age is not None:
|
||||
obj.age = age
|
||||
if phone_no is not None:
|
||||
obj.phone_no = phone_no.strip() or None
|
||||
if customer_tags is not None:
|
||||
obj.customer_tags = customer_tags.strip() or None
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = remark.strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_customer(db: Session, customer_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_customer(db, customer_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("客户不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -48,7 +48,7 @@ def list_depts(
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(SysDept.dept_name.like(like))
|
||||
rows = q.order_by(SysDept.order_num.asc(), SysDept.create_time.desc()).all()
|
||||
rows = q.order_by(SysDept.create_time.desc()).all()
|
||||
return [_row_to_dict(x) for x in rows]
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ def list_dict_data(
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(SysDictData.order_num.asc(), SysDictData.create_time.desc())
|
||||
.order_by(SysDictData.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
@@ -147,7 +147,7 @@ def list_dict_data_options(
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(SysDictData.tenant_no == tenant_no)
|
||||
rows = q.order_by(SysDictData.order_num.asc(), SysDictData.create_time.desc()).all()
|
||||
rows = q.order_by(SysDictData.create_time.desc()).all()
|
||||
return [{"label": r.dict_label, "value": r.dict_value} for r in rows]
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ def list_dict_types(
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(SysDictType.order_num.asc(), SysDictType.create_time.desc())
|
||||
.order_by(SysDictType.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""民族码表 CRUD(全局 sys_ethnic)"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import SysEthnic
|
||||
|
||||
|
||||
def _row_to_dict(row: SysEthnic) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"ethnicName": row.ethnic_name or "",
|
||||
"ethnicCode": row.ethnic_code or "",
|
||||
"orderNum": row.order_num,
|
||||
}
|
||||
|
||||
|
||||
def list_ethnics(
|
||||
db: Session,
|
||||
*,
|
||||
keyword: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> List[Dict[str, Any]]:
|
||||
q = db.query(SysEthnic).filter(SysEthnic.del_flag == 0)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(SysEthnic.ethnic_name.like(like))
|
||||
rows = q.order_by(SysEthnic.order_num.asc(), SysEthnic.ethnic_code.asc()).limit(max(1, min(limit, 500))).all()
|
||||
return [_row_to_dict(x) for x in rows]
|
||||
@@ -0,0 +1,238 @@
|
||||
"""展览预约 CRUD(biz_exhibition_appointment)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizExhibitionAppointment
|
||||
|
||||
|
||||
def _format_visit_date(val: Any) -> str:
|
||||
"""格式化 visit_date 为 YYYY-MM-DD HH:mm:ss 字符串"""
|
||||
if val is None:
|
||||
return ""
|
||||
if isinstance(val, datetime):
|
||||
return val.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(val)
|
||||
|
||||
|
||||
def _row_to_dict(u: BizExhibitionAppointment) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": u.id,
|
||||
"appointmentNo": u.appointment_no,
|
||||
"visitOrg": u.visit_org,
|
||||
"contactName": u.contact_name,
|
||||
"visitDate": _format_visit_date(u.visit_date),
|
||||
"visitorCount": u.visitor_count,
|
||||
"receptionUserId": u.reception_user_id,
|
||||
"tenantNo": u.tenant_no,
|
||||
"orderNum": u.order_num if u.order_num is not None else 0,
|
||||
"remark": u.remark or "",
|
||||
"createTime": u.create_time.strftime("%Y-%m-%d %H:%M:%S") if u.create_time else "",
|
||||
"updateTime": u.update_time.strftime("%Y-%m-%d %H:%M:%S") if u.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_exhibition_appointments(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizExhibitionAppointment).filter(BizExhibitionAppointment.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizExhibitionAppointment.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizExhibitionAppointment.appointment_no.like(like),
|
||||
BizExhibitionAppointment.visit_org.like(like),
|
||||
BizExhibitionAppointment.contact_name.like(like),
|
||||
)
|
||||
)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizExhibitionAppointment.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizExhibitionAppointment.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_exhibition_appointment(db: Session, appointment_id: str) -> Optional[BizExhibitionAppointment]:
|
||||
return (
|
||||
db.query(BizExhibitionAppointment)
|
||||
.filter(and_(BizExhibitionAppointment.id == appointment_id, BizExhibitionAppointment.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def appointment_no_exists(db: Session, appointment_no: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
q = db.query(BizExhibitionAppointment).filter(
|
||||
and_(
|
||||
BizExhibitionAppointment.appointment_no == appointment_no.strip(),
|
||||
BizExhibitionAppointment.tenant_no == tenant_no,
|
||||
BizExhibitionAppointment.del_flag == 0,
|
||||
)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizExhibitionAppointment.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def _generate_appointment_no(db: Session, tenant_no: str) -> str:
|
||||
"""生成预约编号:YYYYMMDD + 6位序号(如 20260515000001)"""
|
||||
today_str = datetime.now().strftime("%Y%m%d")
|
||||
prefix = today_str
|
||||
last_row = (
|
||||
db.query(BizExhibitionAppointment.appointment_no)
|
||||
.filter(
|
||||
BizExhibitionAppointment.tenant_no == tenant_no,
|
||||
BizExhibitionAppointment.appointment_no.like(f"{prefix}%"),
|
||||
BizExhibitionAppointment.del_flag == 0,
|
||||
)
|
||||
.order_by(BizExhibitionAppointment.appointment_no.desc())
|
||||
.first()
|
||||
)
|
||||
if last_row and last_row[0]:
|
||||
try:
|
||||
seq = int(last_row[0][len(prefix):]) + 1
|
||||
except (ValueError, IndexError):
|
||||
seq = 1
|
||||
else:
|
||||
seq = 1
|
||||
return f"{prefix}{seq:06d}"
|
||||
|
||||
|
||||
def create_exhibition_appointment(
|
||||
db: Session,
|
||||
*,
|
||||
appointment_no: Optional[str] = None,
|
||||
visit_org: str,
|
||||
contact_name: str,
|
||||
visit_date: Optional[str] = None,
|
||||
visitor_count: int,
|
||||
reception_user_id: Optional[str] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizExhibitionAppointment:
|
||||
no = (appointment_no or "").strip()
|
||||
if not no:
|
||||
no = _generate_appointment_no(db, tenant_no)
|
||||
else:
|
||||
if appointment_no_exists(db, no, tenant_no):
|
||||
raise ValueError("预约编号已存在")
|
||||
visit_date_val: Optional[datetime] = None
|
||||
if visit_date:
|
||||
try:
|
||||
visit_date_val = datetime.fromisoformat(visit_date)
|
||||
except (ValueError, TypeError):
|
||||
visit_date_val = None
|
||||
obj = BizExhibitionAppointment(
|
||||
id=generate_uuid(),
|
||||
appointment_no=no,
|
||||
visit_org=(visit_org or "").strip(),
|
||||
contact_name=(contact_name or "").strip(),
|
||||
visit_date=visit_date_val,
|
||||
visitor_count=visitor_count if visitor_count is not None else 0,
|
||||
reception_user_id=reception_user_id or "",
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_exhibition_appointment(
|
||||
db: Session,
|
||||
appointment_id: str,
|
||||
*,
|
||||
appointment_no: Optional[str] = None,
|
||||
visit_org: Optional[str] = None,
|
||||
contact_name: Optional[str] = None,
|
||||
visit_date: Optional[str] = None,
|
||||
visitor_count: Optional[int] = None,
|
||||
reception_user_id: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizExhibitionAppointment:
|
||||
obj = get_exhibition_appointment(db, appointment_id)
|
||||
if not obj:
|
||||
raise ValueError("展览预约不存在")
|
||||
if appointment_no is not None:
|
||||
new_no = appointment_no.strip()
|
||||
if not new_no:
|
||||
raise ValueError("预约编号不能为空")
|
||||
if new_no != obj.appointment_no and appointment_no_exists(db, new_no, obj.tenant_no, exclude_id=appointment_id):
|
||||
raise ValueError("预约编号已存在")
|
||||
obj.appointment_no = new_no
|
||||
if visit_org is not None:
|
||||
obj.visit_org = (visit_org or "").strip()
|
||||
if contact_name is not None:
|
||||
obj.contact_name = (contact_name or "").strip()
|
||||
if visit_date is not None:
|
||||
if visit_date:
|
||||
try:
|
||||
obj.visit_date = datetime.fromisoformat(visit_date)
|
||||
except (ValueError, TypeError):
|
||||
obj.visit_date = None
|
||||
else:
|
||||
obj.visit_date = None
|
||||
if visitor_count is not None:
|
||||
obj.visitor_count = visitor_count
|
||||
if reception_user_id is not None:
|
||||
obj.reception_user_id = reception_user_id or ""
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_exhibition_appointment(db: Session, appointment_id: str) -> None:
|
||||
obj = get_exhibition_appointment(db, appointment_id)
|
||||
if not obj:
|
||||
raise ValueError("展览预约不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -48,7 +48,7 @@ def list_express_companies(
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizExpressCompany.order_num.asc(), BizExpressCompany.create_time.desc())
|
||||
.order_by(BizExpressCompany.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
|
||||
@@ -48,7 +48,7 @@ def list_item_units(
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizItemUnit.order_num.asc(), BizItemUnit.create_time.desc())
|
||||
.order_by(BizItemUnit.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""请假申请 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizLeaveRequest
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
return (total + page_size - 1) // page_size if page_size else 0
|
||||
|
||||
|
||||
def _row_to_dict(row: BizLeaveRequest) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"leaveType": row.leave_type,
|
||||
"startDate": row.start_date,
|
||||
"endDate": row.end_date,
|
||||
"days": row.days,
|
||||
"reason": row.reason or "",
|
||||
"wfInstanceId": row.wf_instance_id or "",
|
||||
"status": row.status,
|
||||
"tenantNo": row.tenant_no,
|
||||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||||
"remark": row.remark or "",
|
||||
"createBy": row.create_by or "",
|
||||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
||||
"updateBy": row.update_by or "",
|
||||
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_leaves(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
create_by: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
q = db.query(BizLeaveRequest).filter(BizLeaveRequest.del_flag == 0)
|
||||
if tenant_no:
|
||||
q = q.filter(BizLeaveRequest.tenant_no == tenant_no)
|
||||
if status:
|
||||
q = q.filter(BizLeaveRequest.status == status)
|
||||
if create_by:
|
||||
q = q.filter(BizLeaveRequest.create_by == create_by)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(or_(BizLeaveRequest.reason.like(like), BizLeaveRequest.leave_type.like(like)))
|
||||
total = q.count()
|
||||
rows = (
|
||||
q.order_by(BizLeaveRequest.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(r) for r in rows], total
|
||||
|
||||
|
||||
def get_leave(db: Session, leave_id: str) -> Optional[Dict[str, Any]]:
|
||||
row = db.query(BizLeaveRequest).filter(BizLeaveRequest.id == leave_id, BizLeaveRequest.del_flag == 0).first()
|
||||
return _row_to_dict(row) if row else None
|
||||
|
||||
|
||||
def create_leave(
|
||||
db: Session,
|
||||
*,
|
||||
leave_type: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
days: int,
|
||||
reason: str = "",
|
||||
tenant_no: str = "zkxw",
|
||||
user_id: str = "",
|
||||
dept_id: str = "",
|
||||
remark: str = "",
|
||||
) -> BizLeaveRequest:
|
||||
row = BizLeaveRequest(
|
||||
id=generate_uuid(),
|
||||
leave_type=leave_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
days=days,
|
||||
reason=reason,
|
||||
status="draft",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def update_leave(db: Session, leave_id: str, *, user_id: str = "", **fields) -> None:
|
||||
row = db.query(BizLeaveRequest).filter(BizLeaveRequest.id == leave_id, BizLeaveRequest.del_flag == 0).first()
|
||||
if not row:
|
||||
return
|
||||
mapping = {
|
||||
"leaveType": "leave_type",
|
||||
"startDate": "start_date",
|
||||
"endDate": "end_date",
|
||||
"days": "days",
|
||||
"reason": "reason",
|
||||
"remark": "remark",
|
||||
}
|
||||
for camel, snake in mapping.items():
|
||||
if camel in fields and fields[camel] is not None:
|
||||
setattr(row, snake, fields[camel])
|
||||
row.update_by = user_id
|
||||
row.update_time = datetime.now()
|
||||
db.flush()
|
||||
|
||||
|
||||
def update_leave_status(db: Session, leave_id: str, status: str, wf_instance_id: str = "", user_id: str = "") -> None:
|
||||
row = db.query(BizLeaveRequest).filter(BizLeaveRequest.id == leave_id, BizLeaveRequest.del_flag == 0).first()
|
||||
if not row:
|
||||
return
|
||||
row.status = status
|
||||
if wf_instance_id:
|
||||
row.wf_instance_id = wf_instance_id
|
||||
row.update_by = user_id
|
||||
row.update_time = datetime.now()
|
||||
db.flush()
|
||||
|
||||
|
||||
def soft_delete_leave(db: Session, leave_id: str, user_id: str = "") -> None:
|
||||
row = db.query(BizLeaveRequest).filter(BizLeaveRequest.id == leave_id, BizLeaveRequest.del_flag == 0).first()
|
||||
if not row:
|
||||
return
|
||||
row.del_flag = 1
|
||||
row.update_by = user_id
|
||||
row.update_time = datetime.now()
|
||||
db.flush()
|
||||
@@ -51,7 +51,7 @@ def list_menus(
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(SysMenu.menu_name.like(like))
|
||||
rows = q.order_by(SysMenu.order_num.asc(), SysMenu.create_time.desc()).all()
|
||||
rows = q.order_by(SysMenu.create_time.desc()).all()
|
||||
return [_row_to_dict(x) for x in rows]
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def list_posts(
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(SysPost.order_num.asc(), SysPost.create_time.desc())
|
||||
.order_by(SysPost.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
|
||||
@@ -65,7 +65,7 @@ def list_roles(
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(SysRole.order_num.asc(), SysRole.create_time.desc())
|
||||
.order_by(SysRole.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
@@ -207,13 +207,12 @@ def update_role_menus(
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> None:
|
||||
# soft-delete old
|
||||
db.query(SysRoleMenu).filter(
|
||||
and_(SysRoleMenu.role_id == role_id, SysRoleMenu.del_flag == 0)
|
||||
).update({"del_flag": 1, "update_time": datetime.now()})
|
||||
# insert new
|
||||
# uk_role_menu 为 (role_id, menu_id, del_flag):先全量软删再插入会在再次保存时
|
||||
# 出现两条 (role_id, menu_id, del_flag=1),触发 1062。关联表采用整角色物理清理后重建。
|
||||
unique_ids = list(dict.fromkeys(menu_ids or []))
|
||||
db.query(SysRoleMenu).filter(SysRoleMenu.role_id == role_id).delete(synchronize_session=False)
|
||||
now = datetime.now()
|
||||
for mid in menu_ids:
|
||||
for mid in unique_ids:
|
||||
obj = SysRoleMenu(
|
||||
id=generate_uuid(),
|
||||
role_id=role_id,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""样本编码池 CRUD(biz_sample_code)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizSampleCode
|
||||
|
||||
|
||||
def _row_to_dict(r: BizSampleCode) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"sampleCode": r.sample_code,
|
||||
"sampleCodeType": r.sample_code_type or "",
|
||||
"issued": int(r.issued or 0),
|
||||
"issuedUserId": r.issued_user_id or "",
|
||||
"issuedUserName": r.issued_user_name or "",
|
||||
"issuedTime": r.issued_time.strftime("%Y-%m-%d %H:%M:%S") if r.issued_time else "",
|
||||
"tenantNo": r.tenant_no,
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def sample_code_exists(db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
v = (code or "").strip()
|
||||
if not v:
|
||||
return False
|
||||
q = db.query(BizSampleCode).filter(
|
||||
and_(BizSampleCode.sample_code == v, BizSampleCode.tenant_no == tenant_no, BizSampleCode.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizSampleCode.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def list_sample_codes(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizSampleCode).filter(BizSampleCode.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizSampleCode.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(or_(BizSampleCode.sample_code.like(like), BizSampleCode.remark.like(like)))
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizSampleCode.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizSampleCode.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_sample_code(db: Session, row_id: str, tenant_no: Optional[str] = None) -> Optional[BizSampleCode]:
|
||||
q = db.query(BizSampleCode).filter(and_(BizSampleCode.id == row_id, BizSampleCode.del_flag == 0))
|
||||
if tenant_no:
|
||||
q = q.filter(BizSampleCode.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_sample_code(
|
||||
db: Session,
|
||||
*,
|
||||
sample_code: str,
|
||||
issued: int = 0,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizSampleCode:
|
||||
sc = (sample_code or "").strip()
|
||||
if not sc:
|
||||
raise ValueError("样本编码不能为空")
|
||||
if sample_code_exists(db, sc, tenant_no):
|
||||
raise ValueError("样本编码已存在")
|
||||
obj = BizSampleCode(
|
||||
id=generate_uuid(),
|
||||
sample_code=sc,
|
||||
issued=1 if issued else 0,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_sample_code(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
sample_code: Optional[str] = None,
|
||||
issued: Optional[int] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
issued_user_id: Optional[str] = None,
|
||||
issued_user_name: Optional[str] = None,
|
||||
issued_time: Optional[str] = None,
|
||||
) -> BizSampleCode:
|
||||
obj = get_sample_code(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
if sample_code is not None:
|
||||
sc = sample_code.strip()
|
||||
if not sc:
|
||||
raise ValueError("样本编码不能为空")
|
||||
if sc != obj.sample_code and sample_code_exists(db, sc, obj.tenant_no, exclude_id=row_id):
|
||||
raise ValueError("样本编码已存在")
|
||||
obj.sample_code = sc
|
||||
if issued is not None:
|
||||
obj.issued = 1 if issued else 0
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = remark.strip() or None
|
||||
if issued_user_id is not None:
|
||||
obj.issued_user_id = issued_user_id or None
|
||||
if issued_user_name is not None:
|
||||
obj.issued_user_name = issued_user_name or None
|
||||
if issued_time is not None:
|
||||
obj.issued_time = issued_time or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_sample_code(db: Session, row_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_sample_code(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def batch_generate_sample_codes(
|
||||
db: Session,
|
||||
*,
|
||||
sample_code_type: str,
|
||||
middle: str,
|
||||
seq_start: int,
|
||||
seq_end: int,
|
||||
digit_count: int = 6,
|
||||
tenant_no: str = "zkxw",
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""批量生成样本编码: 类型前缀 + 中间段 + 补零序号"""
|
||||
t = (sample_code_type or "").strip()
|
||||
if t not in ("zxbm", "qdbm"):
|
||||
raise ValueError("样本编码类型无效,仅支持 zxbm/qdbm")
|
||||
m = (middle or "").strip()
|
||||
if not m:
|
||||
raise ValueError("中间段不能为空")
|
||||
if seq_start < 0 or seq_end < 0:
|
||||
raise ValueError("序号不能为负数")
|
||||
if seq_start > seq_end:
|
||||
raise ValueError("开始序号不能大于结束序号")
|
||||
if digit_count < 1:
|
||||
raise ValueError("序号位数不能小于1")
|
||||
count = seq_end - seq_start + 1
|
||||
if count > 10000:
|
||||
raise ValueError("单次批量生成不能超过10000条")
|
||||
|
||||
prefix_map = {"zxbm": "ZKZX", "qdbm": "ZKQD"}
|
||||
prefix = prefix_map[t]
|
||||
|
||||
generated_codes = []
|
||||
for i in range(seq_start, seq_end + 1):
|
||||
seq_str = str(i).zfill(digit_count)
|
||||
full_code = f"{prefix}{m}{seq_str}"
|
||||
generated_codes.append(full_code)
|
||||
|
||||
existing = set(
|
||||
row.sample_code
|
||||
for row in db.query(BizSampleCode.sample_code)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.sample_code.in_(generated_codes),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
created = []
|
||||
skipped = []
|
||||
for code in generated_codes:
|
||||
if code in existing:
|
||||
skipped.append(code)
|
||||
continue
|
||||
obj = BizSampleCode(
|
||||
id=generate_uuid(),
|
||||
sample_code=code,
|
||||
sample_code_type=t,
|
||||
issued=0,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
created.append(obj)
|
||||
|
||||
if created:
|
||||
db.bulk_save_objects(created)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"created": len(created),
|
||||
"skipped": len(skipped),
|
||||
"skippedCodes": skipped,
|
||||
}
|
||||
|
||||
|
||||
def batch_issue_sample_codes(
|
||||
db: Session,
|
||||
*,
|
||||
ids: List[str],
|
||||
issued_user_id: str,
|
||||
issued_user_name: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""批量下发样本编码"""
|
||||
if not ids:
|
||||
raise ValueError("请选择要下发的样本编码")
|
||||
uid = (issued_user_id or "").strip()
|
||||
if not uid:
|
||||
raise ValueError("请选择下发用户")
|
||||
|
||||
now = datetime.now()
|
||||
issued_count = 0
|
||||
already_issued = []
|
||||
|
||||
for row_id in ids:
|
||||
obj = get_sample_code(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
continue
|
||||
if obj.issued == 1:
|
||||
already_issued.append(obj.sample_code)
|
||||
continue
|
||||
obj.issued = 1
|
||||
obj.issued_user_id = uid
|
||||
obj.issued_user_name = (issued_user_name or "").strip() or None
|
||||
obj.issued_time = now
|
||||
obj.update_by = user_id
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
issued_count += 1
|
||||
|
||||
if issued_count > 0:
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"issuedCount": issued_count,
|
||||
"alreadyIssued": already_issued,
|
||||
}
|
||||
|
||||
|
||||
def batch_revoke_sample_codes(
|
||||
db: Session,
|
||||
*,
|
||||
ids: List[str],
|
||||
tenant_no: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if not ids:
|
||||
raise ValueError("请选择要收回的样本编码")
|
||||
|
||||
now = datetime.now()
|
||||
revoked_count = 0
|
||||
not_issued = []
|
||||
|
||||
for row_id in ids:
|
||||
obj = get_sample_code(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
continue
|
||||
if obj.issued != 1:
|
||||
not_issued.append(obj.sample_code)
|
||||
continue
|
||||
obj.issued = 0
|
||||
obj.issued_user_id = None
|
||||
obj.issued_user_name = None
|
||||
obj.issued_time = None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
revoked_count += 1
|
||||
|
||||
if revoked_count > 0:
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"revokedCount": revoked_count,
|
||||
"notIssued": not_issued,
|
||||
}
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -54,7 +54,7 @@ def list_sites(
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(SysSite.order_num.asc(), SysSite.create_time.desc())
|
||||
.order_by(SysSite.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"""存储设施(树结构)+ 小格 CRUD(biz_storage_facility / biz_storage_facility_slot)"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizStorageFacility, BizStorageFacilitySlot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Facility node helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _row_to_dict(f: BizStorageFacility) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": f.id,
|
||||
"parentId": f.parent_id,
|
||||
"nodeName": f.node_name,
|
||||
"nodeType": f.node_type or "",
|
||||
"isLeaf": 1 if f.is_leaf else 0,
|
||||
"tenantNo": f.tenant_no,
|
||||
"orderNum": f.order_num if f.order_num is not None else 0,
|
||||
"remark": f.remark or "",
|
||||
"createTime": f.create_time.strftime("%Y-%m-%d %H:%M:%S") if f.create_time else "",
|
||||
"updateTime": f.update_time.strftime("%Y-%m-%d %H:%M:%S") if f.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_all_facilities(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
keyword: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return FLAT list of all non-deleted facility nodes (no pagination, no tree building)."""
|
||||
q = db.query(BizStorageFacility).filter(BizStorageFacility.del_flag == 0)
|
||||
if tenant_no:
|
||||
q = q.filter(BizStorageFacility.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(BizStorageFacility.node_name.like(like))
|
||||
rows = q.order_by(BizStorageFacility.order_num.asc(), BizStorageFacility.create_time.asc()).all()
|
||||
return [_row_to_dict(x) for x in rows]
|
||||
|
||||
|
||||
def get_facility(db: Session, node_id: str, tenant_no: Optional[str] = None) -> Optional[BizStorageFacility]:
|
||||
q = db.query(BizStorageFacility).filter(
|
||||
and_(BizStorageFacility.id == node_id, BizStorageFacility.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizStorageFacility.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def get_children(db: Session, parent_id: str, tenant_no: str) -> List[BizStorageFacility]:
|
||||
"""Direct children of a node."""
|
||||
return (
|
||||
db.query(BizStorageFacility)
|
||||
.filter(
|
||||
and_(
|
||||
BizStorageFacility.parent_id == parent_id,
|
||||
BizStorageFacility.del_flag == 0,
|
||||
BizStorageFacility.tenant_no == tenant_no,
|
||||
)
|
||||
)
|
||||
.order_by(BizStorageFacility.order_num.asc(), BizStorageFacility.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def collect_descendant_ids(db: Session, node_id: str, tenant_no: str) -> List[str]:
|
||||
"""Iteratively collect ALL descendant node IDs (stack-based, no recursion)."""
|
||||
ids: List[str] = []
|
||||
stack: List[str] = [node_id]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
children = get_children(db, current, tenant_no)
|
||||
for child in children:
|
||||
ids.append(child.id)
|
||||
stack.append(child.id)
|
||||
return ids
|
||||
|
||||
|
||||
def create_facility(
|
||||
db: Session,
|
||||
*,
|
||||
parent_id: str = "0",
|
||||
node_name: str,
|
||||
node_type: Optional[str] = None,
|
||||
is_leaf: int = 0,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizStorageFacility:
|
||||
name = (node_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("节点名称不能为空")
|
||||
obj = BizStorageFacility(
|
||||
id=generate_uuid(),
|
||||
parent_id=parent_id or "0",
|
||||
node_name=name,
|
||||
node_type=(node_type or "").strip() or None,
|
||||
is_leaf=is_leaf,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_facility(
|
||||
db: Session,
|
||||
node_id: str,
|
||||
*,
|
||||
node_name: Optional[str] = None,
|
||||
node_type: Optional[str] = None,
|
||||
is_leaf: Optional[int] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizStorageFacility:
|
||||
obj = get_facility(db, node_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("存储设施节点不存在")
|
||||
if node_name is not None:
|
||||
name = (node_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("节点名称不能为空")
|
||||
obj.node_name = name
|
||||
if node_type is not None:
|
||||
obj.node_type = (node_type or "").strip() or None
|
||||
if is_leaf is not None:
|
||||
obj.is_leaf = is_leaf
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_facility(db: Session, node_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
"""Cascade soft-delete: node + all descendants + their slots."""
|
||||
node = get_facility(db, node_id, tenant_no=tenant_no)
|
||||
if not node:
|
||||
raise ValueError("存储设施节点不存在")
|
||||
# Collect all descendant IDs
|
||||
all_ids = [node_id] + collect_descendant_ids(db, node_id, node.tenant_no)
|
||||
now = datetime.now()
|
||||
# Soft-delete all slots belonging to these nodes
|
||||
db.query(BizStorageFacilitySlot).filter(
|
||||
BizStorageFacilitySlot.facility_node_id.in_(all_ids),
|
||||
BizStorageFacilitySlot.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
# Soft-delete all facility nodes
|
||||
db.query(BizStorageFacility).filter(
|
||||
BizStorageFacility.id.in_(all_ids),
|
||||
BizStorageFacility.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _slot_to_dict(s: BizStorageFacilitySlot) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": s.id,
|
||||
"facilityNodeId": s.facility_node_id,
|
||||
"slotCode": s.slot_code or "",
|
||||
"slotStatus": s.slot_status or "",
|
||||
"tenantNo": s.tenant_no,
|
||||
"orderNum": s.order_num if s.order_num is not None else 0,
|
||||
"remark": s.remark or "",
|
||||
"createTime": s.create_time.strftime("%Y-%m-%d %H:%M:%S") if s.create_time else "",
|
||||
"updateTime": s.update_time.strftime("%Y-%m-%d %H:%M:%S") if s.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_slots(
|
||||
db: Session,
|
||||
*,
|
||||
facility_node_id: str,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
q = db.query(BizStorageFacilitySlot).filter(
|
||||
and_(
|
||||
BizStorageFacilitySlot.facility_node_id == facility_node_id,
|
||||
BizStorageFacilitySlot.del_flag == 0,
|
||||
)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizStorageFacilitySlot.tenant_no == tenant_no)
|
||||
rows = q.order_by(BizStorageFacilitySlot.order_num.asc(), BizStorageFacilitySlot.create_time.asc()).all()
|
||||
return [_slot_to_dict(x) for x in rows]
|
||||
|
||||
|
||||
def get_slot(db: Session, slot_id: str, tenant_no: Optional[str] = None) -> Optional[BizStorageFacilitySlot]:
|
||||
q = db.query(BizStorageFacilitySlot).filter(
|
||||
and_(BizStorageFacilitySlot.id == slot_id, BizStorageFacilitySlot.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizStorageFacilitySlot.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_slot(
|
||||
db: Session,
|
||||
*,
|
||||
facility_node_id: str,
|
||||
slot_code: Optional[str] = None,
|
||||
slot_status: Optional[str] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizStorageFacilitySlot:
|
||||
fid = (facility_node_id or "").strip()
|
||||
if not fid:
|
||||
raise ValueError("所属设施节点不能为空")
|
||||
obj = BizStorageFacilitySlot(
|
||||
id=generate_uuid(),
|
||||
facility_node_id=fid,
|
||||
slot_code=(slot_code or "").strip() or None,
|
||||
slot_status=(slot_status or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_slot(
|
||||
db: Session,
|
||||
slot_id: str,
|
||||
*,
|
||||
slot_code: Optional[str] = None,
|
||||
slot_status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizStorageFacilitySlot:
|
||||
obj = get_slot(db, slot_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("存储设施小格不存在")
|
||||
if slot_code is not None:
|
||||
obj.slot_code = (slot_code or "").strip() or None
|
||||
if slot_status is not None:
|
||||
obj.slot_status = (slot_status or "").strip() or None
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_slot(db: Session, slot_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_slot(db, slot_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("存储设施小格不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def batch_create_slots(
|
||||
db: Session,
|
||||
*,
|
||||
facility_node_id: str,
|
||||
rows: int,
|
||||
cols: int,
|
||||
code_prefix: str = "",
|
||||
tenant_no: str = "zkxw",
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
fid = (facility_node_id or "").strip()
|
||||
if not fid:
|
||||
raise ValueError("所属设施节点不能为空")
|
||||
if rows < 1 or cols < 1:
|
||||
raise ValueError("行数和列数必须大于 0")
|
||||
if rows > 50 or cols > 50:
|
||||
raise ValueError("单次最多生成 50×50 = 2500 个格子")
|
||||
node = db.query(BizStorageFacility).filter(
|
||||
and_(BizStorageFacility.id == fid, BizStorageFacility.del_flag == 0)
|
||||
).first()
|
||||
if not node:
|
||||
raise ValueError("所属设施节点不存在")
|
||||
if not node.is_leaf:
|
||||
raise ValueError("只能为叶子节点创建格子")
|
||||
prefix = (code_prefix or "").strip()
|
||||
now = datetime.now()
|
||||
slots: List[BizStorageFacilitySlot] = []
|
||||
for r in range(1, rows + 1):
|
||||
for c in range(1, cols + 1):
|
||||
code = f"{prefix}{r}-{c}" if prefix else f"{r}-{c}"
|
||||
order = (r - 1) * cols + c
|
||||
obj = BizStorageFacilitySlot(
|
||||
id=generate_uuid(),
|
||||
facility_node_id=fid,
|
||||
slot_code=code,
|
||||
slot_status="空",
|
||||
tenant_no=tenant_no,
|
||||
order_num=order,
|
||||
remark=None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
slots.append(obj)
|
||||
db.commit()
|
||||
for s in slots:
|
||||
db.refresh(s)
|
||||
return [_slot_to_dict(s) for s in slots]
|
||||
@@ -47,7 +47,7 @@ def list_tenants(
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(SysTenant.order_num.asc(), SysTenant.create_time.desc())
|
||||
.order_by(SysTenant.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
|
||||
@@ -90,7 +90,7 @@ def list_users(
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(SysUser.order_num.asc(), SysUser.create_time.desc())
|
||||
.order_by(SysUser.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
@@ -114,6 +114,91 @@ def get_user(db: Session, user_id: str) -> Optional[SysUser]:
|
||||
)
|
||||
|
||||
|
||||
def list_post_ids_for_user(db: Session, user_id: str) -> List[str]:
|
||||
"""用户当前有效职位 ID 列表。"""
|
||||
return [
|
||||
r.post_id
|
||||
for r in db.query(SysUserPost)
|
||||
.filter(and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0))
|
||||
.all()
|
||||
]
|
||||
|
||||
|
||||
def set_user_post_ids_only(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
tenant_no: str,
|
||||
post_ids: List[str],
|
||||
updater_id: Optional[str],
|
||||
) -> None:
|
||||
"""仅替换 sys_user_post,不影响 sys_user_role。"""
|
||||
now = datetime.now()
|
||||
uniq = [p for p in dict.fromkeys(post_ids or []) if p]
|
||||
db.query(SysUserPost).filter(
|
||||
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
for pid in uniq:
|
||||
db.add(
|
||||
SysUserPost(
|
||||
id=generate_uuid(),
|
||||
user_id=user_id,
|
||||
post_id=pid,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=updater_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=updater_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def list_user_ids_bound_to_post(db: Session, post_id: str, tenant_no: str) -> List[str]:
|
||||
rows = (
|
||||
db.query(SysUserPost.user_id)
|
||||
.filter(
|
||||
and_(
|
||||
SysUserPost.post_id == post_id,
|
||||
SysUserPost.tenant_no == tenant_no,
|
||||
SysUserPost.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def sync_post_user_bindings(
|
||||
db: Session,
|
||||
post_id: str,
|
||||
tenant_no: str,
|
||||
user_ids: List[str],
|
||||
updater_id: Optional[str],
|
||||
) -> None:
|
||||
"""将本职位绑定的用户集合设为 user_ids(只增删本职位,保留用户其它职位与角色)。"""
|
||||
ordered = list(dict.fromkeys([uid for uid in (user_ids or []) if uid]))
|
||||
for uid in ordered:
|
||||
u = get_user(db, uid)
|
||||
if not u or u.tenant_no != tenant_no:
|
||||
raise ValueError("存在无效用户或用户不属于当前租户")
|
||||
|
||||
current = set(list_user_ids_bound_to_post(db, post_id, tenant_no))
|
||||
target = set(ordered)
|
||||
affected = current | target
|
||||
for uid in affected:
|
||||
u = get_user(db, uid)
|
||||
if not u or u.tenant_no != tenant_no:
|
||||
continue
|
||||
existing = list_post_ids_for_user(db, uid)
|
||||
without = [p for p in existing if p != post_id]
|
||||
new_posts = without + [post_id] if uid in target else without
|
||||
set_user_post_ids_only(db, uid, tenant_no, new_posts, updater_id)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_user_with_relations(db: Session, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
u = get_user(db, user_id)
|
||||
if not u:
|
||||
@@ -133,12 +218,7 @@ def get_user_with_relations(db: Session, user_id: str) -> Optional[Dict[str, Any
|
||||
.filter(and_(SysUserRole.user_id == user_id, SysUserRole.del_flag == 0))
|
||||
.all()
|
||||
]
|
||||
post_ids = [
|
||||
p.post_id
|
||||
for p in db.query(SysUserPost)
|
||||
.filter(and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0))
|
||||
.all()
|
||||
]
|
||||
post_ids = list_post_ids_for_user(db, user_id)
|
||||
d["roleIds"] = role_ids
|
||||
d["postIds"] = post_ids
|
||||
return d
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
"""工作流 CRUD(wf_process_def / wf_process_version / wf_node / wf_transition / wf_process_instance / wf_task)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
SysUser,
|
||||
WfNode,
|
||||
WfProcessDef,
|
||||
WfProcessInstance,
|
||||
WfProcessVersion,
|
||||
WfTask,
|
||||
WfTransition,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 流程定义 wf_process_def
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _row_to_dict(t: WfProcessDef) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": t.id,
|
||||
"processCode": t.process_code,
|
||||
"processName": t.process_name,
|
||||
"status": t.status,
|
||||
"tenantNo": t.tenant_no,
|
||||
"orderNum": t.order_num if t.order_num is not None else 0,
|
||||
"remark": t.remark or "",
|
||||
"createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
|
||||
"updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_process_defs(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(WfProcessDef).filter(WfProcessDef.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(WfProcessDef.tenant_no == tenant_no)
|
||||
if status is not None:
|
||||
q0 = q0.filter(WfProcessDef.status == status)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(or_(WfProcessDef.process_name.like(like), WfProcessDef.process_code.like(like)))
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(WfProcessDef.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(WfProcessDef.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
d = _row_to_dict(r)
|
||||
ver = (
|
||||
db.query(WfProcessVersion)
|
||||
.filter(
|
||||
and_(
|
||||
WfProcessVersion.process_def_id == r.id,
|
||||
WfProcessVersion.is_latest == "1",
|
||||
WfProcessVersion.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.order_by(WfProcessVersion.version_no.desc())
|
||||
.first()
|
||||
)
|
||||
d["latestVersion"] = ver.version_no if ver else None
|
||||
result.append(d)
|
||||
return result, int(total)
|
||||
|
||||
|
||||
def get_process_def(db: Session, def_id: str) -> Optional[WfProcessDef]:
|
||||
return (
|
||||
db.query(WfProcessDef)
|
||||
.filter(and_(WfProcessDef.id == def_id, WfProcessDef.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def process_code_exists(
|
||||
db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None
|
||||
) -> bool:
|
||||
q = db.query(WfProcessDef).filter(
|
||||
and_(WfProcessDef.process_code == code.strip(), WfProcessDef.tenant_no == tenant_no, WfProcessDef.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(WfProcessDef.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def create_process_def(
|
||||
db: Session,
|
||||
*,
|
||||
process_code: str,
|
||||
process_name: str,
|
||||
tenant_no: str,
|
||||
status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> WfProcessDef:
|
||||
code = (process_code or "").strip()
|
||||
if not code:
|
||||
raise ValueError("流程编码不能为空")
|
||||
if process_code_exists(db, code, tenant_no):
|
||||
raise ValueError("流程编码已存在")
|
||||
obj = WfProcessDef(
|
||||
id=generate_uuid(),
|
||||
process_code=code,
|
||||
process_name=(process_name or "").strip(),
|
||||
status=status or "1",
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_process_def(
|
||||
db: Session,
|
||||
def_id: str,
|
||||
*,
|
||||
process_code: Optional[str] = None,
|
||||
process_name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> WfProcessDef:
|
||||
obj = get_process_def(db, def_id)
|
||||
if not obj:
|
||||
raise ValueError("流程定义不存在")
|
||||
if process_code is not None:
|
||||
new_code = process_code.strip()
|
||||
if not new_code:
|
||||
raise ValueError("流程编码不能为空")
|
||||
tn = tenant_no or obj.tenant_no
|
||||
if new_code != obj.process_code and process_code_exists(db, new_code, tn, exclude_id=def_id):
|
||||
raise ValueError("流程编码已存在")
|
||||
obj.process_code = new_code
|
||||
if process_name is not None:
|
||||
obj.process_name = (process_name or "").strip()
|
||||
if status is not None:
|
||||
obj.status = status
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_process_def(db: Session, def_id: str) -> None:
|
||||
obj = get_process_def(db, def_id)
|
||||
if not obj:
|
||||
raise ValueError("流程定义不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 流程版本 wf_process_version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def publish_version(
|
||||
db: Session,
|
||||
process_def_id: str,
|
||||
graph_json: str,
|
||||
nodes: List[Dict[str, Any]],
|
||||
transitions: List[Dict[str, Any]],
|
||||
tenant_no: str,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> WfProcessVersion:
|
||||
defn = get_process_def(db, process_def_id)
|
||||
if not defn:
|
||||
raise ValueError("流程定义不存在")
|
||||
|
||||
db.query(WfProcessVersion).filter(
|
||||
and_(
|
||||
WfProcessVersion.process_def_id == process_def_id,
|
||||
WfProcessVersion.is_latest == "1",
|
||||
WfProcessVersion.del_flag == 0,
|
||||
)
|
||||
).update({"is_latest": "0", "update_time": datetime.now()}, synchronize_session=False)
|
||||
|
||||
max_ver = (
|
||||
db.query(func.max(WfProcessVersion.version_no))
|
||||
.filter(and_(WfProcessVersion.process_def_id == process_def_id, WfProcessVersion.del_flag == 0))
|
||||
.scalar()
|
||||
) or 0
|
||||
new_ver_no = max_ver + 1
|
||||
|
||||
ver = WfProcessVersion(
|
||||
id=generate_uuid(),
|
||||
process_def_id=process_def_id,
|
||||
version_no=new_ver_no,
|
||||
graph_json=graph_json,
|
||||
is_latest="1",
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(ver)
|
||||
db.flush()
|
||||
|
||||
for idx, n in enumerate(nodes):
|
||||
node = WfNode(
|
||||
id=generate_uuid(),
|
||||
process_version_id=ver.id,
|
||||
node_key=n.get("nodeKey", ""),
|
||||
node_type=n.get("nodeType", ""),
|
||||
node_name=n.get("nodeName"),
|
||||
config_json=n.get("configJson"),
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
remark=None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(node)
|
||||
|
||||
for idx, t in enumerate(transitions):
|
||||
trans = WfTransition(
|
||||
id=generate_uuid(),
|
||||
process_version_id=ver.id,
|
||||
from_node_key=t.get("fromNodeKey", ""),
|
||||
to_node_key=t.get("toNodeKey", ""),
|
||||
condition_expr=t.get("conditionExpr"),
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
remark=None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(trans)
|
||||
|
||||
db.commit()
|
||||
db.refresh(ver)
|
||||
return ver
|
||||
|
||||
|
||||
def get_latest_version(db: Session, process_def_id: str) -> Optional[WfProcessVersion]:
|
||||
return (
|
||||
db.query(WfProcessVersion)
|
||||
.filter(
|
||||
and_(
|
||||
WfProcessVersion.process_def_id == process_def_id,
|
||||
WfProcessVersion.is_latest == "1",
|
||||
WfProcessVersion.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _version_to_dict(v: WfProcessVersion) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": v.id,
|
||||
"versionNo": v.version_no,
|
||||
"isLatest": v.is_latest,
|
||||
"remark": v.remark or "",
|
||||
"createTime": v.create_time.strftime("%Y-%m-%d %H:%M:%S") if v.create_time else "",
|
||||
"updateTime": v.update_time.strftime("%Y-%m-%d %H:%M:%S") if v.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_versions(
|
||||
db: Session, process_def_id: str, tenant_no: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
q = db.query(WfProcessVersion).filter(
|
||||
and_(WfProcessVersion.process_def_id == process_def_id, WfProcessVersion.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(WfProcessVersion.tenant_no == tenant_no)
|
||||
rows = q.order_by(WfProcessVersion.version_no.desc()).all()
|
||||
return [_version_to_dict(v) for v in rows]
|
||||
|
||||
|
||||
def get_nodes_by_version(db: Session, version_id: str) -> List[WfNode]:
|
||||
return (
|
||||
db.query(WfNode)
|
||||
.filter(and_(WfNode.process_version_id == version_id, WfNode.del_flag == 0))
|
||||
.order_by(WfNode.order_num.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def get_transitions_by_version(db: Session, version_id: str) -> List[WfTransition]:
|
||||
return (
|
||||
db.query(WfTransition)
|
||||
.filter(and_(WfTransition.process_version_id == version_id, WfTransition.del_flag == 0))
|
||||
.order_by(WfTransition.order_num.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 流程实例 wf_process_instance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_instance(
|
||||
db: Session,
|
||||
*,
|
||||
process_version_id: str,
|
||||
business_type: Optional[str] = None,
|
||||
business_id: Optional[str] = None,
|
||||
starter_user_id: Optional[str] = None,
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
status: Optional[str] = None,
|
||||
callback_url: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> WfProcessInstance:
|
||||
obj = WfProcessInstance(
|
||||
id=generate_uuid(),
|
||||
process_version_id=process_version_id,
|
||||
business_type=business_type,
|
||||
business_id=business_id,
|
||||
starter_user_id=starter_user_id,
|
||||
variables_json=json.dumps(variables, ensure_ascii=False) if variables else None,
|
||||
status=status or "running",
|
||||
callback_url=callback_url,
|
||||
tenant_no=tenant_no or "zkxw",
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def get_instance(db: Session, instance_id: str) -> Optional[WfProcessInstance]:
|
||||
return (
|
||||
db.query(WfProcessInstance)
|
||||
.filter(and_(WfProcessInstance.id == instance_id, WfProcessInstance.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def update_instance_status(db: Session, instance_id: str, status: str) -> None:
|
||||
obj = get_instance(db, instance_id)
|
||||
if not obj:
|
||||
raise ValueError("流程实例不存在")
|
||||
obj.status = status
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_my_instances(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
from sqlalchemy import func as sa_func
|
||||
|
||||
latest_per_biz = (
|
||||
db.query(
|
||||
WfProcessInstance.business_id,
|
||||
sa_func.max(WfProcessInstance.create_time).label("max_ct"),
|
||||
)
|
||||
.filter(
|
||||
and_(
|
||||
WfProcessInstance.starter_user_id == user_id,
|
||||
WfProcessInstance.del_flag == 0,
|
||||
WfProcessInstance.business_id != None,
|
||||
WfProcessInstance.business_id != "",
|
||||
)
|
||||
)
|
||||
.group_by(WfProcessInstance.business_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
q0 = (
|
||||
db.query(WfProcessInstance, WfProcessVersion, WfProcessDef)
|
||||
.join(WfProcessVersion, WfProcessInstance.process_version_id == WfProcessVersion.id)
|
||||
.join(WfProcessDef, WfProcessVersion.process_def_id == WfProcessDef.id)
|
||||
.join(
|
||||
latest_per_biz,
|
||||
and_(
|
||||
WfProcessInstance.business_id == latest_per_biz.c.business_id,
|
||||
WfProcessInstance.create_time == latest_per_biz.c.max_ct,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
and_(
|
||||
WfProcessInstance.starter_user_id == user_id,
|
||||
WfProcessInstance.del_flag == 0,
|
||||
WfProcessVersion.del_flag == 0,
|
||||
WfProcessDef.del_flag == 0,
|
||||
)
|
||||
)
|
||||
)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(WfProcessInstance.tenant_no == tenant_no)
|
||||
if status:
|
||||
q0 = q0.filter(WfProcessInstance.status == status)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(WfProcessDef.process_name.like(like), WfProcessInstance.business_id.like(like))
|
||||
)
|
||||
|
||||
total = q0.order_by(None).with_entities(func.count(WfProcessInstance.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
q0.order_by(WfProcessInstance.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
result = []
|
||||
for inst, ver, defn in rows:
|
||||
result.append({
|
||||
"id": inst.id,
|
||||
"processName": defn.process_name,
|
||||
"businessType": inst.business_type,
|
||||
"businessId": inst.business_id,
|
||||
"starterUserId": inst.starter_user_id,
|
||||
"status": inst.status,
|
||||
"createTime": inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "",
|
||||
"updateTime": inst.update_time.strftime("%Y-%m-%d %H:%M:%S") if inst.update_time else "",
|
||||
})
|
||||
return result, int(total)
|
||||
|
||||
|
||||
def get_all_tasks_for_business(
|
||||
db: Session,
|
||||
business_type: str,
|
||||
business_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
instances = (
|
||||
db.query(WfProcessInstance)
|
||||
.filter(
|
||||
and_(
|
||||
WfProcessInstance.business_type == business_type,
|
||||
WfProcessInstance.business_id == business_id,
|
||||
WfProcessInstance.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.order_by(WfProcessInstance.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
all_tasks: List[Dict[str, Any]] = []
|
||||
for inst in instances:
|
||||
node_name_map: Dict[str, str] = {}
|
||||
if inst.process_version_id:
|
||||
for n in get_nodes_by_version(db, inst.process_version_id):
|
||||
node_name_map[n.node_key] = n.node_name or n.node_key
|
||||
|
||||
tasks = (
|
||||
db.query(WfTask)
|
||||
.filter(and_(WfTask.instance_id == inst.id, WfTask.del_flag == 0))
|
||||
.order_by(WfTask.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
user_ids = {t.assignee_user_id for t in tasks if t.assignee_user_id}
|
||||
user_name_map: Dict[str, str] = {}
|
||||
if user_ids:
|
||||
rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(user_ids)).all()
|
||||
user_name_map = {r.id: r.nick_name for r in rows}
|
||||
|
||||
for t in tasks:
|
||||
all_tasks.append({
|
||||
"id": t.id,
|
||||
"instanceId": inst.id,
|
||||
"instanceStatus": inst.status,
|
||||
"nodeKey": t.node_key,
|
||||
"nodeName": node_name_map.get(t.node_key, t.node_key or ""),
|
||||
"assigneeUserId": t.assignee_user_id,
|
||||
"assigneeUserName": user_name_map.get(t.assignee_user_id, t.assignee_user_id or ""),
|
||||
"status": t.status,
|
||||
"commentText": t.comment_text,
|
||||
"createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
|
||||
"updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
|
||||
})
|
||||
|
||||
return all_tasks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 流程任务 wf_task
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_task(
|
||||
db: Session,
|
||||
*,
|
||||
instance_id: str,
|
||||
node_key: Optional[str] = None,
|
||||
assignee_user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> WfTask:
|
||||
obj = WfTask(
|
||||
id=generate_uuid(),
|
||||
instance_id=instance_id,
|
||||
node_key=node_key,
|
||||
assignee_user_id=assignee_user_id,
|
||||
status="pending",
|
||||
tenant_no=tenant_no or "zkxw",
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def get_task(db: Session, task_id: str) -> Optional[WfTask]:
|
||||
return (
|
||||
db.query(WfTask)
|
||||
.filter(and_(WfTask.id == task_id, WfTask.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_pending_tasks_by_node(db: Session, instance_id: str, node_key: str) -> List[WfTask]:
|
||||
return (
|
||||
db.query(WfTask)
|
||||
.filter(
|
||||
and_(
|
||||
WfTask.instance_id == instance_id,
|
||||
WfTask.node_key == node_key,
|
||||
WfTask.status == "pending",
|
||||
WfTask.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _task_row_to_dict(
|
||||
t: WfTask,
|
||||
process_name: Optional[str] = None,
|
||||
business_type: Optional[str] = None,
|
||||
business_id: Optional[str] = None,
|
||||
starter_user_id: Optional[str] = None,
|
||||
instance_status: Optional[str] = None,
|
||||
instance_create_time: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": t.id,
|
||||
"instanceId": t.instance_id,
|
||||
"nodeKey": t.node_key,
|
||||
"assigneeUserId": t.assignee_user_id,
|
||||
"status": t.status,
|
||||
"commentText": t.comment_text,
|
||||
"createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
|
||||
"updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
|
||||
"processName": process_name,
|
||||
"businessType": business_type,
|
||||
"businessId": business_id,
|
||||
"starterUserId": starter_user_id,
|
||||
"instanceStatus": instance_status,
|
||||
"instanceCreateTime": instance_create_time,
|
||||
}
|
||||
|
||||
|
||||
def list_pending_tasks(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
q0 = (
|
||||
db.query(WfTask, WfProcessInstance, WfProcessVersion, WfProcessDef)
|
||||
.join(WfProcessInstance, WfTask.instance_id == WfProcessInstance.id)
|
||||
.join(WfProcessVersion, WfProcessInstance.process_version_id == WfProcessVersion.id)
|
||||
.join(WfProcessDef, WfProcessVersion.process_def_id == WfProcessDef.id)
|
||||
.filter(
|
||||
and_(
|
||||
WfTask.assignee_user_id == user_id,
|
||||
WfTask.status == "pending",
|
||||
WfTask.del_flag == 0,
|
||||
WfProcessInstance.del_flag == 0,
|
||||
WfProcessVersion.del_flag == 0,
|
||||
WfProcessDef.del_flag == 0,
|
||||
)
|
||||
)
|
||||
)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(WfTask.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(WfProcessDef.process_name.like(like), WfProcessDef.process_code.like(like))
|
||||
)
|
||||
|
||||
total = q0.order_by(None).with_entities(func.count(WfTask.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
q0.order_by(WfTask.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
result = []
|
||||
for task, inst, ver, defn in rows:
|
||||
result.append(
|
||||
_task_row_to_dict(
|
||||
task,
|
||||
process_name=defn.process_name,
|
||||
business_type=inst.business_type,
|
||||
business_id=inst.business_id,
|
||||
starter_user_id=inst.starter_user_id,
|
||||
instance_status=inst.status,
|
||||
instance_create_time=inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "",
|
||||
)
|
||||
)
|
||||
return result, int(total)
|
||||
|
||||
|
||||
def list_completed_tasks(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
q0 = (
|
||||
db.query(WfTask, WfProcessInstance, WfProcessVersion, WfProcessDef)
|
||||
.join(WfProcessInstance, WfTask.instance_id == WfProcessInstance.id)
|
||||
.join(WfProcessVersion, WfProcessInstance.process_version_id == WfProcessVersion.id)
|
||||
.join(WfProcessDef, WfProcessVersion.process_def_id == WfProcessDef.id)
|
||||
.filter(
|
||||
and_(
|
||||
WfTask.assignee_user_id == user_id,
|
||||
WfTask.status.in_(["approved", "rejected"]),
|
||||
WfTask.del_flag == 0,
|
||||
WfProcessInstance.del_flag == 0,
|
||||
WfProcessVersion.del_flag == 0,
|
||||
WfProcessDef.del_flag == 0,
|
||||
)
|
||||
)
|
||||
)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(WfTask.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(WfProcessDef.process_name.like(like), WfProcessDef.process_code.like(like))
|
||||
)
|
||||
|
||||
total = q0.order_by(None).with_entities(func.count(WfTask.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
q0.order_by(WfTask.update_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
result = []
|
||||
for task, inst, ver, defn in rows:
|
||||
result.append(
|
||||
_task_row_to_dict(
|
||||
task,
|
||||
process_name=defn.process_name,
|
||||
business_type=inst.business_type,
|
||||
business_id=inst.business_id,
|
||||
starter_user_id=inst.starter_user_id,
|
||||
instance_status=inst.status,
|
||||
instance_create_time=inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "",
|
||||
)
|
||||
)
|
||||
return result, int(total)
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
+7
-1
@@ -10,7 +10,7 @@ from app.core.database import SessionLocal
|
||||
from app.core.api_request_log_middleware import ApiRequestLogMiddleware
|
||||
from app.core.api_crypto_middleware import ApiCryptoMiddleware
|
||||
from app.models import models # noqa: F401
|
||||
from app.api import public_router, menu_router, admin_router
|
||||
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -61,6 +61,12 @@ app.add_middleware(ApiCryptoMiddleware)
|
||||
app.include_router(public_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(menu_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(admin_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(workflow_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(basic_info_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(collection_protocol_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(leave_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(operations_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(ethnic_router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
logger = logging.getLogger("sqxp")
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""数据模型(与 docs/sql/zkxw-requirements-v0.3-schema.sql 对齐的核心系统表)"""
|
||||
"""数据模型(与 docs/sql/zkxw.sql 全库导出基线对齐的核心系统表)"""
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Integer, Text, CHAR, SmallInteger
|
||||
from sqlalchemy import Column, String, DateTime, Integer, Text, CHAR, SmallInteger, Numeric
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
@@ -294,6 +294,132 @@ class BizExpressCompany(Base):
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCustomer(Base):
|
||||
"""客户 biz_customer"""
|
||||
|
||||
__tablename__ = "biz_customer"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
customer_name = Column(String(100), nullable=False, comment="客户姓名")
|
||||
sex = Column(CHAR(1), nullable=True, default="2", comment="性别(0男1女2未知)")
|
||||
id_card_no = Column(String(32), nullable=True, comment="身份证号")
|
||||
age = Column(Integer, nullable=True, comment="年龄")
|
||||
phone_no = Column(String(32), nullable=True, comment="联系电话")
|
||||
customer_tags = Column(String(512), nullable=True, comment="客户标签,逗号分隔")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCellSampleType(Base):
|
||||
"""细胞样本类型 biz_cell_sample_type"""
|
||||
|
||||
__tablename__ = "biz_cell_sample_type"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
type_code = Column(String(64), nullable=False, comment="样本类型编码")
|
||||
type_name = Column(String(200), nullable=False, comment="样本类型名称")
|
||||
storage_years = Column(Integer, nullable=True, comment="样本存储年份")
|
||||
storage_price = Column(Numeric(18, 2), nullable=True, comment="样本存储价格(元)")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCollectionRegion(Base):
|
||||
"""采集地区 biz_collection_region"""
|
||||
|
||||
__tablename__ = "biz_collection_region"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
region_name = Column(String(200), nullable=False, comment="地区名称")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCollectionHospital(Base):
|
||||
"""采集地区下属医院 biz_collection_hospital"""
|
||||
|
||||
__tablename__ = "biz_collection_hospital"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
region_id = Column(String(64), nullable=False, comment="采集地区ID")
|
||||
hospital_name = Column(String(200), nullable=False, comment="医院名称")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCollectionProtocolNo(Base):
|
||||
"""采集协议号池 biz_collection_protocol_no"""
|
||||
|
||||
__tablename__ = "biz_collection_protocol_no"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
protocol_no = Column(String(32), nullable=False, comment="采集协议号")
|
||||
issued = Column(SmallInteger, nullable=False, default=0, comment="是否下发(1是0否)")
|
||||
issued_user_id = Column(String(64), nullable=True, comment="下发用户ID")
|
||||
issued_user_name = Column(String(100), nullable=True, comment="下发用户姓名(冗余)")
|
||||
issued_time = Column(DateTime, nullable=True, comment="下发时间")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizSampleCode(Base):
|
||||
"""样本编码池 biz_sample_code"""
|
||||
|
||||
__tablename__ = "biz_sample_code"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
sample_code = Column(String(32), nullable=False, comment="样本编码")
|
||||
sample_code_type = Column(String(16), nullable=True, comment="样本编码类型 zxbm造血编码 qdbm脐带编码")
|
||||
issued = Column(SmallInteger, nullable=False, default=0, comment="是否下发(1是0否)")
|
||||
issued_user_id = Column(String(64), nullable=True, comment="下发用户ID")
|
||||
issued_user_name = Column(String(100), nullable=True, comment="下发用户姓名(冗余)")
|
||||
issued_time = Column(DateTime, nullable=True, comment="下发时间")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 字典管理(sys_dict_type / sys_dict_data)
|
||||
# =============================================================================
|
||||
@@ -318,6 +444,25 @@ class SysDictType(Base):
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class SysEthnic(Base):
|
||||
"""民族码表 sys_ethnic(国标 GB/T 3304,全局)"""
|
||||
|
||||
__tablename__ = "sys_ethnic"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
ethnic_name = Column(String(64), nullable=False, default="", comment="民族名称")
|
||||
ethnic_code = Column(String(16), nullable=False, default="", comment="民族编码")
|
||||
tenant_no = Column(String(64), nullable=False, default="", comment="租户编号(空=全局)")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class SysDictData(Base):
|
||||
"""字典数据 sys_dict_data"""
|
||||
|
||||
@@ -393,3 +538,555 @@ class SysApiRequestLogHis(Base):
|
||||
update_at = Column(DateTime, nullable=True, comment="修改时间")
|
||||
update_by = Column(String(32), nullable=True, comment="修改人ID")
|
||||
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 业务表(biz_leave_request)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BizLeaveRequest(Base):
|
||||
"""请假申请 biz_leave_request"""
|
||||
|
||||
__tablename__ = "biz_leave_request"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
leave_type = Column(String(32), nullable=False, default="personal", comment="请假类型: personal/sick/annual/other")
|
||||
start_date = Column(String(20), nullable=False, comment="开始日期")
|
||||
end_date = Column(String(20), nullable=False, comment="结束日期")
|
||||
days = Column(Integer, nullable=False, default=1, comment="请假天数")
|
||||
reason = Column(String(500), nullable=True, comment="请假原因")
|
||||
wf_instance_id = Column(String(64), nullable=True, comment="流程实例ID")
|
||||
status = Column(String(32), nullable=False, default="draft", comment="状态: draft/pending/approved/rejected/cancelled")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记:0-正常 1-已删除")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 工作流(wf_process_def / wf_process_version / wf_node / wf_transition / wf_process_instance / wf_task)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class WfProcessDef(Base):
|
||||
"""流程定义 wf_process_def"""
|
||||
|
||||
__tablename__ = "wf_process_def"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
process_code = Column(String(64), nullable=False, comment="流程编码")
|
||||
process_name = Column(String(200), nullable=False, comment="流程名称")
|
||||
status = Column(CHAR(1), nullable=False, default="1", comment="1启用 0停用")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记:0-正常 1-已删除")
|
||||
|
||||
|
||||
class WfProcessVersion(Base):
|
||||
"""流程定义版本 wf_process_version"""
|
||||
|
||||
__tablename__ = "wf_process_version"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
process_def_id = Column(String(64), nullable=False, comment="流程定义ID")
|
||||
version_no = Column(Integer, nullable=False, comment="版本号,递增")
|
||||
graph_json = Column(Text, nullable=True, comment="节点与连线等配置(JSON 字符串)")
|
||||
is_latest = Column(CHAR(1), nullable=True, default="0", comment="是否最新版本(1是 0否)")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记:0-正常 1-已删除")
|
||||
|
||||
|
||||
class WfNode(Base):
|
||||
"""流程节点 wf_node"""
|
||||
|
||||
__tablename__ = "wf_node"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
process_version_id = Column(String(64), nullable=False, comment="流程版本ID")
|
||||
node_key = Column(String(64), nullable=False, comment="节点唯一键(画布内)")
|
||||
node_type = Column(String(32), nullable=False, comment="start/end/approval/condition 等")
|
||||
node_name = Column(String(200), nullable=True, comment="节点名称")
|
||||
config_json = Column(Text, nullable=True, comment="审批人/SQL/会签策略等配置")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记:0-正常 1-已删除")
|
||||
|
||||
|
||||
class WfTransition(Base):
|
||||
"""流程连线/转移 wf_transition"""
|
||||
|
||||
__tablename__ = "wf_transition"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
process_version_id = Column(String(64), nullable=False, comment="流程版本ID")
|
||||
from_node_key = Column(String(64), nullable=False, comment="起始节点key")
|
||||
to_node_key = Column(String(64), nullable=False, comment="目标节点key")
|
||||
condition_expr = Column(String(512), nullable=True, comment="条件表达式或分支说明")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记:0-正常 1-已删除")
|
||||
|
||||
|
||||
class WfProcessInstance(Base):
|
||||
"""流程实例 wf_process_instance"""
|
||||
|
||||
__tablename__ = "wf_process_instance"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
process_version_id = Column(String(64), nullable=False, comment="发起时锁定的流程版本ID")
|
||||
business_type = Column(String(64), nullable=True, comment="业务类型")
|
||||
business_id = Column(String(64), nullable=True, comment="业务主键")
|
||||
starter_user_id = Column(String(64), nullable=True, comment="发起人")
|
||||
variables_json = Column(Text, nullable=True, comment="流程变量 JSON")
|
||||
status = Column(String(32), nullable=True, comment="running/completed/rejected 等")
|
||||
callback_url = Column(String(512), nullable=True, comment="业务回调地址")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记:0-正常 1-已删除")
|
||||
|
||||
|
||||
class WfTask(Base):
|
||||
"""流程任务/待办 wf_task"""
|
||||
|
||||
__tablename__ = "wf_task"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
instance_id = Column(String(64), nullable=False, comment="流程实例ID")
|
||||
node_key = Column(String(64), nullable=True, comment="当前节点key")
|
||||
assignee_user_id = Column(String(64), nullable=True, comment="待办人")
|
||||
status = Column(String(32), nullable=True, comment="pending/approved/rejected 等")
|
||||
comment_text = Column(String(512), nullable=True, comment="审批意见")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记:0-正常 1-已删除")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 细胞存储 — 采集协议单(biz_collection_protocol / audit / payment)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BizCollectionProtocol(Base):
|
||||
"""采集协议单 biz_collection_protocol"""
|
||||
|
||||
__tablename__ = "biz_collection_protocol"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
protocol_no_id = Column(String(64), nullable=True, comment="采集协议号表ID")
|
||||
protocol_no = Column(String(32), nullable=False, comment="采集协议号展示")
|
||||
protocol_status = Column(String(16), nullable=False, default="dsx", comment="协议状态")
|
||||
protocol_attachments = Column(String(4000), nullable=True, comment="协议附件")
|
||||
register_date = Column(CHAR(8), nullable=True, comment="登记日期YYYYMMDD")
|
||||
effective_time = Column(DateTime, nullable=True, comment="协议生效时间")
|
||||
effective_desc = Column(String(512), nullable=True, comment="协议生效描述")
|
||||
expire_time = Column(DateTime, nullable=True, comment="协议过期时间")
|
||||
expire_desc = Column(String(512), nullable=True, comment="协议过期描述")
|
||||
void_time = Column(DateTime, nullable=True, comment="协议作废时间")
|
||||
void_desc = Column(String(512), nullable=True, comment="协议作废描述")
|
||||
to_research_time = Column(DateTime, nullable=True, comment="协议转科研时间")
|
||||
to_research_desc = Column(String(512), nullable=True, comment="协议转科研描述")
|
||||
collector_user_id = Column(String(64), nullable=True, comment="采集专员用户ID")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注信息")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProtocolAudit(Base):
|
||||
"""采集协议单-审核记录 biz_protocol_audit"""
|
||||
|
||||
__tablename__ = "biz_protocol_audit"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
|
||||
auditor_user_id = Column(String(64), nullable=True, comment="审核人")
|
||||
audit_remark = Column(String(1000), nullable=True, comment="审核备注")
|
||||
audit_status = Column(String(16), nullable=False, comment="审核状态 shtg/shth")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProtocolPayment(Base):
|
||||
"""采集协议单-缴费主表 biz_protocol_payment"""
|
||||
|
||||
__tablename__ = "biz_protocol_payment"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
|
||||
pay_mode = Column(String(8), nullable=False, comment="dj趸交 fq分期")
|
||||
contract_amount = Column(Numeric(18, 2), nullable=True, comment="合同金额")
|
||||
installment_count = Column(Integer, nullable=True, comment="分期期数")
|
||||
installment_interval_months = Column(Integer, nullable=True, comment="每期间隔(月)")
|
||||
lump_sum_deadline = Column(CHAR(8), nullable=True, comment="趸交截止时间YYYYMMDD")
|
||||
installment_start_date = Column(CHAR(8), nullable=True, comment="分期开始YYYYMMDD")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProtocolPaymentDetail(Base):
|
||||
"""采集协议单-缴费明细 biz_protocol_payment_detail"""
|
||||
|
||||
__tablename__ = "biz_protocol_payment_detail"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
payment_id = Column(String(64), nullable=False, comment="缴费主表ID")
|
||||
transfer_type = Column(String(16), nullable=True, comment="转账方式")
|
||||
due_amount = Column(Numeric(18, 2), nullable=True, comment="应缴费金额")
|
||||
transfer_amount = Column(Numeric(18, 2), nullable=True, comment="转账金额")
|
||||
transfer_time = Column(DateTime, nullable=True, comment="转账时间")
|
||||
transfer_attachments = Column(String(4000), nullable=True, comment="转账附件")
|
||||
finance_confirm_attachments = Column(String(4000), nullable=True, comment="财务确认附件")
|
||||
pay_status = Column(String(32), nullable=False, default="dfj", comment="缴费状态")
|
||||
installment_no = Column(Integer, nullable=True, comment="分期期序号")
|
||||
installment_due_date = Column(CHAR(8), nullable=True, comment="分期应缴日期YYYYMMDD")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProtocolPartyCustomer(Base):
|
||||
"""采集协议单-当事人客户信息 biz_protocol_party_customer"""
|
||||
|
||||
__tablename__ = "biz_protocol_party_customer"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
|
||||
customer_id = Column(String(64), nullable=True, comment="客户ID")
|
||||
mother_name = Column(String(100), nullable=True, comment="母亲姓名")
|
||||
mother_phone = Column(String(32), nullable=True, comment="母亲电话")
|
||||
mother_id_card = Column(String(32), nullable=True, comment="母亲身份证号")
|
||||
mother_birth_ym = Column(CHAR(6), nullable=True, comment="母亲出生年月YYYYMM")
|
||||
mother_ethnic = Column(String(32), nullable=True, comment="母亲民族")
|
||||
mother_address = Column(String(512), nullable=True, comment="母亲地址")
|
||||
father_name = Column(String(100), nullable=True, comment="父亲姓名")
|
||||
father_phone = Column(String(32), nullable=True, comment="父亲电话")
|
||||
father_id_card = Column(String(32), nullable=True, comment="父亲身份证号")
|
||||
newborn_name = Column(String(100), nullable=True, comment="新生儿姓名")
|
||||
newborn_id_card = Column(String(32), nullable=True, comment="新生儿身份证号")
|
||||
newborn_delivery_time = Column(DateTime, nullable=True, comment="新生儿分娩时间")
|
||||
newborn_sex = Column(CHAR(1), nullable=True, comment="新生儿性别")
|
||||
delivery_mode = Column(String(16), nullable=True, comment="分娩方式")
|
||||
birth_order = Column(Integer, nullable=True, comment="出生顺序")
|
||||
newborn_weight_gram = Column(Integer, nullable=True, comment="新生儿体重(克)")
|
||||
apgar_1min = Column(Numeric(4, 1), nullable=True, comment="Apgar 1分钟评分")
|
||||
apgar_5min = Column(Numeric(4, 1), nullable=True, comment="Apgar 5分钟评分")
|
||||
collector_user_id = Column(String(64), nullable=True, comment="采集专员用户ID")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProtocolCollectionInfo(Base):
|
||||
"""采集协议单-采集信息 biz_protocol_collection_info"""
|
||||
|
||||
__tablename__ = "biz_protocol_collection_info"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
|
||||
region_id = Column(String(64), nullable=True, comment="区域ID")
|
||||
hospital_id = Column(String(64), nullable=True, comment="医院ID")
|
||||
medical_staff = Column(String(200), nullable=True, comment="医护人员")
|
||||
sales_user_id = Column(String(64), nullable=True, comment="销售人员用户ID")
|
||||
sample_collect_time = Column(DateTime, nullable=True, comment="样本采集时间")
|
||||
maternal_blood_collect_time = Column(DateTime, nullable=True, comment="母血采集时间")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProtocolSample(Base):
|
||||
"""采集协议单-样本信息 biz_protocol_sample"""
|
||||
|
||||
__tablename__ = "biz_protocol_sample"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
|
||||
sample_type_id = Column(String(64), nullable=True, comment="样本类型ID")
|
||||
sample_code_id = Column(String(64), nullable=True, comment="样本编码ID")
|
||||
sample_code = Column(String(32), nullable=True, comment="样本编码")
|
||||
collect_amount = Column(Numeric(18, 4), nullable=True, comment="采集量")
|
||||
unit_id = Column(String(64), nullable=True, comment="单位ID")
|
||||
price = Column(Numeric(18, 2), nullable=True, comment="价格")
|
||||
storage_years = Column(Integer, nullable=True, comment="存储年限")
|
||||
sample_status = Column(String(16), nullable=True, comment="样本状态")
|
||||
storage_in_date = Column(CHAR(8), nullable=True, comment="入库日期YYYYMMDD")
|
||||
detect_start_date = Column(CHAR(8), nullable=True, comment="检测开始日期YYYYMMDD")
|
||||
void_date = Column(CHAR(8), nullable=True, comment="作废日期YYYYMMDD")
|
||||
storage_expire_date = Column(CHAR(8), nullable=True, comment="存储到期日期YYYYMMDD")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProtocolSampleTransport(Base):
|
||||
"""采集协议单-样本运输 biz_protocol_sample_transport"""
|
||||
|
||||
__tablename__ = "biz_protocol_sample_transport"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
|
||||
sample_id = Column(String(64), nullable=False, comment="样本ID")
|
||||
transport_type = Column(String(16), nullable=False, comment="kdys快递运输 ryxd人员携带")
|
||||
express_company_id = Column(String(64), nullable=True, comment="快递公司ID")
|
||||
waybill_no = Column(String(128), nullable=True, comment="快递单号")
|
||||
dest_site_id = Column(String(64), nullable=True, comment="目的站点ID")
|
||||
express_fee = Column(Numeric(18, 2), nullable=True, comment="快递费用(元)")
|
||||
eta_time = Column(DateTime, nullable=True, comment="预计到达时间")
|
||||
carrier_name = Column(String(100), nullable=True, comment="携带人员姓名")
|
||||
carrier_phone = Column(String(32), nullable=True, comment="携带人员联系电话")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizAttachment(Base):
|
||||
"""业务附件 biz_attachment"""
|
||||
|
||||
__tablename__ = "biz_attachment"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
business_type = Column(String(50), nullable=True, comment="业务类型")
|
||||
business_id = Column(String(64), nullable=True, comment="业务主键ID")
|
||||
original_name = Column(String(200), nullable=False, comment="原始文件名")
|
||||
file_ext = Column(String(20), nullable=True, comment="文件后缀名")
|
||||
file_url = Column(String(500), nullable=False, comment="MinIO路径后缀")
|
||||
file_size = Column(Integer, nullable=True, comment="文件大小(字节)")
|
||||
file_type = Column(String(50), nullable=True, comment="MIME类型")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProtocolStorageCert(Base):
|
||||
"""采集协议单-储存证书 biz_protocol_storage_cert"""
|
||||
|
||||
__tablename__ = "biz_protocol_storage_cert"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
|
||||
make_status = Column(CHAR(1), nullable=True, default="0", comment="制作状态 0-未制作 1-已制作")
|
||||
cert_image_urls = Column(String(4000), nullable=True, comment="证书图片URLs")
|
||||
make_date = Column(CHAR(8), nullable=True, comment="制作日期YYYYMMDD")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# V0.4 运营支撑(2.4 洁净室管理 / 库位管理 / 展馆预约)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BizCleanRoom(Base):
|
||||
"""洁净室 biz_clean_room"""
|
||||
|
||||
__tablename__ = "biz_clean_room"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
room_code = Column(String(64), nullable=False, comment="编号")
|
||||
room_name = Column(String(200), nullable=False, comment="名称")
|
||||
room_grade = Column(String(8), nullable=True, comment="等级 A/B/C/D")
|
||||
room_status = Column(String(16), nullable=False, default="zc", comment="zc正常 yc异常")
|
||||
area_sqm = Column(Numeric(12, 2), nullable=True, comment="面积m²")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCleanRoomAccessLog(Base):
|
||||
"""洁净室出入记录 biz_clean_room_access_log"""
|
||||
|
||||
__tablename__ = "biz_clean_room_access_log"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
clean_room_id = Column(String(64), nullable=False, comment="洁净室ID")
|
||||
user_id = Column(String(64), nullable=True, comment="人员用户ID")
|
||||
person_name = Column(String(100), nullable=True, comment="人员姓名(冗余)")
|
||||
dept_name = Column(String(200), nullable=True, comment="部门")
|
||||
enter_time = Column(DateTime, nullable=False, comment="进入时间")
|
||||
purpose = Column(String(512), nullable=True, comment="目的")
|
||||
leave_time = Column(DateTime, nullable=True, comment="离开时间")
|
||||
body_temp = Column(String(32), nullable=True, comment="体温")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizStorageFacility(Base):
|
||||
"""存储设施树 biz_storage_facility"""
|
||||
|
||||
__tablename__ = "biz_storage_facility"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
parent_id = Column(String(64), nullable=False, default="0", comment="父节点ID,0根")
|
||||
node_name = Column(String(200), nullable=False, comment="节点名称(罐/层/盒等)")
|
||||
node_type = Column(String(32), nullable=True, comment="facility/layer/box 等扩展")
|
||||
is_leaf = Column(SmallInteger, nullable=False, default=0, comment="是否叶子存储位 1是")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizStorageFacilitySlot(Base):
|
||||
"""存储设施小格 biz_storage_facility_slot"""
|
||||
|
||||
__tablename__ = "biz_storage_facility_slot"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
facility_node_id = Column(String(64), nullable=False, comment="设施树叶子节点ID")
|
||||
slot_code = Column(String(64), nullable=True, comment="小格编码/坐标")
|
||||
slot_status = Column(String(16), nullable=True, comment="占用状态等扩展")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizExhibitionAppointment(Base):
|
||||
"""展馆预约 biz_exhibition_appointment"""
|
||||
|
||||
__tablename__ = "biz_exhibition_appointment"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
appointment_no = Column(String(64), nullable=False, comment="预约编号")
|
||||
visit_org = Column(String(200), nullable=True, comment="来访单位")
|
||||
contact_name = Column(String(100), nullable=True, comment="联系人")
|
||||
visit_date = Column(DateTime, nullable=True, comment="到访日期")
|
||||
visitor_count = Column(Integer, nullable=True, comment="人数")
|
||||
reception_user_id = Column(String(64), nullable=True, comment="接待人员用户ID")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
@@ -0,0 +1,13 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AttachmentUploadResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
fileName: str
|
||||
fileUrl: str
|
||||
fileSize: Optional[int] = None
|
||||
fileSizeDisplay: Optional[str] = None
|
||||
fileType: Optional[str] = None
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Pydantic schemas - 请假申请"""
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LeaveCreate(BaseModel):
|
||||
leaveType: str = Field(..., description="请假类型: personal/sick/annual/other")
|
||||
startDate: str = Field(..., description="开始日期")
|
||||
endDate: str = Field(..., description="结束日期")
|
||||
days: int = Field(..., description="请假天数")
|
||||
reason: Optional[str] = Field(None, description="请假原因")
|
||||
remark: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class LeaveUpdate(BaseModel):
|
||||
leaveType: Optional[str] = Field(None, description="请假类型")
|
||||
startDate: Optional[str] = Field(None, description="开始日期")
|
||||
endDate: Optional[str] = Field(None, description="结束日期")
|
||||
days: Optional[int] = Field(None, description="请假天数")
|
||||
reason: Optional[str] = Field(None, description="请假原因")
|
||||
remark: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class LeaveResponse(BaseModel):
|
||||
id: str
|
||||
leaveType: Optional[str] = None
|
||||
startDate: Optional[str] = None
|
||||
endDate: Optional[str] = None
|
||||
days: Optional[int] = None
|
||||
reason: Optional[str] = None
|
||||
wfInstanceId: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
createBy: Optional[str] = None
|
||||
createTime: Optional[str] = None
|
||||
updateBy: Optional[str] = None
|
||||
updateTime: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Pydantic schemas - 工作流"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProcessDefCreate(BaseModel):
|
||||
processCode: str = Field(..., description="流程编码")
|
||||
processName: str = Field(..., description="流程名称")
|
||||
status: Optional[str] = Field("1", description="1启用 0停用")
|
||||
orderNum: Optional[int] = Field(None, description="排序号")
|
||||
remark: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class ProcessDefUpdate(BaseModel):
|
||||
processCode: Optional[str] = Field(None, description="流程编码")
|
||||
processName: Optional[str] = Field(None, description="流程名称")
|
||||
status: Optional[str] = Field(None, description="1启用 0停用")
|
||||
orderNum: Optional[int] = Field(None, description="排序号")
|
||||
remark: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class ProcessDefResponse(BaseModel):
|
||||
id: str
|
||||
processCode: str
|
||||
processName: str
|
||||
status: str
|
||||
latestVersion: Optional[int] = None
|
||||
tenantNo: Optional[str] = None
|
||||
orderNum: Optional[int] = None
|
||||
remark: Optional[str] = None
|
||||
createTime: Optional[str] = None
|
||||
updateTime: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NodeData(BaseModel):
|
||||
nodeKey: str = Field(..., description="节点唯一键")
|
||||
nodeType: str = Field(..., description="start/end/approval/condition 等")
|
||||
nodeName: Optional[str] = Field(None, description="节点名称")
|
||||
configJson: Optional[str] = Field(None, description="审批人/SQL/会签策略等配置")
|
||||
|
||||
|
||||
class TransitionData(BaseModel):
|
||||
fromNodeKey: str = Field(..., description="起始节点key")
|
||||
toNodeKey: str = Field(..., description="目标节点key")
|
||||
conditionExpr: Optional[str] = Field(None, description="条件表达式或分支说明")
|
||||
|
||||
|
||||
class PublishVersionRequest(BaseModel):
|
||||
graphJson: str = Field(..., description="节点与连线等配置JSON")
|
||||
nodes: List[NodeData] = Field(..., description="节点列表")
|
||||
transitions: List[TransitionData] = Field(default_factory=list, description="连线列表")
|
||||
remark: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
id: str
|
||||
versionNo: int
|
||||
isLatest: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
createTime: Optional[str] = None
|
||||
updateTime: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class StartInstanceRequest(BaseModel):
|
||||
processCode: str = Field(..., description="流程编码")
|
||||
businessType: Optional[str] = Field(None, description="业务类型")
|
||||
businessId: Optional[str] = Field(None, description="业务主键")
|
||||
variables: Optional[Dict[str, Any]] = Field(default_factory=dict, description="流程变量")
|
||||
callbackUrl: Optional[str] = Field(None, description="业务回调地址")
|
||||
|
||||
|
||||
class ApproveRequest(BaseModel):
|
||||
commentText: Optional[str] = Field(None, description="审批意见")
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
id: str
|
||||
instanceId: str
|
||||
nodeKey: Optional[str] = None
|
||||
assigneeUserId: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
commentText: Optional[str] = None
|
||||
createTime: Optional[str] = None
|
||||
updateTime: Optional[str] = None
|
||||
processName: Optional[str] = None
|
||||
businessType: Optional[str] = None
|
||||
businessId: Optional[str] = None
|
||||
starterUserId: Optional[str] = None
|
||||
instanceStatus: Optional[str] = None
|
||||
instanceCreateTime: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class InstanceResponse(BaseModel):
|
||||
id: str
|
||||
processVersionId: str
|
||||
businessType: Optional[str] = None
|
||||
businessId: Optional[str] = None
|
||||
starterUserId: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
createTime: Optional[str] = None
|
||||
updateTime: Optional[str] = None
|
||||
tasks: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -38,7 +38,7 @@ def resolve_web_role_for_user(db: Session, user_id: str) -> str:
|
||||
SysRole.status == "1",
|
||||
SysRole.tenant_no == tenant,
|
||||
)
|
||||
.order_by(SysRole.order_num.asc())
|
||||
.order_by(SysRole.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
names = [str(r[0]).strip() for r in rows if r and r[0]]
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""工作流回调服务 —— 流程状态变更时通知业务系统"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.models import WfProcessInstance
|
||||
|
||||
logger = logging.getLogger("sqxp.workflow_callback")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 本地回调注册表:内部路径(以 / 开头)直接在进程内调用,不走 HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LocalCallbackFn = Callable[[Any, str, str], None] # (db, business_id, event) -> None
|
||||
|
||||
_LOCAL_CALLBACKS: Dict[str, _LocalCallbackFn] = {}
|
||||
|
||||
|
||||
def register_local_callback(path: str, fn: _LocalCallbackFn) -> None:
|
||||
"""注册本地回调函数。当 callback_url 以 path 开头时直接调用 fn。"""
|
||||
_LOCAL_CALLBACKS[path] = fn
|
||||
|
||||
|
||||
def _dispatch_local(db: Any, instance: WfProcessInstance, event: str) -> bool:
|
||||
"""尝试本地直调。返回 True 表示已处理,False 表示无匹配的本地回调。"""
|
||||
url = instance.callback_url or ""
|
||||
if not url.startswith("/"):
|
||||
return False
|
||||
for prefix, fn in _LOCAL_CALLBACKS.items():
|
||||
if url.startswith(prefix):
|
||||
try:
|
||||
fn(db, instance.business_id or "", event)
|
||||
logger.info(
|
||||
"工作流本地回调成功: event=%s instance=%s path=%s",
|
||||
event,
|
||||
instance.id,
|
||||
url,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"工作流本地回调失败: event=%s instance=%s path=%s",
|
||||
event,
|
||||
instance.id,
|
||||
url,
|
||||
)
|
||||
return True # 已匹配但执行失败,不再走 HTTP
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trigger_callback(对外入口)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def trigger_callback(db: Any, instance: WfProcessInstance, event: str) -> None:
|
||||
"""流程实例状态变更后,向业务系统发送回调通知。
|
||||
|
||||
优先级:
|
||||
1. 如果 callback_url 是内部路径(以 / 开头),优先尝试本地直调
|
||||
2. 本地无匹配时,回退到 HTTP 回调(需完整 URL)
|
||||
|
||||
Args:
|
||||
db: 数据库会话。
|
||||
instance: 流程实例 ORM 对象。
|
||||
event: 事件类型,如 approved / rejected / completed。
|
||||
"""
|
||||
if not instance.callback_url:
|
||||
return
|
||||
|
||||
# 尝试本地直调
|
||||
if _dispatch_local(db, instance, event):
|
||||
return
|
||||
|
||||
# 回退到 HTTP 回调
|
||||
payload: Dict[str, Any] = {
|
||||
"event": event,
|
||||
"instanceId": instance.id,
|
||||
"businessType": instance.business_type,
|
||||
"businessId": instance.business_id,
|
||||
"status": instance.status,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(
|
||||
instance.callback_url,
|
||||
json=payload,
|
||||
timeout=10,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
logger.info(
|
||||
"工作流HTTP回调成功: event=%s instance=%s status=%d",
|
||||
event,
|
||||
instance.id,
|
||||
resp.status_code,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"工作流HTTP回调失败: event=%s instance=%s url=%s",
|
||||
event,
|
||||
instance.id,
|
||||
instance.callback_url,
|
||||
)
|
||||
@@ -0,0 +1,389 @@
|
||||
"""工作流引擎 —— 读取节点与连线执行审批流转"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud import workflow as wf_crud
|
||||
from app.models.models import (
|
||||
SysUser,
|
||||
SysUserRole,
|
||||
WfNode,
|
||||
WfProcessInstance,
|
||||
WfTask,
|
||||
WfTransition,
|
||||
)
|
||||
from app.services.workflow_callback import trigger_callback
|
||||
|
||||
logger = logging.getLogger("sqxp.workflow_engine")
|
||||
|
||||
_FORBIDDEN_SQL_KEYWORDS = re.compile(
|
||||
r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE)\b", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
class WorkflowEngine:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# start_process
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start_process(
|
||||
self,
|
||||
*,
|
||||
process_def_id: str,
|
||||
business_type: Optional[str] = None,
|
||||
business_id: Optional[str] = None,
|
||||
starter_user_id: Optional[str] = None,
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
callback_url: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> WfProcessInstance:
|
||||
version = wf_crud.get_latest_version(self.db, process_def_id)
|
||||
if not version:
|
||||
raise ValueError("流程定义没有可用版本,请先发布")
|
||||
instance = wf_crud.create_instance(
|
||||
self.db,
|
||||
process_version_id=version.id,
|
||||
business_type=business_type,
|
||||
business_id=business_id,
|
||||
starter_user_id=starter_user_id,
|
||||
variables=variables,
|
||||
callback_url=callback_url,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
nodes = wf_crud.get_nodes_by_version(self.db, version.id)
|
||||
start_node = next((n for n in nodes if n.node_type == "start"), None)
|
||||
if not start_node:
|
||||
raise ValueError("流程缺少开始节点")
|
||||
self._advance(instance.id, start_node.node_key, tenant_no or "zkxw")
|
||||
self.db.refresh(instance)
|
||||
return instance
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# approve_task
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def approve_task(self, task_id: str, comment: str, tenant_no: str) -> WfProcessInstance:
|
||||
task = wf_crud.get_task(self.db, task_id)
|
||||
if not task:
|
||||
raise ValueError("任务不存在")
|
||||
if task.status != "pending":
|
||||
raise ValueError(f"任务状态为 {task.status},无法审批")
|
||||
|
||||
task.status = "approved"
|
||||
task.comment_text = comment
|
||||
task.update_time = datetime.now()
|
||||
self.db.add(task)
|
||||
self.db.commit()
|
||||
|
||||
instance = wf_crud.get_instance(self.db, task.instance_id)
|
||||
if not instance:
|
||||
raise ValueError("流程实例不存在")
|
||||
|
||||
version_id = instance.process_version_id
|
||||
nodes = wf_crud.get_nodes_by_version(self.db, version_id)
|
||||
current_node = next(
|
||||
(n for n in nodes if n.node_key == task.node_key), None
|
||||
)
|
||||
config = self._parse_node_config(current_node)
|
||||
pass_type = config.get("passType", "any")
|
||||
|
||||
if pass_type == "any":
|
||||
others = wf_crud.get_pending_tasks_by_node(
|
||||
self.db, task.instance_id, task.node_key
|
||||
)
|
||||
for t in others:
|
||||
t.status = "withdrawn"
|
||||
t.update_time = datetime.now()
|
||||
self.db.add(t)
|
||||
self.db.commit()
|
||||
self._advance(instance.id, task.node_key, tenant_no)
|
||||
else:
|
||||
remaining = wf_crud.get_pending_tasks_by_node(
|
||||
self.db, task.instance_id, task.node_key
|
||||
)
|
||||
if not remaining:
|
||||
self._advance(instance.id, task.node_key, tenant_no)
|
||||
|
||||
self.db.refresh(instance)
|
||||
return instance
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# reject_task
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def reject_task(self, task_id: str, comment: str, tenant_no: str) -> WfProcessInstance:
|
||||
task = wf_crud.get_task(self.db, task_id)
|
||||
if not task:
|
||||
raise ValueError("任务不存在")
|
||||
if task.status != "pending":
|
||||
raise ValueError(f"任务状态为 {task.status},无法驳回")
|
||||
|
||||
task.status = "rejected"
|
||||
task.comment_text = comment
|
||||
task.update_time = datetime.now()
|
||||
self.db.add(task)
|
||||
self.db.commit()
|
||||
|
||||
others = wf_crud.get_pending_tasks_by_node(
|
||||
self.db, task.instance_id, task.node_key
|
||||
)
|
||||
for t in others:
|
||||
t.status = "withdrawn"
|
||||
t.update_time = datetime.now()
|
||||
self.db.add(t)
|
||||
self.db.commit()
|
||||
|
||||
instance = wf_crud.get_instance(self.db, task.instance_id)
|
||||
if instance:
|
||||
wf_crud.update_instance_status(self.db, instance.id, "rejected")
|
||||
self.db.refresh(instance)
|
||||
trigger_callback(self.db, instance, "rejected")
|
||||
|
||||
return instance
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _advance (internal)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _advance(
|
||||
self, instance_id: str, from_node_key: str, tenant_no: str
|
||||
) -> None:
|
||||
instance = wf_crud.get_instance(self.db, instance_id)
|
||||
if not instance:
|
||||
return
|
||||
if instance.status != "running":
|
||||
return
|
||||
|
||||
version_id = instance.process_version_id
|
||||
nodes = wf_crud.get_nodes_by_version(self.db, version_id)
|
||||
transitions = wf_crud.get_transitions_by_version(self.db, version_id)
|
||||
variables = self._parse_variables(instance)
|
||||
|
||||
current_node = next(
|
||||
(n for n in nodes if n.node_key == from_node_key), None
|
||||
)
|
||||
if not current_node:
|
||||
return
|
||||
|
||||
out_edges = [
|
||||
t for t in transitions if t.from_node_key == from_node_key
|
||||
]
|
||||
if not out_edges:
|
||||
return
|
||||
|
||||
if current_node.node_type in ("start", "approval"):
|
||||
target_key = out_edges[0].to_node_key
|
||||
elif current_node.node_type == "condition":
|
||||
target_key = self._evaluate_condition(out_edges, variables)
|
||||
else:
|
||||
target_key = out_edges[0].to_node_key
|
||||
|
||||
next_node = next((n for n in nodes if n.node_key == target_key), None)
|
||||
if next_node:
|
||||
self._execute_node(instance_id, next_node, variables, tenant_no)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _execute_node
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _execute_node(
|
||||
self,
|
||||
instance_id: str,
|
||||
node: WfNode,
|
||||
variables: Dict[str, Any],
|
||||
tenant_no: str,
|
||||
) -> None:
|
||||
if node.node_type == "end":
|
||||
wf_crud.update_instance_status(self.db, instance_id, "completed")
|
||||
instance = wf_crud.get_instance(self.db, instance_id)
|
||||
if instance:
|
||||
trigger_callback(self.db, instance, "completed")
|
||||
return
|
||||
|
||||
if node.node_type == "approval":
|
||||
config = self._parse_node_config(node)
|
||||
user_ids = self._resolve_approvers(config, variables, tenant_no)
|
||||
for uid in user_ids:
|
||||
wf_crud.create_task(
|
||||
self.db,
|
||||
instance_id=instance_id,
|
||||
node_key=node.node_key,
|
||||
assignee_user_id=uid,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return
|
||||
|
||||
if node.node_type == "condition":
|
||||
self._advance(instance_id, node.node_key, tenant_no)
|
||||
return
|
||||
|
||||
self._advance(instance_id, node.node_key, tenant_no)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _resolve_approvers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_approvers(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
variables: Dict[str, Any],
|
||||
tenant_no: str,
|
||||
) -> List[str]:
|
||||
strategy = config.get("strategy", "users")
|
||||
|
||||
if strategy == "users":
|
||||
return config.get("userIds", [])
|
||||
|
||||
if strategy == "roles":
|
||||
role_ids = config.get("roleIds", [])
|
||||
if not role_ids:
|
||||
return []
|
||||
rows = (
|
||||
self.db.query(SysUser.id)
|
||||
.join(SysUserRole, SysUserRole.user_id == SysUser.id)
|
||||
.filter(
|
||||
SysUserRole.role_id.in_(role_ids),
|
||||
SysUser.del_flag == 0,
|
||||
SysUser.status == "1",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [r.id for r in rows]
|
||||
|
||||
if strategy == "sql":
|
||||
sql_template = config.get("sql", "")
|
||||
if not sql_template or not sql_template.strip().upper().startswith("SELECT"):
|
||||
raise ValueError("SQL 审批人策略必须以 SELECT 开头")
|
||||
if _FORBIDDEN_SQL_KEYWORDS.search(sql_template):
|
||||
raise ValueError("SQL 审批人策略包含禁止的关键字")
|
||||
try:
|
||||
result = self.db.execute(text(sql_template), variables or {})
|
||||
return [str(row[0]) for row in result.fetchall()]
|
||||
except Exception as exc:
|
||||
logger.exception("SQL 审批人解析失败: %s", exc)
|
||||
raise ValueError(f"SQL 审批人解析失败: {exc}") from exc
|
||||
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _evaluate_condition
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _evaluate_condition(
|
||||
self, out_edges: List[WfTransition], variables: Dict[str, Any]
|
||||
) -> str:
|
||||
unconditional_fallback = out_edges[-1].to_node_key if out_edges else ""
|
||||
|
||||
for edge in out_edges:
|
||||
cond_str = edge.condition_expr
|
||||
if not cond_str:
|
||||
continue
|
||||
try:
|
||||
rules = json.loads(cond_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if self._check_rules(rules, variables):
|
||||
return edge.to_node_key
|
||||
|
||||
return unconditional_fallback
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _check_rules (recursive)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_rules(self, rules: Any, variables: Dict[str, Any]) -> bool:
|
||||
if isinstance(rules, list):
|
||||
return any(self._check_rules(r, variables) for r in rules)
|
||||
|
||||
if isinstance(rules, dict):
|
||||
if "and" in rules:
|
||||
return all(self._check_rules(r, variables) for r in rules["and"])
|
||||
if "or" in rules:
|
||||
return any(self._check_rules(r, variables) for r in rules["or"])
|
||||
if "rules" in rules:
|
||||
return self._check_rules(rules["rules"], variables)
|
||||
if "field" in rules:
|
||||
actual = self._get_variable(rules["field"], variables)
|
||||
op = rules.get("op", "==")
|
||||
expected = rules.get("value")
|
||||
return self._compare(actual, op, expected)
|
||||
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _compare
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _compare(actual: Any, op: str, expected: Any) -> bool:
|
||||
try:
|
||||
if op == "==":
|
||||
return str(actual) == str(expected)
|
||||
if op == "!=":
|
||||
return str(actual) != str(expected)
|
||||
if op == ">":
|
||||
return float(actual) > float(expected)
|
||||
if op == "<":
|
||||
return float(actual) < float(expected)
|
||||
if op == ">=":
|
||||
return float(actual) >= float(expected)
|
||||
if op == "<=":
|
||||
return float(actual) <= float(expected)
|
||||
if op == "in":
|
||||
return str(actual) in ([str(e) for e in expected] if isinstance(expected, list) else str(expected))
|
||||
if op == "not_in":
|
||||
return str(actual) not in ([str(e) for e in expected] if isinstance(expected, list) else str(expected))
|
||||
if op == "contains":
|
||||
return str(expected) in str(actual)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _get_variable (dot-notation)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _get_variable(field: str, variables: Dict[str, Any]) -> Any:
|
||||
parts = field.split(".")
|
||||
current: Any = variables
|
||||
for part in parts:
|
||||
if isinstance(current, dict):
|
||||
current = current.get(part)
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_node_config(node: Optional[WfNode]) -> Dict[str, Any]:
|
||||
if not node or not node.config_json:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(node.config_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _parse_variables(instance: WfProcessInstance) -> Dict[str, Any]:
|
||||
if not instance.variables_json:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(instance.variables_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
@@ -15,3 +15,4 @@ python-dotenv==1.0.0
|
||||
apscheduler==3.10.4
|
||||
minio==7.2.20
|
||||
Pillow>=10.0.0
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Tests for tenant ownership validation in basic-info CRUD operations.
|
||||
|
||||
Per AGENTS.md: "All business queries SHALL filter by current user's tenant_no."
|
||||
Per audit: customer, cell_sample_type, collection_protocol_no, sample_code
|
||||
CRUD get_*() functions do NOT filter by tenant_no, allowing cross-tenant
|
||||
update/delete if the record ID is known.
|
||||
|
||||
These tests verify that:
|
||||
1. get_*() functions accept and filter by tenant_no
|
||||
2. update_*() functions reject cross-tenant modifications
|
||||
3. soft_delete_*() functions reject cross-tenant deletions
|
||||
|
||||
The collection_region module already correctly implements tenant_no filtering
|
||||
and serves as the reference pattern.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# We test the CRUD function signatures and behavior without a real database
|
||||
# by patching the SQLAlchemy session and model queries.
|
||||
|
||||
|
||||
class TestCustomerTenantOwnership(unittest.TestCase):
|
||||
"""Customer CRUD should enforce tenant_no on update/delete."""
|
||||
|
||||
def test_get_customer_signature_accepts_tenant_no(self):
|
||||
"""get_customer() should accept an optional tenant_no parameter."""
|
||||
from app.crud.customer import get_customer
|
||||
|
||||
import inspect
|
||||
sig = inspect.signature(get_customer)
|
||||
params = list(sig.parameters.keys())
|
||||
# Currently: ['db', 'customer_id'] — should add 'tenant_no'
|
||||
self.assertIn("tenant_no", params, "get_customer() must accept tenant_no parameter")
|
||||
|
||||
def test_get_customer_filters_by_tenant_no(self):
|
||||
"""get_customer() should filter query by tenant_no when provided."""
|
||||
from app.crud.customer import get_customer
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
# Should not raise, and should add tenant_no filter
|
||||
result = get_customer(mock_db, "some-id", tenant_no="tenant-A")
|
||||
mock_db.query.assert_called_once()
|
||||
# filter() should be called at least twice (del_flag + tenant_no)
|
||||
self.assertGreaterEqual(mock_query.filter.call_count, 2)
|
||||
|
||||
def test_update_customer_rejects_cross_tenant(self):
|
||||
"""update_customer() should raise when record belongs to different tenant."""
|
||||
from app.crud.customer import update_customer
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None # tenant_no filter blocks access
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
update_customer(
|
||||
mock_db,
|
||||
"cross-tenant-id",
|
||||
customer_name="Hacked",
|
||||
user_id="user-A",
|
||||
tenant_no="tenant-A", # record belongs to tenant-B
|
||||
)
|
||||
self.assertIn("不存在", str(ctx.exception))
|
||||
|
||||
def test_soft_delete_customer_rejects_cross_tenant(self):
|
||||
"""soft_delete_customer() should raise when record belongs to different tenant."""
|
||||
from app.crud.customer import soft_delete_customer
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None # tenant_no filter blocks access
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
soft_delete_customer(mock_db, "cross-tenant-id", tenant_no="tenant-A")
|
||||
|
||||
|
||||
class TestCellSampleTypeTenantOwnership(unittest.TestCase):
|
||||
"""CellSampleType CRUD should enforce tenant_no on update/delete."""
|
||||
|
||||
def test_get_cell_sample_type_signature_accepts_tenant_no(self):
|
||||
"""get_cell_sample_type() should accept an optional tenant_no parameter."""
|
||||
from app.crud.cell_sample_type import get_cell_sample_type
|
||||
|
||||
import inspect
|
||||
sig = inspect.signature(get_cell_sample_type)
|
||||
params = list(sig.parameters.keys())
|
||||
self.assertIn("tenant_no", params, "get_cell_sample_type() must accept tenant_no parameter")
|
||||
|
||||
def test_update_cell_sample_type_rejects_cross_tenant(self):
|
||||
"""update_cell_sample_type() should raise when record belongs to different tenant."""
|
||||
from app.crud.cell_sample_type import update_cell_sample_type
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
update_cell_sample_type(
|
||||
mock_db,
|
||||
"cross-tenant-id",
|
||||
type_name="Hacked",
|
||||
user_id="user-A",
|
||||
tenant_no="tenant-A",
|
||||
)
|
||||
self.assertIn("不存在", str(ctx.exception))
|
||||
|
||||
def test_soft_delete_cell_sample_type_rejects_cross_tenant(self):
|
||||
"""soft_delete_cell_sample_type() should raise when record belongs to different tenant."""
|
||||
from app.crud.cell_sample_type import soft_delete_cell_sample_type
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
soft_delete_cell_sample_type(mock_db, "cross-tenant-id", tenant_no="tenant-A")
|
||||
|
||||
|
||||
class TestProtocolNoTenantOwnership(unittest.TestCase):
|
||||
"""CollectionProtocolNo CRUD should enforce tenant_no on update/delete."""
|
||||
|
||||
def test_get_protocol_no_signature_accepts_tenant_no(self):
|
||||
"""get_protocol_no() should accept an optional tenant_no parameter."""
|
||||
from app.crud.collection_protocol_no import get_protocol_no
|
||||
|
||||
import inspect
|
||||
sig = inspect.signature(get_protocol_no)
|
||||
params = list(sig.parameters.keys())
|
||||
self.assertIn("tenant_no", params, "get_protocol_no() must accept tenant_no parameter")
|
||||
|
||||
def test_update_protocol_no_rejects_cross_tenant(self):
|
||||
"""update_protocol_no() should raise when record belongs to different tenant."""
|
||||
from app.crud.collection_protocol_no import update_protocol_no
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
update_protocol_no(
|
||||
mock_db,
|
||||
"cross-tenant-id",
|
||||
remark="Hacked",
|
||||
user_id="user-A",
|
||||
tenant_no="tenant-A",
|
||||
)
|
||||
self.assertIn("不存在", str(ctx.exception))
|
||||
|
||||
def test_soft_delete_protocol_no_rejects_cross_tenant(self):
|
||||
"""soft_delete_protocol_no() should raise when record belongs to different tenant."""
|
||||
from app.crud.collection_protocol_no import soft_delete_protocol_no
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
soft_delete_protocol_no(mock_db, "cross-tenant-id", tenant_no="tenant-A")
|
||||
|
||||
|
||||
class TestSampleCodeTenantOwnership(unittest.TestCase):
|
||||
"""SampleCode CRUD should enforce tenant_no on update/delete."""
|
||||
|
||||
def test_get_sample_code_signature_accepts_tenant_no(self):
|
||||
"""get_sample_code() should accept an optional tenant_no parameter."""
|
||||
from app.crud.sample_code import get_sample_code
|
||||
|
||||
import inspect
|
||||
sig = inspect.signature(get_sample_code)
|
||||
params = list(sig.parameters.keys())
|
||||
self.assertIn("tenant_no", params, "get_sample_code() must accept tenant_no parameter")
|
||||
|
||||
def test_update_sample_code_rejects_cross_tenant(self):
|
||||
"""update_sample_code() should raise when record belongs to different tenant."""
|
||||
from app.crud.sample_code import update_sample_code
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
update_sample_code(
|
||||
mock_db,
|
||||
"cross-tenant-id",
|
||||
remark="Hacked",
|
||||
user_id="user-A",
|
||||
tenant_no="tenant-A",
|
||||
)
|
||||
self.assertIn("不存在", str(ctx.exception))
|
||||
|
||||
def test_soft_delete_sample_code_rejects_cross_tenant(self):
|
||||
"""soft_delete_sample_code() should raise when record belongs to different tenant."""
|
||||
from app.crud.sample_code import soft_delete_sample_code
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
soft_delete_sample_code(mock_db, "cross-tenant-id", tenant_no="tenant-A")
|
||||
|
||||
|
||||
class TestGetTenantNoFallback(unittest.TestCase):
|
||||
"""_get_tenant_no() should not silently fall back to hardcoded value."""
|
||||
|
||||
def test_get_tenant_no_raises_on_missing_user(self):
|
||||
"""When user_id is missing from token, should raise rather than default."""
|
||||
from app.api.basic_info import _get_tenant_no
|
||||
|
||||
mock_db = MagicMock()
|
||||
user = {} # no user_id
|
||||
with self.assertRaises((ValueError, PermissionError)):
|
||||
_get_tenant_no(mock_db, user)
|
||||
|
||||
def test_get_tenant_no_raises_on_missing_sys_user(self):
|
||||
"""When SysUser record not found in DB, should raise rather than default."""
|
||||
from app.api.basic_info import _get_tenant_no
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None # SysUser not found
|
||||
|
||||
user = {"user_id": "some-id"}
|
||||
with self.assertRaises((ValueError, PermissionError)):
|
||||
_get_tenant_no(mock_db, user)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Tests for collection_protocol API/CRUD convention compliance.
|
||||
|
||||
Per AGENTS.md engineering standards:
|
||||
1. _get_tenant_no() MUST NOT silently fall back to hardcoded "zkxw"
|
||||
— must raise PermissionError when user_id missing or SysUser not found.
|
||||
2. All list queries SHALL sort by order_num ASC, create_time DESC
|
||||
(unless a business-specific sort is justified in the design doc).
|
||||
3. list_audits, list_receipt_confirm_details, list_refund_confirm_details
|
||||
currently only sort by create_time DESC — missing order_num ASC prefix.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# H1: _get_tenant_no must raise PermissionError (not fall back to "zkxw")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCollectionProtocolGetTenantNo(unittest.TestCase):
|
||||
"""collection_protocol._get_tenant_no should match basic_info pattern."""
|
||||
|
||||
def test_raises_on_missing_user_id(self):
|
||||
"""When user dict has no user_id, must raise PermissionError."""
|
||||
from app.api.collection_protocol import _get_tenant_no
|
||||
|
||||
mock_db = MagicMock()
|
||||
user = {} # no user_id key
|
||||
with self.assertRaises(PermissionError):
|
||||
_get_tenant_no(mock_db, user)
|
||||
|
||||
def test_raises_on_none_user_id(self):
|
||||
"""When user_id is None, must raise PermissionError."""
|
||||
from app.api.collection_protocol import _get_tenant_no
|
||||
|
||||
mock_db = MagicMock()
|
||||
user = {"user_id": None}
|
||||
with self.assertRaises(PermissionError):
|
||||
_get_tenant_no(mock_db, user)
|
||||
|
||||
def test_raises_on_empty_user_id(self):
|
||||
"""When user_id is empty string, must raise PermissionError."""
|
||||
from app.api.collection_protocol import _get_tenant_no
|
||||
|
||||
mock_db = MagicMock()
|
||||
user = {"user_id": ""}
|
||||
with self.assertRaises(PermissionError):
|
||||
_get_tenant_no(mock_db, user)
|
||||
|
||||
def test_raises_on_missing_sys_user(self):
|
||||
"""When SysUser record not found in DB, must raise PermissionError."""
|
||||
from app.api.collection_protocol import _get_tenant_no
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None # SysUser not found
|
||||
|
||||
user = {"user_id": "some-id"}
|
||||
with self.assertRaises(PermissionError):
|
||||
_get_tenant_no(mock_db, user)
|
||||
|
||||
def test_raises_on_sys_user_no_tenant(self):
|
||||
"""When SysUser exists but tenant_no is None, must raise PermissionError."""
|
||||
from app.api.collection_protocol import _get_tenant_no
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user = MagicMock()
|
||||
mock_user.tenant_no = None
|
||||
mock_query.first.return_value = mock_user
|
||||
|
||||
user = {"user_id": "some-id"}
|
||||
with self.assertRaises(PermissionError):
|
||||
_get_tenant_no(mock_db, user)
|
||||
|
||||
def test_returns_tenant_no_on_valid_user(self):
|
||||
"""When SysUser has valid tenant_no, should return it."""
|
||||
from app.api.collection_protocol import _get_tenant_no
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user = MagicMock()
|
||||
mock_user.tenant_no = "tenant-ABC"
|
||||
mock_query.first.return_value = mock_user
|
||||
|
||||
result = _get_tenant_no(mock_db, {"user_id": "user-1"})
|
||||
self.assertEqual(result, "tenant-ABC")
|
||||
|
||||
def test_does_not_return_hardcoded_zkxw(self):
|
||||
"""Must never return the hardcoded fallback 'zkxw'."""
|
||||
from app.api.collection_protocol import _get_tenant_no
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = None
|
||||
|
||||
user = {"user_id": "missing-user"}
|
||||
with self.assertRaises(PermissionError) as ctx:
|
||||
_get_tenant_no(mock_db, user)
|
||||
# Ensure it raised, not returned "zkxw"
|
||||
self.assertNotIsInstance(ctx.exception, type(None))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M1: Sort order — list_audits should include order_num ASC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListAuditsSortOrder(unittest.TestCase):
|
||||
"""list_audits should sort by order_num ASC, create_time DESC."""
|
||||
|
||||
@patch("app.crud.collection_protocol.BizProtocolAudit")
|
||||
@patch("app.crud.collection_protocol.BizCollectionProtocol")
|
||||
def test_list_audits_includes_order_num_in_sort(self, MockProtocol, MockAudit):
|
||||
"""order_by should include order_num ASC before create_time DESC."""
|
||||
from app.crud.collection_protocol import list_audits
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_query.join.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.with_entities.return_value = mock_query
|
||||
mock_query.scalar.return_value = 0
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = []
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
list_audits(mock_db, tenant_no="t1")
|
||||
|
||||
# Check that order_by was called at least once for the data query
|
||||
# The sort call should include order_num ASC
|
||||
order_by_calls = mock_query.order_by.call_args_list
|
||||
self.assertGreaterEqual(len(order_by_calls), 1, "order_by should be called for data query")
|
||||
|
||||
# The data query's order_by should contain order_num.asc()
|
||||
# We check the last order_by call (the data query, not the count query)
|
||||
last_sort_call = order_by_calls[-1]
|
||||
sort_args = last_sort_call[0] # positional args
|
||||
# Should have at least 2 args: order_num ASC, create_time DESC
|
||||
self.assertGreaterEqual(
|
||||
len(sort_args), 2,
|
||||
f"order_by should have at least 2 args (order_num ASC, create_time DESC), got: {sort_args}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M1: Sort order — list_receipt_confirm_details should include order_num ASC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListReceiptConfirmSortOrder(unittest.TestCase):
|
||||
"""list_receipt_confirm_details should sort by order_num ASC, create_time DESC."""
|
||||
|
||||
@patch("app.crud.collection_protocol.BizProtocolPaymentDetail")
|
||||
@patch("app.crud.collection_protocol.BizProtocolPayment")
|
||||
@patch("app.crud.collection_protocol.BizCollectionProtocol")
|
||||
def test_receipt_confirm_includes_order_num_in_sort(self, MockProtocol, MockPayment, MockDetail):
|
||||
"""order_by should include order_num ASC before create_time DESC."""
|
||||
from app.crud.collection_protocol import list_receipt_confirm_details
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_query.join.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.with_entities.return_value = mock_query
|
||||
mock_query.scalar.return_value = 0
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = []
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
list_receipt_confirm_details(mock_db, tenant_no="t1")
|
||||
|
||||
order_by_calls = mock_query.order_by.call_args_list
|
||||
self.assertGreaterEqual(len(order_by_calls), 1)
|
||||
|
||||
last_sort_call = order_by_calls[-1]
|
||||
sort_args = last_sort_call[0]
|
||||
self.assertGreaterEqual(
|
||||
len(sort_args), 2,
|
||||
f"order_by should have at least 2 args, got: {sort_args}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M1: Sort order — list_refund_confirm_details should include order_num ASC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListRefundConfirmSortOrder(unittest.TestCase):
|
||||
"""list_refund_confirm_details should sort by order_num ASC, create_time DESC."""
|
||||
|
||||
@patch("app.crud.collection_protocol.BizProtocolPaymentDetail")
|
||||
@patch("app.crud.collection_protocol.BizProtocolPayment")
|
||||
@patch("app.crud.collection_protocol.BizCollectionProtocol")
|
||||
def test_refund_confirm_includes_order_num_in_sort(self, MockProtocol, MockPayment, MockDetail):
|
||||
"""order_by should include order_num ASC before create_time DESC."""
|
||||
from app.crud.collection_protocol import list_refund_confirm_details
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_query.join.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.with_entities.return_value = mock_query
|
||||
mock_query.scalar.return_value = 0
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = []
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
list_refund_confirm_details(mock_db, tenant_no="t1")
|
||||
|
||||
order_by_calls = mock_query.order_by.call_args_list
|
||||
self.assertGreaterEqual(len(order_by_calls), 1)
|
||||
|
||||
last_sort_call = order_by_calls[-1]
|
||||
sort_args = last_sort_call[0]
|
||||
self.assertGreaterEqual(
|
||||
len(sort_args), 2,
|
||||
f"order_by should have at least 2 args, got: {sort_args}"
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeYyyymmddDate(unittest.TestCase):
|
||||
def test_compact_and_dashed(self):
|
||||
from app.crud.collection_protocol import _normalize_yyyymmdd_date
|
||||
|
||||
self.assertEqual(_normalize_yyyymmdd_date("20260506"), "20260506")
|
||||
self.assertEqual(_normalize_yyyymmdd_date("2026-05-06"), "20260506")
|
||||
self.assertIsNone(_normalize_yyyymmdd_date(""))
|
||||
self.assertIsNone(_normalize_yyyymmdd_date(None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""采集协议单创建/更新子表持久化与字段名规范化测试。"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.crud.collection_protocol import (
|
||||
_normalize_fields,
|
||||
attachments_to_urls,
|
||||
urls_to_attachment_list,
|
||||
upsert_party_customer,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeFields(unittest.TestCase):
|
||||
def test_camel_case_to_snake(self):
|
||||
raw = {
|
||||
"customerId": "c1",
|
||||
"motherName": "张三",
|
||||
"motherBirthYm": "199001",
|
||||
"apgar1min": 9.5,
|
||||
"apgar5min": 10,
|
||||
}
|
||||
out = _normalize_fields(raw)
|
||||
self.assertEqual(out["customer_id"], "c1")
|
||||
self.assertEqual(out["mother_name"], "张三")
|
||||
self.assertEqual(out["mother_birth_ym"], "199001")
|
||||
self.assertEqual(out["apgar_1min"], 9.5)
|
||||
self.assertEqual(out["apgar_5min"], 10)
|
||||
|
||||
|
||||
class TestAttachmentsHelpers(unittest.TestCase):
|
||||
def test_attachments_to_urls(self):
|
||||
urls = attachments_to_urls(
|
||||
[{"fileUrl": "http://a/1.jpg"}, {"fileUrl": "http://a/2.jpg"}]
|
||||
)
|
||||
self.assertEqual(urls, "http://a/1.jpg,http://a/2.jpg")
|
||||
|
||||
def test_urls_to_attachment_list(self):
|
||||
items = urls_to_attachment_list("http://x/a.pdf,http://x/b.png")
|
||||
self.assertEqual(len(items), 2)
|
||||
self.assertEqual(items[0]["fileUrl"], "http://x/a.pdf")
|
||||
|
||||
def test_urls_to_attachment_list_uses_original_name_from_db(self):
|
||||
db = MagicMock()
|
||||
att = MagicMock()
|
||||
att.id = "att-1"
|
||||
att.original_name = "合同扫描件.pdf"
|
||||
att.file_url = "protocol/2025/05/16/abc.pdf"
|
||||
att.file_size = 2048
|
||||
att.file_type = "application/pdf"
|
||||
q = MagicMock()
|
||||
db.query.return_value = q
|
||||
q.filter.return_value = q
|
||||
q.first.return_value = att
|
||||
items = urls_to_attachment_list("protocol/2025/05/16/abc.pdf", db, "t1")
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]["fileName"], "合同扫描件.pdf")
|
||||
self.assertEqual(items[0]["id"], "att-1")
|
||||
|
||||
|
||||
class TestUpsertPartyCustomer(unittest.TestCase):
|
||||
@patch("app.crud.collection_protocol.get_party_customer")
|
||||
def test_upsert_applies_camel_case_fields(self, mock_get):
|
||||
mock_get.return_value = None
|
||||
db = MagicMock()
|
||||
fields = {
|
||||
"motherName": "李四",
|
||||
"motherPhone": "13800000000",
|
||||
"motherIdCard": "110101199001011234",
|
||||
"motherBirthYm": "199001",
|
||||
"fatherName": "王五",
|
||||
}
|
||||
upsert_party_customer(db, "proto-1", "t1", "u1", **fields)
|
||||
added = db.add.call_args[0][0]
|
||||
self.assertEqual(added.mother_name, "李四")
|
||||
self.assertEqual(added.mother_phone, "13800000000")
|
||||
self.assertEqual(added.mother_id_card, "110101199001011234")
|
||||
self.assertEqual(added.mother_birth_ym, "199001")
|
||||
self.assertEqual(added.father_name, "王五")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""民族码表 CRUD 冒烟测试"""
|
||||
from app.core.database import SessionLocal
|
||||
from app.crud.ethnic import list_ethnics
|
||||
|
||||
|
||||
def test_list_ethnics_has_han():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = list_ethnics(db)
|
||||
assert len(rows) >= 56
|
||||
han = next((r for r in rows if r["ethnicName"] == "汉族"), None)
|
||||
assert han is not None
|
||||
assert han["ethnicCode"] == "01"
|
||||
filtered = list_ethnics(db, keyword="蒙古")
|
||||
assert any(r["ethnicName"] == "蒙古族" for r in filtered)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 采集协议单当事人:新生儿 Apgar 评分支持一位小数
|
||||
ALTER TABLE `biz_protocol_party_customer`
|
||||
MODIFY COLUMN `apgar_1min` decimal(4,1) DEFAULT NULL COMMENT '新生儿评分1分钟',
|
||||
MODIFY COLUMN `apgar_5min` decimal(4,1) DEFAULT NULL COMMENT '新生儿评分5分钟';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 样本状态允许为空,移除默认「已采集」,与前端清空行为一致
|
||||
ALTER TABLE `biz_protocol_sample`
|
||||
MODIFY COLUMN `sample_status` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'ycj已采集 jcz检测中 yrk已入库 yzf已作废';
|
||||
@@ -0,0 +1,83 @@
|
||||
-- 民族码表(国标 GB/T 3304,仅数字编码条目,不含字母编码重复项)
|
||||
-- 执行前请确认库中无同名表;与 docs/sql/zkxw.sql 全量基线可并存,亦可合并进全量导出
|
||||
|
||||
DROP TABLE IF EXISTS `sys_ethnic`;
|
||||
CREATE TABLE `sys_ethnic` (
|
||||
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
|
||||
`ethnic_name` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '民族名称',
|
||||
`ethnic_code` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '民族编码',
|
||||
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '租户编号(空=全局码表)',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_sys_ethnic_code` (`ethnic_code`,`del_flag`),
|
||||
KEY `idx_sys_ethnic_name` (`ethnic_name`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='民族码表';
|
||||
|
||||
BEGIN;
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-01', '汉族', '01', '', 1, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-02', '蒙古族', '02', '', 2, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-03', '回族', '03', '', 3, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-04', '藏族', '04', '', 4, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-05', '维吾尔族', '05', '', 5, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-06', '苗族', '06', '', 6, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-07', '彝族', '07', '', 7, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-08', '壮族', '08', '', 8, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-09', '布依族', '09', '', 9, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-10', '朝鲜族', '10', '', 10, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-11', '满族', '11', '', 11, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-12', '侗族', '12', '', 12, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-13', '瑶族', '13', '', 13, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-14', '白族', '14', '', 14, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-15', '土家族', '15', '', 15, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-16', '哈尼族', '16', '', 16, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-17', '哈萨克族', '17', '', 17, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-18', '傣族', '18', '', 18, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-19', '黎族', '19', '', 19, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-20', '傈僳族', '20', '', 20, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-21', '佤族', '21', '', 21, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-22', '畲族', '22', '', 22, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-23', '高山族', '23', '', 23, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-24', '拉祜族', '24', '', 24, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-25', '水族', '25', '', 25, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-26', '东乡族', '26', '', 26, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-27', '纳西族', '27', '', 27, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-28', '景颇族', '28', '', 28, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-29', '柯尔克孜族', '29', '', 29, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-30', '土族', '30', '', 30, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-31', '达斡尔族', '31', '', 31, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-32', '仫佬族', '32', '', 32, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-33', '羌族', '33', '', 33, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-34', '布朗族', '34', '', 34, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-35', '撒拉族', '35', '', 35, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-36', '毛难族', '36', '', 36, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-37', '仡佬族', '37', '', 37, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-38', '锡伯族', '38', '', 38, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-39', '阿昌族', '39', '', 39, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-40', '普米族', '40', '', 40, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-41', '塔吉克族', '41', '', 41, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-42', '怒族', '42', '', 42, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-43', '乌孜别克族', '43', '', 43, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-44', '俄罗斯族', '44', '', 44, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-45', '鄂温克族', '45', '', 45, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-46', '德昂族', '46', '', 46, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-47', '保安族', '47', '', 47, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-48', '裕固族', '48', '', 48, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-49', '京族', '49', '', 49, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-50', '塔塔尔族', '50', '', 50, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-51', '独龙族', '51', '', 51, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-52', '鄂伦春族', '52', '', 52, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-53', '赫哲族', '53', '', 53, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-54', '门巴族', '54', '', 54, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-55', '珞巴族', '55', '', 55, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-56', '基诺族', '56', '', 56, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-81', '穿青人族', '81', '', 57, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-97', '其他', '97', '', 58, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
INSERT INTO `sys_ethnic` (`id`, `ethnic_name`, `ethnic_code`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('ethnic-98', '外国血统中国籍人士', '98', '', 59, NULL, NULL, '', '2026-05-16 00:00:00', NULL, '2026-05-16 00:00:00', 0);
|
||||
COMMIT;
|
||||
@@ -1,20 +0,0 @@
|
||||
-- 组织管理初始化数据(需求 2.1.3)
|
||||
-- 市场营销部门下设:采集部、临床部、中试服务部、支持中心、客服部
|
||||
|
||||
SET @marketing_id = UUID();
|
||||
|
||||
INSERT INTO sys_dept (id, parent_id, dept_name, dept_category_code, status, leader, leader_phone, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
(UUID(), '0', '中科兴皖', 'gs', '1', NULL, NULL, 'zkxw', 0, '', 0),
|
||||
(@marketing_id, '0', '市场营销部门', 'bm', '1', NULL, NULL, 'zkxw', 1, '', 0),
|
||||
(UUID(), '0', '技术研发部', 'bm', '1', NULL, NULL, 'zkxw', 2, '', 0),
|
||||
(UUID(), '0', '生产部', 'bm', '1', NULL, NULL, 'zkxw', 3, '', 0),
|
||||
(UUID(), '0', '质量安全部', 'bm', '1', NULL, NULL, 'zkxw', 4, '', 0),
|
||||
(UUID(), '0', '财务部', 'bm', '1', NULL, NULL, 'zkxw', 5, '', 0),
|
||||
(UUID(), '0', '综合部', 'bm', '1', NULL, NULL, 'zkxw', 6, '', 0);
|
||||
|
||||
INSERT INTO sys_dept (id, parent_id, dept_name, dept_category_code, status, leader, leader_phone, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
(UUID(), @marketing_id, '采集部', 'bm', '1', NULL, NULL, 'zkxw', 1, '', 0),
|
||||
(UUID(), @marketing_id, '临床部', 'bm', '1', NULL, NULL, 'zkxw', 2, '', 0),
|
||||
(UUID(), @marketing_id, '中试服务部', 'bm', '1', NULL, NULL, 'zkxw', 3, '', 0),
|
||||
(UUID(), @marketing_id, '支持中心', 'bm', '1', NULL, NULL, 'zkxw', 4, '', 0),
|
||||
(UUID(), @marketing_id, '客服部', 'bm', '1', NULL, NULL, 'zkxw', 5, '', 0);
|
||||
@@ -1,24 +0,0 @@
|
||||
-- ============================================================
|
||||
-- 字典管理初始数据(sys_dict_type / sys_dict_data)
|
||||
-- 执行前提:zkxw-requirements-v0.3-schema.sql 已建表
|
||||
-- ============================================================
|
||||
|
||||
-- 字典类型
|
||||
INSERT INTO sys_dict_type (id, dict_name, dict_type, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
(UUID(), '组织类别编码', 'zzlbbm', 'zkxw', 1, '', 0),
|
||||
(UUID(), '站点类型', 'zdlx', 'zkxw', 2, '', 0),
|
||||
(UUID(), '站点业务范围', 'zdywfw', 'zkxw', 3, '', 0),
|
||||
(UUID(), '部门状态', 'bmzt', 'zkxw', 4, '', 0);
|
||||
|
||||
-- 字典数据
|
||||
INSERT INTO sys_dict_data (id, dict_label, dict_value, dict_type, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
(UUID(), '公司', 'gs', 'zzlbbm', 'zkxw', 1, '', 0),
|
||||
(UUID(), '分公司', 'fgs', 'zzlbbm', 'zkxw', 2, '', 0),
|
||||
(UUID(), '部门', 'bm', 'zzlbbm', 'zkxw', 3, '', 0),
|
||||
(UUID(), '中心基地', 'zxjd', 'zdlx', 'zkxw', 1, '', 0),
|
||||
(UUID(), '样本采集', 'ybcj', 'zdywfw', 'zkxw', 1, '', 0),
|
||||
(UUID(), '样本制备', 'ybzb', 'zdywfw', 'zkxw', 2, '', 0),
|
||||
(UUID(), '质量检测', 'zljc', 'zdywfw', 'zkxw', 3, '', 0),
|
||||
(UUID(), '细胞存储', 'xbcc', 'zdywfw', 'zkxw', 4, '', 0),
|
||||
(UUID(), '停用', '0', 'bmzt', 'zkxw', 1, '', 0),
|
||||
(UUID(), '正常', '1', 'bmzt', 'zkxw', 2, '', 0);
|
||||
@@ -1,24 +0,0 @@
|
||||
-- 2.1.6 菜单管理初始化数据
|
||||
-- Web端根节点与移动端根节点,parent_id = 0
|
||||
|
||||
-- 根节点:Web端菜单、移动端菜单
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, visible, status, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
('1', 'Web端菜单', '0', 'M', '/system', NULL, '1', '1', 'zkxw', 1, '', 0),
|
||||
('2', '移动端菜单', '0', 'M', '/mini', NULL, '1', '1', 'zkxw', 2, '', 0);
|
||||
|
||||
-- Web端菜单 - 系统管理目录
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, visible, status, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
('100', '系统管理', '1', 'M', '/system', '/index/index', NULL, '1', '1', 'zkxw', 1, '', 0);
|
||||
|
||||
-- 系统管理子菜单
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, visible, status, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
('101', '租户管理', '100', 'C', 'tenant', '/system/tenant/index', 'system:tenant:list', '1', '1', 'zkxw', 1, '', 0),
|
||||
('102', '字典管理', '100', 'C', 'dict', '/system/dict/index', 'system:dict:list', '1', '1', 'zkxw', 2, '', 0),
|
||||
('103', '组织管理', '100', 'C', 'dept', '/system/dept/index', 'system:dept:list', '1', '1', 'zkxw', 3, '', 0),
|
||||
('104', '用户管理', '100', 'C', 'user', '/system/user/index', 'system:user:list', '1', '1', 'zkxw', 4, '', 0),
|
||||
('105', '角色管理', '100', 'C', 'role', '/system/role/index', 'system:role:list', '1', '1', 'zkxw', 5, '', 0),
|
||||
('106', '菜单管理', '100', 'C', 'menu', '/system/menu/index', 'system:menu:list', '1', '1', 'zkxw', 6, '', 0),
|
||||
('107', '站点管理', '100', 'C', 'site', '/system/site/index', 'system:site:list', '1', '1', 'zkxw', 7, '', 0),
|
||||
('108', '职位管理', '100', 'C', 'post', '/system/post/index', 'system:post:list', '1', '1', 'zkxw', 8, '', 0),
|
||||
('109', '物品单位', '100', 'C', 'unit', '/system/unit/index', 'system:unit:list', '1', '1', 'zkxw', 9, '', 0),
|
||||
('110', '快递公司', '100', 'C', 'express', '/system/express/index', 'system:express:list', '1', '1', 'zkxw', 10, '', 0);
|
||||
@@ -1,28 +0,0 @@
|
||||
-- V0.3 角色初始数据
|
||||
-- sys_role_data_scope_detail 建表语句
|
||||
CREATE TABLE IF NOT EXISTS `sys_role_data_scope_detail` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`role_id` varchar(64) NOT NULL COMMENT '角色ID',
|
||||
`scope_type` varchar(20) NOT NULL COMMENT '范围类型:dept/user',
|
||||
`scope_value` varchar(100) NOT NULL COMMENT '范围值:部门ID/用户ID',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` smallint NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_role_id` (`role_id`),
|
||||
KEY `idx_tenant_no` (`tenant_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色自定义数据权限明细';
|
||||
|
||||
-- 角色初始数据
|
||||
INSERT INTO sys_role (id, role_name, data_scope, status, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
(UUID(), '超级管理员', '1', '1', 'zkxw', 1, '', 0),
|
||||
(UUID(), '运营管理员', '1', '1', 'zkxw', 2, '', 0),
|
||||
(UUID(), '采集专员', '2', '1', 'zkxw', 3, '', 0),
|
||||
(UUID(), '市场支持专员', '2', '1', 'zkxw', 4, '', 0),
|
||||
(UUID(), '质检专员', '2', '1', 'zkxw', 5, '', 0);
|
||||
@@ -1,26 +0,0 @@
|
||||
-- 站点、职位、物品单位、快递公司 初始化数据
|
||||
-- 对应模型:SysSite, SysPost, BizItemUnit, BizExpressCompany
|
||||
|
||||
-- 站点
|
||||
INSERT INTO sys_site (id, site_name, site_code, site_type, status, address, contact_name, contact_phone, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
(UUID(), '中科兴皖中心基地', 'zkxw-zxjd', 'zxjd', '1', '', NULL, NULL, 'zkxw', 1, '', 0);
|
||||
|
||||
-- 职位
|
||||
INSERT INTO sys_post (id, post_name, post_code, status, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
(UUID(), '采集专员', 'cjzy', '1', 'zkxw', 1, '', 0),
|
||||
(UUID(), '市场支持专员', 'sczczy', '1', 'zkxw', 2, '', 0),
|
||||
(UUID(), '质检专员', 'zjzy', '1', 'zkxw', 3, '', 0);
|
||||
|
||||
-- 物品单位
|
||||
INSERT INTO biz_item_unit (id, unit_code, unit_name, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
(UUID(), 'g', '克', 'zkxw', 1, '', 0),
|
||||
(UUID(), 'ml', '毫升', 'zkxw', 2, '', 0),
|
||||
(UUID(), 'ge', '个', 'zkxw', 3, '', 0),
|
||||
(UUID(), 'fen', '份', 'zkxw', 4, '', 0);
|
||||
|
||||
-- 快递公司
|
||||
INSERT INTO biz_express_company (id, express_code, express_name, tenant_no, order_num, create_dept_id, del_flag) VALUES
|
||||
(UUID(), 'sf', '顺丰速运', 'zkxw', 1, '', 0),
|
||||
(UUID(), 'jd', '京东快递', 'zkxw', 2, '', 0),
|
||||
(UUID(), 'zt', '中通快递', 'zkxw', 3, '', 0),
|
||||
(UUID(), 'yt', '圆通速递', 'zkxw', 4, '', 0);
|
||||
@@ -1,24 +0,0 @@
|
||||
-- zkxw-init-user-data.sql
|
||||
-- V0.3 用户管理初始化数据(测试用户)
|
||||
-- 默认 admin 用户已在骨架 SQL 中创建,此处补充测试账号
|
||||
-- 注意:密码需通过后端 API 创建以获得正确的 bcrypt hash
|
||||
|
||||
INSERT INTO sys_user (
|
||||
id, dept_id, user_no, nick_name, email, phone_no, sex,
|
||||
password, status, tenant_no, order_num,
|
||||
create_dept_id, create_by, create_time, update_by, update_time, del_flag
|
||||
) VALUES
|
||||
(
|
||||
REPLACE(UUID(),'-',''),
|
||||
(SELECT id FROM sys_dept WHERE dept_name='市场营销部' AND del_flag=0 LIMIT 1),
|
||||
'zhangsan', '张三', 'zhangsan@zkxw.com', '13800000001', '0',
|
||||
'$2b$12$placeholder_hash_use_api_to_set', '1', 'zkxw', 2,
|
||||
'', NULL, NOW(), NULL, NOW(), 0
|
||||
),
|
||||
(
|
||||
REPLACE(UUID(),'-',''),
|
||||
(SELECT id FROM sys_dept WHERE dept_name='市场营销部' AND del_flag=0 LIMIT 1),
|
||||
'lisi', '李四', 'lisi@zkxw.com', '13800000002', '1',
|
||||
'$2b$12$placeholder_hash_use_api_to_set', '1', 'zkxw', 3,
|
||||
'', NULL, NOW(), NULL, NOW(), 0
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
+2550
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,59 @@
|
||||
# 细胞存储管理(2.2.6~2.2.10)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 交付细胞存储管理一级菜单下 5 个页面及对应后端 API(方案 C),满足已定稿设计:`docs/superpowers/specs/2026-05-13-cell-storage-protocol-modules-design.md` 与 OpenSpec 变更 `openspec/changes/cell-storage-protocol-modules/`。
|
||||
|
||||
**Architecture:** FastAPI 路由挂在 `/web/admin` 旁路扩展;SQLAlchemy Model 覆盖四张业务表;CRUD 层封装租户过滤与 camelCase 列表 DTO;Vue 页面复制 `system/unit` 列表骨架并按模块替换列与弹窗。
|
||||
|
||||
**Tech Stack:** Python 3.9、FastAPI、SQLAlchemy、Pydantic v2;Vue 3、Element Plus、pnpm、TypeScript。
|
||||
|
||||
---
|
||||
|
||||
## 文件映射(落地前对齐)
|
||||
|
||||
| 区域 | 路径 |
|
||||
|------|------|
|
||||
| 设计/契约 | `docs/superpowers/specs/2026-05-13-cell-storage-protocol-modules-design.md`,`openspec/changes/cell-storage-protocol-modules/` |
|
||||
| Model | `backend/app/models/models.py` |
|
||||
| CRUD | `backend/app/crud/collection_protocol.py`(或按职责拆分文件) |
|
||||
| API | `backend/app/api/collection_protocol.py`,注册 `app/api/__init__.py`、`main.py` |
|
||||
| 菜单 | `backend/app/api/menu.py` |
|
||||
| 前端路由 | `web-admin/src/router/modules/sqxp.ts` |
|
||||
| 前端 API | `web-admin/src/api/collection-protocol.ts`(命名以仓库惯例为准) |
|
||||
| 页面 | `web-admin/src/views/cell-storage/{protocol,protocol-audit,payment-confirm,refund-confirm,installment-warning}/index.vue` |
|
||||
|
||||
---
|
||||
|
||||
## 执行清单(与 OpenSpec tasks.md 同步)
|
||||
|
||||
按顺序执行 `openspec/changes/cell-storage-protocol-modules/tasks.md` 中章节 **1 → 7**。每完成一节,将对应 `- [ ]` 改为 `- [x]`。
|
||||
|
||||
---
|
||||
|
||||
## Self-review(对照 spec)
|
||||
|
||||
| Spec capability | tasks.md 覆盖 |
|
||||
|-----------------|---------------|
|
||||
| cell-storage-collection-protocol | 1.x、2.x、5.1、3.x |
|
||||
| cell-storage-protocol-audit | 1.x、2.x、5.2 |
|
||||
| cell-storage-receipt-confirm | 1.x、2.x、5.3 |
|
||||
| cell-storage-refund-confirm | 1.x、2.x、5.4 |
|
||||
| cell-storage-installment-warning | 1.x、2.x、5.5 |
|
||||
|
||||
无占位:具体接口路径与状态码以实现对照 design.md 为准。
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-05-13-cell-storage-protocol-modules.md`.
|
||||
|
||||
**Two execution options:**
|
||||
|
||||
1. **Subagent-Driven(推荐)** — 每个 task 派生子代理并在任务间复核
|
||||
2. **Inline Execution** — 本会话按 `openspec apply` 逐项实现(使用 executing-plans)
|
||||
|
||||
**Which approach?** 若未指定,默认本会话按 OpenSpec `tasks.md` 顺序实现。
|
||||
|
||||
下一步命令:`openspec instructions apply --change cell-storage-protocol-modules --json` 读取上下文文件后开始改代码。
|
||||
@@ -0,0 +1,535 @@
|
||||
# FlowDesigner 功能补全实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 补全流程设计器三个核心功能缺口:审批人选择(用户/角色下拉数据加载)、连线条件分支配置(点击连线编辑条件表达式)、SQL查询帮助提示。
|
||||
|
||||
**Architecture:** 后端新增2个轻量options端点;前端FlowDesigner.vue增加edge:click事件处理、selectedEdge状态、条件表达式编辑面板、以及handleOpen时加载用户/角色数据。
|
||||
|
||||
**Tech Stack:** Python FastAPI, Vue 3 + Element Plus, AntV X6
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 操作 | 职责 |
|
||||
|------|------|------|
|
||||
| `backend/app/api/admin.py` | 修改 | 新增 `GET /web/admin/users/options` 和 `GET /web/admin/roles/options` |
|
||||
| `web-admin/src/api/admin.ts` | 修改 | 新增 `fetchUserOptions()` 和 `fetchRoleOptions()` |
|
||||
| `web-admin/src/views/system/workflow/designer/FlowDesigner.vue` | 修改 | 核心改动:edge事件、selectedEdge、条件编辑面板、数据加载 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端新增 users/options 和 roles/options 端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/admin.py` — 在 `list_roles_for_select`(line 33)后面新增两个端点
|
||||
|
||||
- [ ] **Step 1: 新增 `GET /web/admin/users/options` 端点**
|
||||
|
||||
在 `admin.py` 的 `list_roles_for_select` 函数后面(约 line 63 之后),添加:
|
||||
|
||||
```python
|
||||
@router.get("/users/options")
|
||||
async def list_users_for_select(
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""返回用户下拉选项(label=昵称,value=用户ID),供流程设计器审批人选择使用。"""
|
||||
tenant_no = _user.get("tenant_no", "")
|
||||
q = db.query(SysUser).filter(SysUser.del_flag == 0, SysUser.status == "1")
|
||||
if tenant_no:
|
||||
q = q.filter(SysUser.tenant_no == tenant_no)
|
||||
rows = q.order_by(SysUser.order_num.asc(), SysUser.create_time.desc()).all()
|
||||
out = [{"label": (r.nick_name or r.user_no or ""), "value": r.id} for r in rows]
|
||||
return success_response(out)
|
||||
```
|
||||
|
||||
注意:顶部已有 `from app.models.models import SysUser, SysRole, ...` 等导入,无需新增。
|
||||
|
||||
- [ ] **Step 2: 新增 `GET /web/admin/roles/options` 端点**
|
||||
|
||||
紧接上面的函数后面添加:
|
||||
|
||||
```python
|
||||
@router.get("/roles/options")
|
||||
async def list_roles_for_designer(
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""返回角色下拉选项(label=角色名,value=角色ID UUID),供流程设计器审批人选择使用。
|
||||
与 /roles 不同,此端点返回角色 UUID 作为 value(引擎解析审批人需要 UUID)。"""
|
||||
tenant_no = _user.get("tenant_no", "")
|
||||
q = db.query(SysRole).filter(SysRole.del_flag == 0, SysRole.status == "1")
|
||||
if tenant_no:
|
||||
q = q.filter(SysRole.tenant_no == tenant_no)
|
||||
rows = q.order_by(SysRole.order_num.asc()).all()
|
||||
out = [{"label": (r.role_name or "").strip(), "value": r.id} for r in rows if (r.role_name or "").strip()]
|
||||
return success_response(out)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证后端启动**
|
||||
|
||||
```bash
|
||||
cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.api.admin import router; print('OK')"
|
||||
```
|
||||
|
||||
Expected: `OK`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 前端 API 函数 + 设计器加载用户/角色数据
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/admin.ts` — 新增两个 API 函数
|
||||
- Modify: `web-admin/src/views/system/workflow/designer/FlowDesigner.vue` — 导入 API、handleOpen 加载数据
|
||||
|
||||
- [ ] **Step 1: 在 `admin.ts` 新增两个 API 函数**
|
||||
|
||||
在 `fetchGetRoleList()`(line 18-22)后面添加:
|
||||
|
||||
```typescript
|
||||
/** 流程设计器:用户下拉选项(label=昵称,value=用户ID) */
|
||||
export function fetchUserOptions() {
|
||||
return request.get<Array<{ label: string; value: string }>>({
|
||||
url: '/web/admin/users/options'
|
||||
})
|
||||
}
|
||||
|
||||
/** 流程设计器:角色下拉选项(label=角色名,value=角色ID UUID) */
|
||||
export function fetchRoleOptions() {
|
||||
return request.get<Array<{ label: string; value: string }>>({
|
||||
url: '/web/admin/roles/options'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 FlowDesigner.vue 导入新 API 并加载选项数据**
|
||||
|
||||
修改 line 146 的 import 语句:
|
||||
|
||||
```typescript
|
||||
import { fetchDesignerData, fetchPublishVersion } from '@/api/workflow'
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```typescript
|
||||
import { fetchDesignerData, fetchPublishVersion } from '@/api/workflow'
|
||||
import { fetchUserOptions, fetchRoleOptions } from '@/api/admin'
|
||||
```
|
||||
|
||||
然后在 `handleOpen()` 函数(line 742-748)中添加数据加载:
|
||||
|
||||
```typescript
|
||||
function handleOpen() {
|
||||
selectedNode.value = null
|
||||
selectedEdge.value = null
|
||||
registerShapes()
|
||||
nextTick(() => {
|
||||
initGraph()
|
||||
loadDesignerData()
|
||||
loadOptions()
|
||||
})
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
try {
|
||||
const [users, roles] = await Promise.all([fetchUserOptions(), fetchRoleOptions()])
|
||||
userOptions.value = users || []
|
||||
roleOptions.value = roles || []
|
||||
} catch {
|
||||
// 加载失败则保持空选项,不影响设计器其他功能
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注意:`selectedEdge` 在 Task 3 中定义。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 连线点击 + 条件表达式编辑器 + 连线标签
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/system/workflow/designer/FlowDesigner.vue`
|
||||
|
||||
这是最核心的改动。需要在 FlowDesigner.vue 中完成以下修改:
|
||||
|
||||
- [ ] **Step 1: 新增 `selectedEdge` 响应式状态和接口定义**
|
||||
|
||||
在 `selectedNode` 定义(line 201)后面添加:
|
||||
|
||||
```typescript
|
||||
interface ConditionRule {
|
||||
field: string
|
||||
op: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface SelectedEdgeData {
|
||||
id: string
|
||||
fromNodeName: string
|
||||
toNodeName: string
|
||||
rules: ConditionRule[]
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
const selectedEdge = ref<SelectedEdgeData | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册 edge:click 事件 + 修改 blank:click 和 node:click 清除 selectedEdge**
|
||||
|
||||
在 `initGraph()` 函数中,`graph.on('blank:click', ...)` (line 560-563)后面添加 edge 事件:
|
||||
|
||||
```typescript
|
||||
// 连线点击 → 选中(仅条件节点的出线可编辑条件)
|
||||
graph.on('edge:click', ({ edge }) => {
|
||||
const sourceId = edge.getSourceCellId() || ''
|
||||
const sourceNode = graph!.getCellById(sourceId)
|
||||
const sourceData = sourceNode?.getData() || {}
|
||||
const targetId = edge.getTargetCellId() || ''
|
||||
const targetNode = graph!.getCellById(targetId)
|
||||
const targetData = targetNode?.getData() || {}
|
||||
|
||||
const edgeData = edge.getData() || {}
|
||||
const rawRules = edgeData.conditionExpr ? (JSON.parse(edgeData.conditionExpr) || {}) : {}
|
||||
const rules: ConditionRule[] = rawRules.rules || rawRules || []
|
||||
// 确保至少有一条规则
|
||||
if (!Array.isArray(rules) || rules.length === 0) {
|
||||
rules.push({ field: '', op: '==', value: '' })
|
||||
}
|
||||
// 规范化为 ConditionRule[](只保留简单的 field/op/value 三元组)
|
||||
const normalizedRules: ConditionRule[] = rules.map((r: any) => ({
|
||||
field: r.field || '',
|
||||
op: r.op || '==',
|
||||
value: String(r.value ?? ''),
|
||||
}))
|
||||
|
||||
selectedEdge.value = {
|
||||
id: edge.id,
|
||||
fromNodeName: sourceData.nodeName || sourceId,
|
||||
toNodeName: targetData.nodeName || targetId,
|
||||
rules: normalizedRules,
|
||||
isDefault: !edgeData.conditionExpr,
|
||||
}
|
||||
selectedNode.value = null
|
||||
})
|
||||
```
|
||||
|
||||
同时修改 `blank:click` 和 `node:click` 事件处理,在清除/设置 `selectedNode` 时也清除 `selectedEdge`:
|
||||
|
||||
```typescript
|
||||
// node:click 处理中添加:
|
||||
selectedEdge.value = null
|
||||
|
||||
// blank:click 处理中改为:
|
||||
graph.on('blank:click', () => {
|
||||
selectedNode.value = null
|
||||
selectedEdge.value = null
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 连线创建时初始化 data**
|
||||
|
||||
在 `createEdge()` 函数(约 line 522-539)中,给新创建的 edge 加上 data 初始化:
|
||||
|
||||
在 `graph!.createEdge({...})` 调用中添加 `data: { conditionExpr: '' }` 属性:
|
||||
|
||||
```typescript
|
||||
createEdge() {
|
||||
return graph!.createEdge({
|
||||
shape: 'edge',
|
||||
data: { conditionExpr: '' },
|
||||
attrs: {
|
||||
// ... 保持原有 attrs 不变
|
||||
},
|
||||
router: { name: 'manhattan' },
|
||||
connector: { name: 'rounded', args: { radius: 8 } },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 添加同步连线条件数据到 graph edge 的函数**
|
||||
|
||||
在 `syncNodeLabel` 函数后面添加:
|
||||
|
||||
```typescript
|
||||
// 同步条件表达式到 edge 并更新标签
|
||||
function syncEdgeCondition() {
|
||||
if (!selectedEdge.value || !graph) return
|
||||
const edge = graph.getCellById(selectedEdge.value.id)
|
||||
if (!edge || !edge.isEdge()) return
|
||||
|
||||
if (selectedEdge.value.isDefault) {
|
||||
edge.setData({ ...edge.getData(), conditionExpr: '' })
|
||||
edge.setLabels([])
|
||||
} else {
|
||||
const rules = selectedEdge.value.rules
|
||||
.filter((r) => r.field.trim())
|
||||
.map((r) => ({ field: r.field, op: r.op, value: r.value }))
|
||||
const expr = JSON.stringify({ rules })
|
||||
edge.setData({ ...edge.getData(), conditionExpr: expr })
|
||||
// 设置标签显示条件摘要
|
||||
const summary = rules.map((r) => `${r.field} ${r.op} ${r.value}`).join(' & ')
|
||||
edge.setLabels([
|
||||
{
|
||||
attrs: {
|
||||
label: {
|
||||
text: summary || '条件',
|
||||
fill: '#374151',
|
||||
fontSize: 11,
|
||||
textAnchor: 'middle',
|
||||
textVerticalAnchor: 'bottom',
|
||||
},
|
||||
rect: {
|
||||
fill: '#fff',
|
||||
stroke: '#d1d5db',
|
||||
strokeWidth: 1,
|
||||
rx: 4,
|
||||
ry: 4,
|
||||
},
|
||||
},
|
||||
position: {
|
||||
distance: 0.5,
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 修改序列化函数,从 edge data 读取 conditionExpr**
|
||||
|
||||
当前 `serializeGraph()` 已经正确读取 `e.getData()?.conditionExpr`(line 670),无需修改。确认即可。
|
||||
|
||||
- [ ] **Step 6: 修改模板 — 右侧属性面板增加连线编辑状态**
|
||||
|
||||
在模板的 `<aside class="props-panel">` 中(line 60-138),修改三态逻辑:
|
||||
|
||||
将原来的 `<template v-if="selectedNode">` ... `<template v-else>` 结构改为三态:
|
||||
|
||||
```html
|
||||
<!-- 右侧属性面板 -->
|
||||
<aside class="props-panel">
|
||||
<!-- 节点选中状态 -->
|
||||
<template v-if="selectedNode">
|
||||
<div class="panel-section-title">节点属性</div>
|
||||
<el-form label-position="top" class="props-form">
|
||||
<!-- ... 保持原有节点属性表单不变 (lines 64-132) ... -->
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<!-- 连线选中状态(条件编辑) -->
|
||||
<template v-else-if="selectedEdge">
|
||||
<div class="panel-section-title">连线条件</div>
|
||||
<div class="edge-info">
|
||||
<span class="edge-from">{{ selectedEdge.fromNodeName }}</span>
|
||||
<span class="edge-arrow">→</span>
|
||||
<span class="edge-to">{{ selectedEdge.toNodeName }}</span>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" class="props-form" style="margin-top: 12px">
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="selectedEdge.isDefault" @change="syncEdgeCondition">
|
||||
默认分支(无条件,其他分支都不满足时走此路径)
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="!selectedEdge.isDefault">
|
||||
<div v-for="(rule, idx) in selectedEdge.rules" :key="idx" class="condition-rule-row">
|
||||
<el-input v-model="rule.field" placeholder="字段名" style="width: 30%" @input="syncEdgeCondition" />
|
||||
<el-select v-model="rule.op" style="width: 22%" @change="syncEdgeCondition">
|
||||
<el-option label="==" value="==" />
|
||||
<el-option label="!=" value="!=" />
|
||||
<el-option label=">" value=">" />
|
||||
<el-option label="<" value="<" />
|
||||
<el-option label=">=" value=">=" />
|
||||
<el-option label="<=" value="<=" />
|
||||
<el-option label="in" value="in" />
|
||||
<el-option label="not_in" value="not_in" />
|
||||
<el-option label="contains" value="contains" />
|
||||
</el-select>
|
||||
<el-input v-model="rule.value" placeholder="值" style="width: 30%" @input="syncEdgeCondition" />
|
||||
<el-button
|
||||
:icon="selectedEdge.rules.length > 1 ? 'Delete' : undefined"
|
||||
text
|
||||
type="danger"
|
||||
@click="removeConditionRule(idx)"
|
||||
/>
|
||||
</div>
|
||||
<el-button type="primary" link @click="addConditionRule">+ 添加条件</el-button>
|
||||
</template>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<template v-else>
|
||||
<div class="panel-section-title">属性</div>
|
||||
<div class="empty-hint">点击画布中的节点或连线查看属性</div>
|
||||
</template>
|
||||
</aside>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 添加条件规则操作函数**
|
||||
|
||||
在 `syncEdgeCondition` 函数后面添加:
|
||||
|
||||
```typescript
|
||||
function addConditionRule() {
|
||||
if (selectedEdge.value) {
|
||||
selectedEdge.value.rules.push({ field: '', op: '==', value: '' })
|
||||
}
|
||||
}
|
||||
|
||||
function removeConditionRule(idx: number) {
|
||||
if (selectedEdge.value && selectedEdge.value.rules.length > 1) {
|
||||
selectedEdge.value.rules.splice(idx, 1)
|
||||
syncEdgeCondition()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 添加条件编辑面板的 CSS**
|
||||
|
||||
在 `<style scoped lang="scss">` 块中添加:
|
||||
|
||||
```scss
|
||||
.edge-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.edge-from {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edge-arrow {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.edge-to {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.condition-rule-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: 更新操作提示列表**
|
||||
|
||||
在左侧面板的操作提示(line 46-51)中,添加连线相关的提示:
|
||||
|
||||
```html
|
||||
<li>点击连线编辑条件规则</li>
|
||||
```
|
||||
|
||||
添加在 "从节点连接点拖出连线" 后面。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: SQL 模式帮助提示
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/system/workflow/designer/FlowDesigner.vue`
|
||||
|
||||
- [ ] **Step 1: 在 SQL textarea 下方添加帮助提示**
|
||||
|
||||
将原有的 SQL textarea(约 line 111-118)修改为:
|
||||
|
||||
```html
|
||||
<el-form-item v-if="selectedNode.config.strategy === 'sql'" label="SQL查询语句">
|
||||
<el-input
|
||||
v-model="selectedNode.config.sqlExpr"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="SELECT user_id FROM sys_user WHERE ..."
|
||||
/>
|
||||
<div class="sql-help">
|
||||
<p><strong>SQL审批人策略说明:</strong></p>
|
||||
<ul>
|
||||
<li>必须以 <code>SELECT</code> 开头,只允许只读查询</li>
|
||||
<li>查询结果第一列将作为审批人 user_id</li>
|
||||
<li>可使用流程变量作为参数:<code>:var_name</code> 占位符</li>
|
||||
<li>禁止 INSERT / UPDATE / DELETE / DROP 等写操作</li>
|
||||
</ul>
|
||||
<p class="sql-example">示例:<code>SELECT id FROM sys_user WHERE dept_id = :dept_id AND del_flag = 0</code></p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 SQL 帮助样式**
|
||||
|
||||
在 scoped style 中添加:
|
||||
|
||||
```scss
|
||||
.sql-help {
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
|
||||
p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 4px 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #e0f2fe;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
color: #0369a1;
|
||||
}
|
||||
}
|
||||
|
||||
.sql-example {
|
||||
margin-top: 6px !important;
|
||||
color: #059669;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 验证
|
||||
|
||||
- [ ] **Step 1: 后端验证**
|
||||
|
||||
```bash
|
||||
cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.api.admin import router; print('OK')"
|
||||
```
|
||||
|
||||
Expected: `OK`
|
||||
|
||||
- [ ] **Step 2: 前端 build 验证**
|
||||
|
||||
```bash
|
||||
cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm build
|
||||
```
|
||||
|
||||
Expected: Build 成功,无新增错误
|
||||
|
||||
- [ ] **Step 3: LSP 诊断**
|
||||
|
||||
对 `FlowDesigner.vue` 运行 `lsp_diagnostics`,确认无类型错误。
|
||||
@@ -0,0 +1,612 @@
|
||||
# FlowDesigner 企业级增强实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
|
||||
|
||||
**Goal:** 将流程设计器从基础原型提升到企业可用级别:节点删除、撤销重做、键盘快捷键、AND/OR条件组、发布前验证、缩放工具栏。
|
||||
|
||||
**Architecture:** 安装 AntV X6 插件(Selection/Keyboard/History/Snapline/Clipboard),增强 FlowDesigner.vue 的交互能力和条件编辑器,新增验证逻辑。
|
||||
|
||||
**Tech Stack:** AntV X6 v3 + X6 插件, Vue 3 + Element Plus
|
||||
|
||||
---
|
||||
|
||||
## 前置:安装 X6 插件
|
||||
|
||||
```bash
|
||||
cd web-admin && pnpm add @antv/x6-plugin-selection @antv/x6-plugin-keyboard @antv/x6-plugin-history @antv/x6-plugin-snapline @antv/x6-plugin-clipboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 交互增强 — 删除 + 撤销重做 + 快捷键
|
||||
|
||||
**Files:** `web-admin/src/views/system/workflow/designer/FlowDesigner.vue`
|
||||
|
||||
### 1.1 新增插件导入
|
||||
```typescript
|
||||
import { Selection } from '@antv/x6-plugin-selection'
|
||||
import { Keyboard } from '@antv/x6-plugin-keyboard'
|
||||
import { History } from '@antv/x6-plugin-history'
|
||||
import { Snapline } from '@antv/x6-plugin-snapline'
|
||||
import { Clipboard } from '@antv/x6-plugin-clipboard'
|
||||
```
|
||||
|
||||
### 1.2 Graph 构造函数启用 history
|
||||
```typescript
|
||||
history: { enabled: true },
|
||||
```
|
||||
|
||||
### 1.3 initGraph 中注册插件(在 graph 创建后、事件绑定前)
|
||||
```typescript
|
||||
graph.use(new Selection({ enabled: true, multiple: true, rubberband: true, movable: true, showNodeSelectionBox: true }))
|
||||
graph.use(new Keyboard({ enabled: true }))
|
||||
graph.use(new Snapline({ enabled: true }))
|
||||
graph.use(new Clipboard({ enabled: true }))
|
||||
```
|
||||
|
||||
### 1.4 键盘快捷键绑定
|
||||
```typescript
|
||||
// Delete/Backspace 删除选中
|
||||
graph.bindKey(['delete', 'backspace'], () => {
|
||||
const cells = graph.getSelectedCells()
|
||||
if (cells.length) {
|
||||
// 保护:不允许删除 start 和 end 节点以外的连线
|
||||
const toRemove = cells.filter((c) => {
|
||||
if (c.isNode()) {
|
||||
const data = c.getData() || {}
|
||||
return data.nodeType !== 'start' && data.nodeType !== 'end'
|
||||
}
|
||||
return true // 边都可以删除
|
||||
})
|
||||
if (toRemove.length) graph.removeCells(toRemove)
|
||||
}
|
||||
selectedNode.value = null
|
||||
selectedEdge.value = null
|
||||
return false
|
||||
})
|
||||
|
||||
// Undo Ctrl+Z
|
||||
graph.bindKey(['ctrl+z', 'meta+z'], () => {
|
||||
if (graph.canUndo()) graph.undo()
|
||||
return false
|
||||
})
|
||||
|
||||
// Redo Ctrl+Y
|
||||
graph.bindKey(['ctrl+y', 'meta+shift+z'], () => {
|
||||
if (graph.canRedo()) graph.redo()
|
||||
return false
|
||||
})
|
||||
|
||||
// Copy Ctrl+C
|
||||
graph.bindKey(['ctrl+c', 'meta+c'], () => {
|
||||
const cells = graph.getSelectedCells().filter((c) => c.isNode())
|
||||
if (cells.length) graph.copy(cells)
|
||||
return false
|
||||
})
|
||||
|
||||
// Paste Ctrl+V
|
||||
graph.bindKey(['ctrl+v', 'meta+v'], () => {
|
||||
if (!graph.isClipboardEmpty()) {
|
||||
const cells = graph.paste({ offset: 32 })
|
||||
graph.cleanSelection()
|
||||
graph.select(cells)
|
||||
}
|
||||
return false
|
||||
})
|
||||
```
|
||||
|
||||
### 1.5 节点/边删除后清除选中状态
|
||||
```typescript
|
||||
graph.on('cell:removed', () => {
|
||||
selectedNode.value = null
|
||||
selectedEdge.value = null
|
||||
})
|
||||
```
|
||||
|
||||
### 1.6 属性面板增加删除按钮
|
||||
在节点属性面板的顶部,节点名称旁边增加删除按钮:
|
||||
```html
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
<span style="font-weight: 500">节点属性</span>
|
||||
<el-button
|
||||
v-if="selectedNode.nodeType !== 'start' && selectedNode.nodeType !== 'end'"
|
||||
type="danger"
|
||||
text
|
||||
size="small"
|
||||
@click="deleteSelectedNode"
|
||||
>
|
||||
删除节点
|
||||
</el-button>
|
||||
</div>
|
||||
```
|
||||
|
||||
在连线条件面板顶部也增加删除按钮:
|
||||
```html
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
<span class="panel-section-title" style="margin: 0">连线条件</span>
|
||||
<el-button type="danger" text size="small" @click="deleteSelectedEdge">删除连线</el-button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 1.7 删除函数
|
||||
```typescript
|
||||
function deleteSelectedNode() {
|
||||
if (!selectedNode.value || !graph) return
|
||||
const cell = graph.getCellById(selectedNode.value.id)
|
||||
if (cell) graph.removeCell(cell)
|
||||
selectedNode.value = null
|
||||
}
|
||||
|
||||
function deleteSelectedEdge() {
|
||||
if (!selectedEdge.value || !graph) return
|
||||
const cell = graph.getCellById(selectedEdge.value.id)
|
||||
if (cell) graph.removeCell(cell)
|
||||
selectedEdge.value = null
|
||||
}
|
||||
```
|
||||
|
||||
### 1.8 工具栏增加撤销/重做按钮
|
||||
在 header 的按钮区域增加撤销/重做:
|
||||
```html
|
||||
<el-button class="btn-ghost" :disabled="!canUndo" @click="handleUndo">撤销</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!canRedo" @click="handleRedo">重做</el-button>
|
||||
```
|
||||
|
||||
新增响应式状态和函数:
|
||||
```typescript
|
||||
const canUndo = ref(false)
|
||||
const canRedo = ref(false)
|
||||
|
||||
// 在 initGraph 中,history 插件注册后:
|
||||
graph.on('history:change', () => {
|
||||
canUndo.value = graph?.canUndo() ?? false
|
||||
canRedo.value = graph?.canRedo() ?? false
|
||||
})
|
||||
|
||||
function handleUndo() {
|
||||
if (graph?.canUndo()) graph.undo()
|
||||
}
|
||||
|
||||
function handleRedo() {
|
||||
if (graph?.canRedo()) graph.redo()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 条件表达式增强 — AND/OR 分组 + 流程变量提示
|
||||
|
||||
### 2.1 数据模型改为分组结构
|
||||
|
||||
替换 `ConditionRule` 接口为分组结构:
|
||||
```typescript
|
||||
interface ConditionRule {
|
||||
field: string
|
||||
op: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface ConditionGroup {
|
||||
logic: 'and' | 'or' // 组内规则之间的逻辑
|
||||
rules: ConditionRule[]
|
||||
}
|
||||
```
|
||||
|
||||
替换 `SelectedEdgeData` 的 `rules` 为 `groups`:
|
||||
```typescript
|
||||
interface SelectedEdgeData {
|
||||
id: string
|
||||
fromNodeName: string
|
||||
toNodeName: string
|
||||
groups: ConditionGroup[] // 组之间是 OR 关系
|
||||
groupLogic: 'or' // 组间逻辑(固定为 OR)
|
||||
isDefault: boolean
|
||||
}
|
||||
```
|
||||
|
||||
条件语义:组内 AND,组间 OR。即 `(rule1 AND rule2) OR (rule3 AND rule4)`
|
||||
|
||||
### 2.2 条件编辑器 UI 改为分组模式
|
||||
|
||||
```html
|
||||
<template v-if="!selectedEdge.isDefault">
|
||||
<div v-for="(group, gIdx) in selectedEdge.groups" :key="gIdx" class="condition-group">
|
||||
<div class="group-header">
|
||||
<span class="group-label">条件组 {{ gIdx + 1 }}</span>
|
||||
<span class="group-logic-badge">组内条件全部满足 (AND)</span>
|
||||
<el-button
|
||||
v-if="selectedEdge.groups.length > 1"
|
||||
type="danger"
|
||||
text
|
||||
size="small"
|
||||
@click="removeConditionGroup(gIdx)"
|
||||
>
|
||||
删除组
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-for="(rule, rIdx) in group.rules" :key="rIdx" class="condition-rule-row">
|
||||
<el-input v-model="rule.field" placeholder="字段名(流程变量)" style="width: 28%" @input="syncEdgeCondition" />
|
||||
<el-select v-model="rule.op" style="width: 20%" @change="syncEdgeCondition">
|
||||
<el-option label="==" value="==" />
|
||||
<el-option label="!=" value="!=" />
|
||||
<el-option label=">" value=">" />
|
||||
<el-option label="<" value="<" />
|
||||
<el-option label=">=" value=">=" />
|
||||
<el-option label="<=" value="<=" />
|
||||
<el-option label="包含" value="contains" />
|
||||
<el-option label="不包含" value="not_in" />
|
||||
</el-select>
|
||||
<el-input v-model="rule.value" placeholder="值" style="width: 28%" @input="syncEdgeCondition" />
|
||||
<el-button
|
||||
v-if="group.rules.length > 1"
|
||||
text
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="removeConditionRule(gIdx, rIdx)"
|
||||
>
|
||||
×
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button type="primary" link size="small" @click="addConditionRule(gIdx)">
|
||||
+ 添加条件
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="group-logic-hint">组间关系:任一条件组满足即可 (OR)</div>
|
||||
<el-button type="primary" link @click="addConditionGroup">+ 添加条件组 (OR)</el-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 2.3 操作函数更新
|
||||
|
||||
```typescript
|
||||
function addConditionGroup() {
|
||||
if (selectedEdge.value) {
|
||||
selectedEdge.value.groups.push({ logic: 'and', rules: [{ field: '', op: '==', value: '' }] })
|
||||
}
|
||||
}
|
||||
|
||||
function removeConditionGroup(gIdx: number) {
|
||||
if (selectedEdge.value && selectedEdge.value.groups.length > 1) {
|
||||
selectedEdge.value.groups.splice(gIdx, 1)
|
||||
syncEdgeCondition()
|
||||
}
|
||||
}
|
||||
|
||||
function addConditionRule(gIdx: number) {
|
||||
if (selectedEdge.value) {
|
||||
selectedEdge.value.groups[gIdx].rules.push({ field: '', op: '==', value: '' })
|
||||
}
|
||||
}
|
||||
|
||||
function removeConditionRule(gIdx: number, rIdx: number) {
|
||||
if (selectedEdge.value && selectedEdge.value.groups[gIdx].rules.length > 1) {
|
||||
selectedEdge.value.groups[gIdx].rules.splice(rIdx, 1)
|
||||
syncEdgeCondition()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 syncEdgeCondition 序列化更新
|
||||
|
||||
生成与后端引擎兼容的 JSON:
|
||||
```typescript
|
||||
function syncEdgeCondition() {
|
||||
if (!selectedEdge.value || !graph) return
|
||||
const edge = graph.getCellById(selectedEdge.value.id)
|
||||
if (!edge || !edge.isEdge()) return
|
||||
|
||||
if (selectedEdge.value.isDefault) {
|
||||
edge.setData({ ...edge.getData(), conditionExpr: '' })
|
||||
edge.setLabels([])
|
||||
} else {
|
||||
// 序列化为引擎兼容格式:[{and: [{field,op,value},...]}, {and: [...]}]
|
||||
// 顶层 array = OR 语义(引擎 _check_rules 对 list 做 any())
|
||||
const groups = selectedEdge.value.groups
|
||||
.filter((g) => g.rules.some((r) => r.field.trim()))
|
||||
.map((g) => ({
|
||||
and: g.rules
|
||||
.filter((r) => r.field.trim())
|
||||
.map((r) => ({ field: r.field, op: r.op, value: r.value })),
|
||||
}))
|
||||
const expr = JSON.stringify(groups)
|
||||
edge.setData({ ...edge.getData(), conditionExpr: expr })
|
||||
|
||||
// 生成可读标签
|
||||
const parts = groups.map((g) =>
|
||||
g.and.map((r) => `${r.field} ${r.op} ${r.value}`).join(' & '),
|
||||
)
|
||||
const summary = parts.join(' | ')
|
||||
edge.setLabels([
|
||||
{
|
||||
attrs: {
|
||||
label: { text: summary || '条件', fill: '#374151', fontSize: 11, textAnchor: 'middle', textVerticalAnchor: 'bottom' },
|
||||
rect: { fill: '#fff', stroke: '#d1d5db', strokeWidth: 1, rx: 4, ry: 4 },
|
||||
},
|
||||
position: { distance: 0.5 },
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 edge:click 解析更新
|
||||
|
||||
解析引擎格式到分组 UI:
|
||||
```typescript
|
||||
// 在 edge:click handler 中:
|
||||
let normalizedGroups: ConditionGroup[] = []
|
||||
if (edgeData.conditionExpr) {
|
||||
try {
|
||||
const parsed = JSON.parse(edgeData.conditionExpr)
|
||||
if (Array.isArray(parsed)) {
|
||||
// 引擎格式: [{and: [{field,op,value},...]}, ...]
|
||||
normalizedGroups = parsed.map((item: any) => {
|
||||
if (item.and) {
|
||||
return { logic: 'and' as const, rules: item.and.map((r: any) => ({ field: r.field || '', op: r.op || '==', value: String(r.value ?? '') })) }
|
||||
}
|
||||
if (item.or) {
|
||||
return { logic: 'or' as const, rules: item.or.map((r: any) => ({ field: r.field || '', op: r.op || '==', value: String(r.value ?? '') })) }
|
||||
}
|
||||
// 兼容旧格式: [{rules: [...]}]
|
||||
const rawRules = item.rules || [item]
|
||||
return { logic: 'and' as const, rules: rawRules.map((r: any) => ({ field: r.field || '', op: r.op || '==', value: String(r.value ?? '') })) }
|
||||
})
|
||||
} else if (parsed.rules) {
|
||||
// 旧格式: {rules: [{field,op,value},...]}
|
||||
normalizedGroups = [{ logic: 'and', rules: parsed.rules.map((r: any) => ({ field: r.field || '', op: r.op || '==', value: String(r.value ?? '') })) }]
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (normalizedGroups.length === 0) {
|
||||
normalizedGroups = [{ logic: 'and', rules: [{ field: '', op: '==', value: '' }] }]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 流程变量提示
|
||||
|
||||
在条件字段输入下方添加提示:
|
||||
```html
|
||||
<div class="field-hint">
|
||||
<p>字段名为流程发起时传入的变量名,支持点号嵌套路径(如 <code>order.amount</code>)</p>
|
||||
<p>常见变量:<code>amount</code>(金额) <code>department</code>(部门) <code>applicant.dept_id</code>(发起人部门)</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2.7 CSS 新增
|
||||
```scss
|
||||
.condition-group {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.group-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.group-logic-badge {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
background: #e5e7eb;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.group-logic-hint {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin: 8px 0;
|
||||
padding: 4px 8px;
|
||||
background: #fef3c7;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #1e40af;
|
||||
line-height: 1.6;
|
||||
|
||||
p { margin: 0 0 2px; }
|
||||
code { background: #dbeafe; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 保存/发布前验证
|
||||
|
||||
### 3.1 验证函数
|
||||
```typescript
|
||||
interface ValidationError {
|
||||
type: 'error' | 'warning'
|
||||
message: string
|
||||
nodeId?: string
|
||||
}
|
||||
|
||||
function validateGraph(): ValidationError[] {
|
||||
const errors: ValidationError[] = []
|
||||
if (!graph) return errors
|
||||
|
||||
const cells = graph.getCells()
|
||||
const nodes = cells.filter((c) => c.isNode())
|
||||
const edges = cells.filter((c) => c.isEdge())
|
||||
|
||||
// 1. 必须有且仅有一个开始节点
|
||||
const startNodes = nodes.filter((n) => (n.getData() || {}).nodeType === 'start')
|
||||
if (startNodes.length === 0) {
|
||||
errors.push({ type: 'error', message: '必须有一个开始节点' })
|
||||
} else if (startNodes.length > 1) {
|
||||
errors.push({ type: 'error', message: '只能有一个开始节点', nodeId: startNodes[1].id })
|
||||
}
|
||||
|
||||
// 2. 必须有至少一个结束节点
|
||||
const endNodes = nodes.filter((n) => (n.getData() || {}).nodeType === 'end')
|
||||
if (endNodes.length === 0) {
|
||||
errors.push({ type: 'error', message: '必须至少有一个结束节点' })
|
||||
}
|
||||
|
||||
// 3. 开始节点必须有出边
|
||||
if (startNodes.length === 1) {
|
||||
const hasOutEdge = edges.some((e) => e.getSourceCellId() === startNodes[0].id)
|
||||
if (!hasOutEdge) {
|
||||
errors.push({ type: 'error', message: '开始节点必须连接到下游节点', nodeId: startNodes[0].id })
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 结束节点必须有入边
|
||||
endNodes.forEach((en) => {
|
||||
const hasInEdge = edges.some((e) => e.getTargetCellId() === en.id)
|
||||
if (!hasInEdge) {
|
||||
errors.push({ type: 'error', message: '结束节点必须有入边连接', nodeId: en.id })
|
||||
}
|
||||
})
|
||||
|
||||
// 5. 审批节点必须配置审批人
|
||||
nodes.filter((n) => (n.getData() || {}).nodeType === 'approval').forEach((n) => {
|
||||
const config = (n.getData() || {}).config || {}
|
||||
const strategy = config.strategy || 'users'
|
||||
if (strategy === 'users' && (!config.userIds || config.userIds.length === 0)) {
|
||||
errors.push({ type: 'error', message: `审批节点"${(n.getData() || {}).nodeName || n.id}"未选择审批用户`, nodeId: n.id })
|
||||
}
|
||||
if (strategy === 'roles' && (!config.roleIds || config.roleIds.length === 0)) {
|
||||
errors.push({ type: 'error', message: `审批节点"${(n.getData() || {}).nodeName || n.id}"未选择审批角色`, nodeId: n.id })
|
||||
}
|
||||
if (strategy === 'sql' && (!config.sqlExpr || !config.sqlExpr.trim())) {
|
||||
errors.push({ type: 'error', message: `审批节点"${(n.getData() || {}).nodeName || n.id}"未填写SQL查询`, nodeId: n.id })
|
||||
}
|
||||
})
|
||||
|
||||
// 6. 条件节点必须有至少2条出边
|
||||
nodes.filter((n) => (n.getData() || {}).nodeType === 'condition').forEach((n) => {
|
||||
const outEdges = edges.filter((e) => e.getSourceCellId() === n.id)
|
||||
if (outEdges.length < 2) {
|
||||
errors.push({ type: 'error', message: `条件节点"${(n.getData() || {}).nodeName || n.id}"至少需要2条出边`, nodeId: n.id })
|
||||
}
|
||||
})
|
||||
|
||||
// 7. 孤立节点检查(非开始/结束节点必须有入边和出边)
|
||||
nodes.forEach((n) => {
|
||||
const nodeType = (n.getData() || {}).nodeType
|
||||
if (nodeType === 'start' || nodeType === 'end') return
|
||||
const hasIn = edges.some((e) => e.getTargetCellId() === n.id)
|
||||
const hasOut = edges.some((e) => e.getSourceCellId() === n.id)
|
||||
if (!hasIn || !hasOut) {
|
||||
errors.push({ type: 'warning', message: `节点"${(n.getData() || {}).nodeName || n.id}"未完全连接`, nodeId: n.id })
|
||||
}
|
||||
})
|
||||
|
||||
return errors
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 在 handlePublish 中调用验证
|
||||
```typescript
|
||||
async function handlePublish() {
|
||||
const errors = validateGraph()
|
||||
const blockers = errors.filter((e) => e.type === 'error')
|
||||
if (blockers.length > 0) {
|
||||
ElMessage.error(blockers[0].message)
|
||||
// 高亮错误节点
|
||||
if (blockers[0].nodeId && graph) {
|
||||
const cell = graph.getCellById(blockers[0].nodeId)
|
||||
if (cell && cell.isNode()) {
|
||||
graph.resetSelection(cell)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// ... 原有发布逻辑
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 缩放工具栏 + 默认分支标记
|
||||
|
||||
### 4.1 缩放工具栏
|
||||
在 header 按钮区域添加缩放控制:
|
||||
```html
|
||||
<div class="zoom-controls">
|
||||
<el-button text size="small" @click="handleZoomOut" title="缩小">−</el-button>
|
||||
<span class="zoom-level">{{ zoomLevel }}%</span>
|
||||
<el-button text size="small" @click="handleZoomIn" title="放大">+</el-button>
|
||||
<el-button text size="small" @click="handleZoomReset" title="适应画布">⊞</el-button>
|
||||
</div>
|
||||
```
|
||||
|
||||
```typescript
|
||||
const zoomLevel = ref(100)
|
||||
|
||||
function handleZoomIn() {
|
||||
if (!graph) return
|
||||
const cur = graph.zoom()
|
||||
graph.zoom(0.1)
|
||||
zoomLevel.value = Math.round((cur + 0.1) * 100)
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
if (!graph) return
|
||||
const cur = graph.zoom()
|
||||
graph.zoom(-0.1)
|
||||
zoomLevel.value = Math.round((cur - 0.1) * 100)
|
||||
}
|
||||
|
||||
function handleZoomReset() {
|
||||
if (!graph) return
|
||||
graph.zoomToFit({ padding: 40, maxScale: 1 })
|
||||
zoomLevel.value = Math.round(graph.zoom() * 100)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 默认分支连线标记
|
||||
默认分支的连线标签显示"其他情况":
|
||||
```typescript
|
||||
// syncEdgeCondition 中,默认分支:
|
||||
if (selectedEdge.value.isDefault) {
|
||||
edge.setData({ ...edge.getData(), conditionExpr: '' })
|
||||
edge.setLabels([{
|
||||
attrs: {
|
||||
label: { text: '其他情况', fill: '#9ca3af', fontSize: 11, textAnchor: 'middle', textVerticalAnchor: 'bottom', fontStyle: 'italic' },
|
||||
rect: { fill: '#f9fafb', stroke: '#d1d5db', strokeWidth: 1, rx: 4, ry: 4, strokeDasharray: '3,3' },
|
||||
},
|
||||
position: { distance: 0.5 },
|
||||
}])
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 CSS
|
||||
```scss
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 12px;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
```
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,618 @@
|
||||
# 运营支撑模块 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement 3 operations support modules (洁净室管理, 库位管理, 展馆预约) with backend API + frontend UI, following existing codebase patterns exactly.
|
||||
|
||||
**Architecture:** FastAPI backend with flat-function CRUD (no service layer), Vue 3 + Element Plus frontend with standard list/tree page templates. All 3 modules share a new API route file `app/api/operations.py` and frontend route group.
|
||||
|
||||
**Tech Stack:** Python 3.9 / FastAPI / SQLAlchemy / Pydantic v2 (backend); Vue 3 / TypeScript / Element Plus / Tailwind CSS 4 (frontend)
|
||||
|
||||
**Design Spec:** `docs/superpowers/specs/2026-05-15-operations-support-design.md`
|
||||
|
||||
**Key Reference Files (READ FIRST):**
|
||||
- Backend CRUD pattern: `backend/app/crud/item_unit.py`
|
||||
- Backend API pattern: `backend/app/api/basic_info.py` (lines 1-100 for simple CRUD)
|
||||
- Backend model pattern: `backend/app/models/models.py` (class BizItemUnit, lines 259-275)
|
||||
- Frontend standard list: `web-admin/src/views/system/unit/index.vue`
|
||||
- Frontend tree template: `web-admin/src/views/system/category/index.vue`
|
||||
- Frontend API pattern: `web-admin/src/api/admin.ts` (any CRUD section)
|
||||
- Frontend router: `web-admin/src/router/modules/sqxp.ts`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend (create/modify)
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `backend/app/models/models.py` | MODIFY | Add 5 model classes at end of file |
|
||||
| `backend/app/crud/clean_room.py` | CREATE | CleanRoom + AccessLog CRUD functions |
|
||||
| `backend/app/crud/storage_facility.py` | CREATE | StorageFacility tree + Slot CRUD functions |
|
||||
| `backend/app/crud/exhibition_appointment.py` | CREATE | ExhibitionAppointment CRUD functions |
|
||||
| `backend/app/api/operations.py` | CREATE | All 3 modules API routes |
|
||||
| `backend/app/api/__init__.py` | MODIFY | Import + export operations_router |
|
||||
| `backend/app/main.py` | MODIFY | Import + register operations_router |
|
||||
| `backend/tests/test_operations_api.py` | CREATE | Backend API tests |
|
||||
|
||||
### Frontend (create/modify)
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `web-admin/src/api/admin.ts` | MODIFY | Add API functions for all 3 modules |
|
||||
| `web-admin/src/router/modules/sqxp.ts` | MODIFY | Add 运营支撑 route group |
|
||||
| `web-admin/src/views/operations/clean-room/index.vue` | CREATE | Clean room list page |
|
||||
| `web-admin/src/views/operations/storage-facility/index.vue` | CREATE | Storage facility tree page |
|
||||
| `web-admin/src/views/operations/exhibition-appointment/index.vue` | CREATE | Exhibition appointment list page |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend Models (5 SQLAlchemy models)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/models.py` (append at end, before EOF)
|
||||
|
||||
- [ ] **Step 1: Add 5 model classes to models.py**
|
||||
|
||||
Append the following after the last class in `backend/app/models/models.py`:
|
||||
|
||||
```python
|
||||
# =============================================================================
|
||||
# V0.4 运营支撑(2.4 洁净室管理 / 库位管理 / 展馆预约)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BizCleanRoom(Base):
|
||||
"""洁净室信息 biz_clean_room"""
|
||||
|
||||
__tablename__ = "biz_clean_room"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
room_code = Column(String(64), nullable=False, comment="编号")
|
||||
room_name = Column(String(200), nullable=False, comment="名称")
|
||||
room_grade = Column(String(8), nullable=True, comment="等级 A/B/C/D")
|
||||
room_status = Column(String(16), nullable=False, default="zc", comment="zc正常 yc异常")
|
||||
area_sqm = Column(Numeric(12, 2), nullable=True, comment="面积m²")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCleanRoomAccessLog(Base):
|
||||
"""洁净室人员进出 biz_clean_room_access_log"""
|
||||
|
||||
__tablename__ = "biz_clean_room_access_log"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
clean_room_id = Column(String(64), nullable=False, comment="洁净室ID")
|
||||
user_id = Column(String(64), nullable=True, comment="人员用户ID")
|
||||
person_name = Column(String(100), nullable=True, comment="人员姓名(冗余)")
|
||||
dept_name = Column(String(200), nullable=True, comment="部门")
|
||||
enter_time = Column(DateTime, nullable=False, comment="进入时间")
|
||||
purpose = Column(String(512), nullable=True, comment="目的")
|
||||
leave_time = Column(DateTime, nullable=True, comment="离开时间")
|
||||
body_temp = Column(String(32), nullable=True, comment="体温")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizStorageFacility(Base):
|
||||
"""细胞存储设施树节点 biz_storage_facility"""
|
||||
|
||||
__tablename__ = "biz_storage_facility"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
parent_id = Column(String(64), nullable=False, default="0", comment="父节点ID,0根")
|
||||
node_name = Column(String(200), nullable=False, comment="节点名称(罐/层/盒等)")
|
||||
node_type = Column(String(32), nullable=True, comment="facility/layer/box 等扩展")
|
||||
is_leaf = Column(SmallInteger, nullable=False, default=0, comment="是否叶子存储位 1是")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizStorageFacilitySlot(Base):
|
||||
"""细胞存储设施储存位置(格子)biz_storage_facility_slot"""
|
||||
|
||||
__tablename__ = "biz_storage_facility_slot"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
facility_node_id = Column(String(64), nullable=False, comment="设施树叶子节点ID")
|
||||
slot_code = Column(String(64), nullable=True, comment="小格编码/坐标")
|
||||
slot_status = Column(String(16), nullable=True, comment="占用状态等扩展")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizExhibitionAppointment(Base):
|
||||
"""展馆预约 biz_exhibition_appointment"""
|
||||
|
||||
__tablename__ = "biz_exhibition_appointment"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
appointment_no = Column(String(64), nullable=False, comment="预约编号")
|
||||
visit_org = Column(String(200), nullable=True, comment="来访单位")
|
||||
contact_name = Column(String(100), nullable=True, comment="联系人")
|
||||
visit_date = Column(CHAR(8), nullable=True, comment="到访日期YYYYMMDD")
|
||||
visitor_count = Column(Integer, nullable=True, comment="人数")
|
||||
reception_user_id = Column(String(64), nullable=True, comment="接待人员用户ID")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify models load without errors**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.models.models import BizCleanRoom, BizCleanRoomAccessLog, BizStorageFacility, BizStorageFacilitySlot, BizExhibitionAppointment; print('All models imported OK')"`
|
||||
Expected: "All models imported OK"
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Backend CRUD — Exhibition Appointment (simplest, start here)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/crud/exhibition_appointment.py`
|
||||
|
||||
- [ ] **Step 1: Create CRUD file**
|
||||
|
||||
Create `backend/app/crud/exhibition_appointment.py` following the `item_unit.py` pattern exactly. Functions: `_row_to_dict`, `list_exhibition_appointments`, `get_exhibition_appointment`, `appointment_no_exists`, `create_exhibition_appointment`, `update_exhibition_appointment`, `soft_delete_exhibition_appointment`, `total_pages`. All queries filter by `del_flag==0` and `tenant_no`. List supports keyword search on `appointment_no`, `visit_org`, `contact_name`. Default sort by `create_time DESC`.
|
||||
|
||||
- [ ] **Step 2: Verify import**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud import exhibition_appointment; print('OK')"`
|
||||
Expected: "OK"
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Backend CRUD — Clean Room + Access Log
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/crud/clean_room.py`
|
||||
|
||||
- [ ] **Step 1: Create CRUD file**
|
||||
|
||||
Create `backend/app/crud/clean_room.py` with two sets of CRUD functions:
|
||||
|
||||
**Clean Room functions:** `_row_to_dict`, `list_clean_rooms`, `get_clean_room`, `room_code_exists`, `create_clean_room`, `update_clean_room`, `soft_delete_clean_room`, `total_pages`. Keyword search on `room_code`, `room_name`. Filter by `tenant_no` and `del_flag==0`.
|
||||
|
||||
**Access Log functions:** `_access_log_to_dict`, `list_access_logs` (filter by `clean_room_id`), `get_access_log`, `create_access_log`, `update_access_log`, `soft_delete_access_log`. List supports `clean_room_id` filter + keyword on `person_name`, `dept_name`.
|
||||
|
||||
- [ ] **Step 2: Verify import**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud import clean_room; print('OK')"`
|
||||
Expected: "OK"
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Backend CRUD — Storage Facility (tree) + Slot
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/crud/storage_facility.py`
|
||||
|
||||
- [ ] **Step 1: Create CRUD file**
|
||||
|
||||
Create `backend/app/crud/storage_facility.py` with:
|
||||
|
||||
**Facility tree functions:** `_row_to_dict`, `list_all_facilities` (returns flat list, filtered by `tenant_no`, `del_flag==0`), `get_facility`, `create_facility`, `update_facility`, `soft_delete_facility` (cascade: soft-delete all children + their slots), `get_children` (find direct children of a node).
|
||||
|
||||
**Slot functions:** `_slot_to_dict`, `list_slots` (filter by `facility_node_id`), `get_slot`, `create_slot`, `update_slot`, `soft_delete_slot`.
|
||||
|
||||
**Tree helpers:** `build_tree` — converts flat list to nested tree structure with `children` array. `collect_descendant_ids` — recursively collects all descendant node IDs for cascade delete.
|
||||
|
||||
- [ ] **Step 2: Verify import**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud import storage_facility; print('OK')"`
|
||||
Expected: "OK"
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Backend API Routes — operations.py
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/api/operations.py`
|
||||
|
||||
- [ ] **Step 1: Create the API route file**
|
||||
|
||||
Create `backend/app/api/operations.py` with `router = APIRouter(prefix="/web/admin", tags=["运营支撑"])`. Include:
|
||||
|
||||
**Clean Room endpoints:**
|
||||
- `GET /clean-rooms` — paginated list with keyword
|
||||
- `POST /clean-rooms` — create
|
||||
- `PUT /clean-rooms/{room_id}` — update
|
||||
- `DELETE /clean-rooms/{room_id}` — soft delete
|
||||
|
||||
**Access Log endpoints:**
|
||||
- `GET /clean-room-access-logs` — list by clean_room_id, paginated
|
||||
- `POST /clean-room-access-logs` — create
|
||||
- `PUT /clean-room-access-logs/{log_id}` — update
|
||||
- `DELETE /clean-room-access-logs/{log_id}` — soft delete
|
||||
|
||||
**Storage Facility endpoints:**
|
||||
- `GET /storage-facilities` — flat list (frontend builds tree)
|
||||
- `POST /storage-facilities` — create node
|
||||
- `PUT /storage-facilities/{node_id}` — update node
|
||||
- `DELETE /storage-facilities/{node_id}` — cascade soft delete
|
||||
|
||||
**Storage Slot endpoints:**
|
||||
- `GET /storage-facility-slots` — list by facility_node_id
|
||||
- `POST /storage-facility-slots` — create
|
||||
- `PUT /storage-facility-slots/{slot_id}` — update
|
||||
- `DELETE /storage-facility-slots/{slot_id}` — soft delete
|
||||
|
||||
**Exhibition Appointment endpoints:**
|
||||
- `GET /exhibition-appointments` — paginated list with keyword
|
||||
- `POST /exhibition-appointments` — create
|
||||
- `PUT /exhibition-appointments/{appointment_id}` — update
|
||||
- `DELETE /exhibition-appointments/{appointment_id}` — soft delete
|
||||
|
||||
All endpoints use `Depends(require_admin_or_super_admin)` and `_get_tenant_no(db, user)`.
|
||||
|
||||
Each endpoint follows the pattern from `basic_info.py`: Pydantic body classes inline with `ConfigDict(populate_by_name=True)` and `Field(..., alias="camelCase")`. List endpoints return `success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})`.
|
||||
|
||||
- [ ] **Step 2: Verify import**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.api.operations import router; print(f'Routes: {len(router.routes)}')"`
|
||||
Expected: "Routes: 13"
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Backend Router Registration
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/__init__.py`
|
||||
- Modify: `backend/app/main.py`
|
||||
|
||||
- [ ] **Step 1: Register in __init__.py**
|
||||
|
||||
Add to `backend/app/api/__init__.py`:
|
||||
```python
|
||||
from app.api.operations import router as operations_router
|
||||
```
|
||||
And add `"operations_router"` to `__all__`.
|
||||
|
||||
- [ ] **Step 2: Register in main.py**
|
||||
|
||||
Add to `backend/app/main.py` line 13 import section:
|
||||
```python
|
||||
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router
|
||||
```
|
||||
Add after line 67:
|
||||
```python
|
||||
app.include_router(operations_router, prefix=settings.API_V1_PREFIX)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify app starts**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.main import app; print(f'App routes: {len(app.routes)}')"`
|
||||
Expected: A number > previous route count
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Backend Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/tests/test_operations_api.py`
|
||||
|
||||
- [ ] **Step 1: Write test file**
|
||||
|
||||
Create `backend/tests/test_operations_api.py` with tests for each module:
|
||||
|
||||
1. **Clean Room tests:** test_create_clean_room, test_list_clean_rooms, test_update_clean_room, test_delete_clean_room
|
||||
2. **Access Log tests:** test_create_access_log, test_list_access_logs_by_room
|
||||
3. **Storage Facility tests:** test_create_facility_node, test_list_facilities_flat, test_create_child_node, test_cascade_delete
|
||||
4. **Slot tests:** test_create_slot, test_list_slots_by_node
|
||||
5. **Exhibition Appointment tests:** test_create_appointment, test_list_appointments, test_update_appointment, test_delete_appointment
|
||||
|
||||
Use `pytest` with FastAPI `TestClient`. Each test creates, reads, updates, deletes and verifies. Tests can use the running dev server or TestClient.
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -m pytest tests/test_operations_api.py -v`
|
||||
Expected: All tests PASS
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Frontend API Functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/admin.ts`
|
||||
|
||||
- [ ] **Step 1: Add API functions for all 3 modules**
|
||||
|
||||
Append to `web-admin/src/api/admin.ts` three new section blocks:
|
||||
|
||||
**Clean Room section:**
|
||||
```typescript
|
||||
// ---------------------------------------------------------------------------
|
||||
// 洁净室管理
|
||||
// ---------------------------------------------------------------------------
|
||||
export function fetchCleanRoomList(params?: { page?: number; pageSize?: number; keyword?: string }) {
|
||||
return request.get({ url: '/web/admin/clean-rooms', params })
|
||||
}
|
||||
export function fetchCreateCleanRoom(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/clean-rooms', params: data })
|
||||
}
|
||||
export function fetchUpdateCleanRoom(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/clean-rooms/${id}`, params: data })
|
||||
}
|
||||
export function fetchDeleteCleanRoom(id: string) {
|
||||
return request.del({ url: `/web/admin/clean-rooms/${id}` })
|
||||
}
|
||||
// 人员进出
|
||||
export function fetchAccessLogList(params?: { page?: number; pageSize?: number; cleanRoomId?: string; keyword?: string }) {
|
||||
return request.get({ url: '/web/admin/clean-room-access-logs', params })
|
||||
}
|
||||
export function fetchCreateAccessLog(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/clean-room-access-logs', params: data })
|
||||
}
|
||||
export function fetchUpdateAccessLog(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/clean-room-access-logs/${id}`, params: data })
|
||||
}
|
||||
export function fetchDeleteAccessLog(id: string) {
|
||||
return request.del({ url: `/web/admin/clean-room-access-logs/${id}` })
|
||||
}
|
||||
```
|
||||
|
||||
**Storage Facility section:**
|
||||
```typescript
|
||||
// ---------------------------------------------------------------------------
|
||||
// 库位管理
|
||||
// ---------------------------------------------------------------------------
|
||||
export function fetchStorageFacilityList(params?: { keyword?: string }) {
|
||||
return request.get({ url: '/web/admin/storage-facilities', params })
|
||||
}
|
||||
export function fetchCreateStorageFacility(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/storage-facilities', params: data })
|
||||
}
|
||||
export function fetchUpdateStorageFacility(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/storage-facilities/${id}`, params: data })
|
||||
}
|
||||
export function fetchDeleteStorageFacility(id: string) {
|
||||
return request.del({ url: `/web/admin/storage-facilities/${id}` })
|
||||
}
|
||||
// 格子
|
||||
export function fetchStorageSlotList(params?: { facilityNodeId?: string }) {
|
||||
return request.get({ url: '/web/admin/storage-facility-slots', params })
|
||||
}
|
||||
export function fetchCreateStorageSlot(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/storage-facility-slots', params: data })
|
||||
}
|
||||
export function fetchUpdateStorageSlot(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/storage-facility-slots/${id}`, params: data })
|
||||
}
|
||||
export function fetchDeleteStorageSlot(id: string) {
|
||||
return request.del({ url: `/web/admin/storage-facility-slots/${id}` })
|
||||
}
|
||||
```
|
||||
|
||||
**Exhibition Appointment section:**
|
||||
```typescript
|
||||
// ---------------------------------------------------------------------------
|
||||
// 展馆预约
|
||||
// ---------------------------------------------------------------------------
|
||||
export function fetchExhibitionAppointmentList(params?: { page?: number; pageSize?: number; keyword?: string }) {
|
||||
return request.get({ url: '/web/admin/exhibition-appointments', params })
|
||||
}
|
||||
export function fetchCreateExhibitionAppointment(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/exhibition-appointments', params: data })
|
||||
}
|
||||
export function fetchUpdateExhibitionAppointment(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/exhibition-appointments/${id}`, params: data })
|
||||
}
|
||||
export function fetchDeleteExhibitionAppointment(id: string) {
|
||||
return request.del({ url: `/web/admin/exhibition-appointments/${id}` })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify no TypeScript errors**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm build:typecheck 2>&1 | tail -5`
|
||||
Expected: No errors related to new functions
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Frontend Router — Add 运营支撑 Group
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/router/modules/sqxp.ts`
|
||||
|
||||
- [ ] **Step 1: Add route group**
|
||||
|
||||
Add before the closing `]` of `sqxpAdminRoutes` (before the last `}` or after the Workflow section):
|
||||
|
||||
```typescript
|
||||
,
|
||||
{
|
||||
id: '60000000000000000000000000000001',
|
||||
name: 'Operations',
|
||||
path: '/operations',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '运营支撑',
|
||||
icon: 'operation',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '60000000000000000000000000000002',
|
||||
name: 'CleanRoom',
|
||||
path: 'clean-room',
|
||||
component: '/operations/clean-room/index',
|
||||
meta: { title: '洁净室管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '60000000000000000000000000000003',
|
||||
name: 'StorageFacility',
|
||||
path: 'storage-facility',
|
||||
component: '/operations/storage-facility/index',
|
||||
meta: { title: '库位管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '60000000000000000000000000000004',
|
||||
name: 'ExhibitionAppointment',
|
||||
path: 'exhibition-appointment',
|
||||
component: '/operations/exhibition-appointment/index',
|
||||
meta: { title: '展馆预约', keepAlive: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify routes compile**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm build:typecheck 2>&1 | tail -5`
|
||||
Expected: No route-related errors
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Frontend — Clean Room Page
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/operations/clean-room/index.vue`
|
||||
|
||||
- [ ] **Step 1: Create the Vue page**
|
||||
|
||||
Create `web-admin/src/views/operations/clean-room/index.vue` following the `system/unit/index.vue` template exactly:
|
||||
|
||||
**Page structure:**
|
||||
- `page-container` → `header.page-head` → `section.panel`
|
||||
- Filter: keyword search
|
||||
- Table columns: 编号(roomCode), 名称(roomName), 等级(roomGrade, tag display: A级绿色/B级蓝色/C级黄色/D级灰色), 状态(roomStatus, tag: 正常=success/异常=danger), 面积(areaSqm), 操作
|
||||
- Toolbar: 新增按钮, 批量删除按钮
|
||||
- Dialog form (label-position="top", form-grid-2):
|
||||
- 编号(roomCode, required), 名称(roomName, required), 等级(roomGrade, select: A/B/C/D), 状态(roomStatus, select: 正常zc/异常yc), 面积(areaSqm, number input, suffix "m²"), 备注(remark)
|
||||
- 操作列: 查看, 编辑, 人员进出, 删除
|
||||
- "人员进出"按钮 → 打开新弹窗显示该洁净室的进出记录列表(带分页), 弹窗内支持新增/编辑/删除进出记录
|
||||
- 进出记录表单: 人员姓名(personName), 部门(deptName), 进入时间(enterTime, datetime), 目的(purpose), 离开时间(leaveTime, datetime), 体温(bodyTemp), 备注(remark)
|
||||
- View dialog using SystemViewDialog component
|
||||
- API calls: `fetchCleanRoomList`, `fetchCreateCleanRoom`, `fetchUpdateCleanRoom`, `fetchDeleteCleanRoom`, `fetchAccessLogList`, `fetchCreateAccessLog`, `fetchUpdateAccessLog`, `fetchDeleteAccessLog`
|
||||
|
||||
- [ ] **Step 2: Verify page renders**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm dev`, then navigate to `/#/operations/clean-room`
|
||||
Expected: Page loads without console errors, shows empty list with filter bar and toolbar
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Frontend — Storage Facility Page (Tree)
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/operations/storage-facility/index.vue`
|
||||
|
||||
- [ ] **Step 1: Create the Vue page**
|
||||
|
||||
Create `web-admin/src/views/operations/storage-facility/index.vue` following the `system/category/index.vue` tree template:
|
||||
|
||||
**Page structure:**
|
||||
- Left panel: tree structure display using `el-table` with `row-key="id"` and `:tree-props="{ children: 'children' }"`
|
||||
- Data fetched as flat list via `fetchStorageFacilityList`, then `buildTree()` converts to nested tree
|
||||
- Tree columns: 节点名称(nodeName), 节点类型(nodeType, display as tag: facility=设施/layer=层/box=盒), 是否叶子(isLeaf, icon or tag), 备注(remark)
|
||||
- Toolbar: 新增根节点, 展开全部, 折叠全部
|
||||
- 操作列: 新增子节点, 编辑, 删除(需确认)
|
||||
- Right panel: when a leaf node (`isLeaf===1`) is selected, show its slots (格子) list
|
||||
- Slot table: 编码(slotCode), 状态(slotStatus), 操作(编辑/删除)
|
||||
- Add slot button
|
||||
- Dialog form for facility node (label-position="top", form-grid-2):
|
||||
- 父节点(parentId, select from tree list, show full path via `buildTreePathLabelFromFlat`), 节点名称(nodeName, required), 节点类型(nodeType, select: facility/layer/box), 是否叶子(isLeaf, switch), 备注(remark)
|
||||
- Dialog form for slot:
|
||||
- 编码(slotCode, required), 状态(slotStatus, select), 备注(remark)
|
||||
- Tree utilities: import `buildTreePathLabelFromFlat` from `@/utils/tree-path`
|
||||
- API calls: `fetchStorageFacilityList`, `fetchCreateStorageFacility`, `fetchUpdateStorageFacility`, `fetchDeleteStorageFacility`, `fetchStorageSlotList`, `fetchCreateStorageSlot`, `fetchUpdateStorageSlot`, `fetchDeleteStorageSlot`
|
||||
|
||||
- [ ] **Step 2: Verify page renders**
|
||||
|
||||
Navigate to `/#/operations/storage-facility`
|
||||
Expected: Page loads, shows empty tree with toolbar
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Frontend — Exhibition Appointment Page
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/operations/exhibition-appointment/index.vue`
|
||||
|
||||
- [ ] **Step 1: Create the Vue page**
|
||||
|
||||
Create `web-admin/src/views/operations/exhibition-appointment/index.vue` following the `system/unit/index.vue` standard list template:
|
||||
|
||||
**Page structure:**
|
||||
- Filter: keyword search (搜索预约编号/来访单位/联系人)
|
||||
- Table columns: 预约编号(appointmentNo), 来访单位(visitOrg), 联系人(contactName), 到访日期(visitDate), 人数(visitorCount), 接待人员(receptionUserId → display name), 操作
|
||||
- Toolbar: 新增按钮, 批量删除按钮
|
||||
- Dialog form (label-position="top", form-grid-2):
|
||||
- 预约编号(appointmentNo, required), 来访单位(visitOrg, required), 联系人(contactName), 到访日期(visitDate, date picker), 人数(visitorCount, number input), 接待人员(receptionUserId, select from user options), 备注(remark)
|
||||
- View dialog using SystemViewDialog component
|
||||
- API calls: `fetchExhibitionAppointmentList`, `fetchCreateExhibitionAppointment`, `fetchUpdateExhibitionAppointment`, `fetchDeleteExhibitionAppointment`
|
||||
|
||||
- [ ] **Step 2: Verify page renders**
|
||||
|
||||
Navigate to `/#/operations/exhibition-appointment`
|
||||
Expected: Page loads without errors, shows empty list
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Integration Test & Verification
|
||||
|
||||
- [ ] **Step 1: Start backend and verify all API endpoints**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -m pytest tests/test_operations_api.py -v`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 2: Start frontend and verify all 3 pages**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm dev`
|
||||
Navigate to each page:
|
||||
- `/#/operations/clean-room` — verify list, create dialog, access log dialog
|
||||
- `/#/operations/storage-facility` — verify tree display, node CRUD, slot CRUD
|
||||
- `/#/operations/exhibition-appointment` — verify list, create/edit dialog
|
||||
|
||||
- [ ] **Step 3: Run frontend typecheck**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm build:typecheck`
|
||||
Expected: No TypeScript errors
|
||||
|
||||
- [ ] **Step 4: Run frontend lint**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm lint`
|
||||
Expected: No lint errors (or only pre-existing ones)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
1. **Spec coverage**: All 5 tables have models ✓, all 3 modules have CRUD ✓, API ✓, frontend ✓
|
||||
2. **Placeholder scan**: No TBD/TODO, all steps have actual code or descriptions ✓
|
||||
3. **Type consistency**: Model field names match CRUD `_row_to_dict` keys match API aliases ✓
|
||||
4. **Convention compliance**: tenant_no filter ✓, soft delete ✓, camelCase API ✓, Fresh Mint colors ✓
|
||||
@@ -0,0 +1,924 @@
|
||||
# 库位管理页面改造 — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将库位管理页面从树形表格+右侧窄面板改为左右布局(左侧 el-tree 树形列表 + 右侧货架行格子卡片),并新增批量生成格子功能。
|
||||
|
||||
**Architecture:** 前端完全重写 `index.vue`,参考 `user/index.vue` 的 el-tree 侧边栏模式。后端新增一个批量创建格子的 API 端点(rows×cols 参数 → 一次事务创建多个 slot)。前端格子区域使用 CSS flex 按行分组展示,每行左侧有行标(A/B/C),格子内编号+状态在第一行,下方预留空间。
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy(后端),Vue 3 + Element Plus + SCSS(前端)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|---------------|
|
||||
| `backend/app/crud/storage_facility.py` | Modify | 新增 `batch_create_slots()` 函数 |
|
||||
| `backend/app/api/operations.py` | Modify | 新增 `POST /storage-facility-slots/batch` 端点 |
|
||||
| `web-admin/src/api/admin.ts` | Modify | 新增 `fetchBatchCreateSlots()` 函数 |
|
||||
| `web-admin/src/views/operations/storage-facility/index.vue` | Rewrite | 完全重写为 el-tree + 货架行布局 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 — 批量创建格子 CRUD 函数
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/storage_facility.py` (在文件末尾 `soft_delete_slot` 之后追加)
|
||||
|
||||
- [ ] **Step 1: 在 `storage_facility.py` 末尾添加 `batch_create_slots` 函数**
|
||||
|
||||
在 `backend/app/crud/storage_facility.py` 文件末尾追加以下函数(在 `soft_delete_slot` 函数之后):
|
||||
|
||||
```python
|
||||
def batch_create_slots(
|
||||
db: Session,
|
||||
*,
|
||||
facility_node_id: str,
|
||||
rows: int,
|
||||
cols: int,
|
||||
code_prefix: str = "",
|
||||
tenant_no: str = "zkxw",
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Batch create rows×cols slots for a leaf facility node in a single transaction."""
|
||||
fid = (facility_node_id or "").strip()
|
||||
if not fid:
|
||||
raise ValueError("所属设施节点不能为空")
|
||||
if rows < 1 or cols < 1:
|
||||
raise ValueError("行数和列数必须大于 0")
|
||||
if rows > 50 or cols > 50:
|
||||
raise ValueError("单次最多生成 50×50 = 2500 个格子")
|
||||
|
||||
node = db.query(BizStorageFacility).filter(
|
||||
and_(BizStorageFacility.id == fid, BizStorageFacility.del_flag == 0)
|
||||
).first()
|
||||
if not node:
|
||||
raise ValueError("所属设施节点不存在")
|
||||
if not node.is_leaf:
|
||||
raise ValueError("只能为叶子节点创建格子")
|
||||
|
||||
prefix = (code_prefix or "").strip()
|
||||
now = datetime.now()
|
||||
slots: List[BizStorageFacilitySlot] = []
|
||||
|
||||
for r in range(1, rows + 1):
|
||||
for c in range(1, cols + 1):
|
||||
code = f"{prefix}{r}-{c}" if prefix else f"{r}-{c}"
|
||||
order = (r - 1) * cols + c
|
||||
obj = BizStorageFacilitySlot(
|
||||
id=generate_uuid(),
|
||||
facility_node_id=fid,
|
||||
slot_code=code,
|
||||
slot_status="空",
|
||||
tenant_no=tenant_no,
|
||||
order_num=order,
|
||||
remark=None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
slots.append(obj)
|
||||
|
||||
db.commit()
|
||||
for s in slots:
|
||||
db.refresh(s)
|
||||
return [_slot_to_dict(s) for s in slots]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证语法无误**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud.storage_facility import batch_create_slots; print('OK')"`
|
||||
Expected: `OK`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端 — 批量创建格子 API 端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/operations.py` (在 `StorageSlotCreateBody` 类之后、`list_storage_slots` 端点之前插入)
|
||||
|
||||
- [ ] **Step 1: 添加 Pydantic body model 和 API 端点**
|
||||
|
||||
在 `backend/app/api/operations.py` 中,在 `StorageSlotCreateBody` 类定义之后(约第 377 行之后),添加 body model:
|
||||
|
||||
```python
|
||||
class StorageSlotBatchCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
facility_node_id: str = Field(..., alias="facilityNodeId")
|
||||
rows: int = Field(..., ge=1, le=50)
|
||||
cols: int = Field(..., ge=1, le=50)
|
||||
code_prefix: Optional[str] = Field("", alias="codePrefix")
|
||||
```
|
||||
|
||||
然后在 `@router.post("/storage-facility-slots")` 端点之前(`StorageSlotBatchCreateBody` 定义之后),添加批量创建端点:
|
||||
|
||||
```python
|
||||
@router.post("/storage-facility-slots/batch")
|
||||
async def batch_create_storage_slots(
|
||||
body: StorageSlotBatchCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = storage_facility_svc.batch_create_slots(
|
||||
db,
|
||||
facility_node_id=body.facility_node_id,
|
||||
rows=body.rows,
|
||||
cols=body.cols,
|
||||
code_prefix=body.code_prefix,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(items, message=f"批量创建 {len(items)} 个格子成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
```
|
||||
|
||||
**重要**:这个端点必须放在 `/storage-facility-slots` 路由之前,否则 FastAPI 会把 `batch` 当做 `{slot_id}` 参数匹配。
|
||||
|
||||
- [ ] **Step 2: 验证语法无误**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.api.operations import router; print('OK')"`
|
||||
Expected: `OK`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 前端 — 添加批量创建 API 函数
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/admin.ts` (在 `fetchCreateStorageSlot` 函数之后)
|
||||
|
||||
- [ ] **Step 1: 在 `admin.ts` 中 `fetchCreateStorageSlot` 之后添加批量创建函数**
|
||||
|
||||
在 `web-admin/src/api/admin.ts` 中,在 `fetchCreateStorageSlot` 函数(第 1336-1338 行)之后插入:
|
||||
|
||||
```typescript
|
||||
export function fetchBatchCreateSlots(data: { facilityNodeId: string; rows: number; cols: number; codePrefix?: string }) {
|
||||
return request.post({ url: '/web/admin/storage-facility-slots/batch', params: data })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 lint 通过**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && npx eslint src/api/admin.ts --no-error-on-unmatched-pattern 2>&1 | tail -5`
|
||||
Expected: 无错误输出
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 — 重写库位管理页面
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `web-admin/src/views/operations/storage-facility/index.vue`
|
||||
- Reference: `web-admin/src/views/system/user/index.vue` (el-tree 侧边栏模式)
|
||||
- Reference: `web-admin/storage-redesign-prototype.html` (视觉原型)
|
||||
|
||||
这是最大的任务。完全重写页面,从树形表格+窄面板 → el-tree 侧边栏 + 货架行格子布局。
|
||||
|
||||
- [ ] **Step 1: 重写整个 index.vue**
|
||||
|
||||
用以下完整内容替换 `web-admin/src/views/operations/storage-facility/index.vue` 的全部内容。
|
||||
|
||||
**关键设计决策:**
|
||||
|
||||
1. **左侧 el-tree** — 参考 `user/index.vue` 的 `w-[280px] shrink-0 border-r` 布局。使用 `el-tree` 组件 + `buildTree()` 构建树形数据。树节点通过 `@node-click` 触发右侧面板更新。
|
||||
|
||||
2. **右侧货架行格子** — 使用 flex 布局,按行分组。每行由行标 `.shelf-label` + 格子容器 `.shelf-cells` 组成。每个格子 `.shelf-cell` 内编号+状态在顶部一行,下方预留空间。
|
||||
|
||||
3. **批量生成** — 点击"批量生成格子"弹出 dialog,输入行数、列数、编码前缀。确认后调用 `fetchBatchCreateSlots`。
|
||||
|
||||
4. **格子编辑** — 点击格子弹出编辑 dialog,修改编码和状态。
|
||||
|
||||
5. **节点 CRUD** — el-tree 节点 hover 显示操作按钮(新增子节点、编辑、删除),点击弹出 dialog。
|
||||
|
||||
**完整代码如下:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>库位管理</h1>
|
||||
<p class="page-desc">维护库位设施树结构,以及叶子节点的格子管理。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="flex gap-4">
|
||||
<div class="w-[280px] shrink-0 border-r border-[var(--mint-border)] pr-4">
|
||||
<div class="panel-top sf-tree-aside__head">
|
||||
<h2 class="title-bar-heading">库位树</h2>
|
||||
<el-button class="btn-main" type="primary" size="small" @click="handleAddRoot">新增</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="treeKeyword"
|
||||
placeholder="搜索节点"
|
||||
clearable
|
||||
class="sf-tree-search"
|
||||
/>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="treeData"
|
||||
:props="{ label: 'nodeName', children: 'children' }"
|
||||
node-key="id"
|
||||
highlight-current
|
||||
default-expand-all
|
||||
:filter-node-method="filterNode"
|
||||
@node-click="handleNodeClick"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<div class="sf-tree-node" @mouseenter="hoveredNodeId = data.id" @mouseleave="hoveredNodeId = ''">
|
||||
<span class="sf-tree-node__label">{{ data.nodeName }}</span>
|
||||
<span v-if="data.isLeaf === 1" class="sf-tree-node__leaf">叶</span>
|
||||
<div v-if="hoveredNodeId === data.id" class="sf-tree-node__ops" @click.stop>
|
||||
<el-button v-if="data.isLeaf !== 1" link size="small" @click="handleAddChild(data)">+</el-button>
|
||||
<el-button link size="small" @click="handleEditNode(data)">编辑</el-button>
|
||||
<el-button link size="small" type="danger" @click="handleDeleteNode(data)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-if="!selectedNode" class="sf-empty">
|
||||
<el-empty description="请在左侧选择一个叶子节点查看格子" />
|
||||
</div>
|
||||
|
||||
<template v-if="selectedNode && selectedNode.isLeaf !== 1">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">{{ selectedNode.nodeName }}</h2>
|
||||
</div>
|
||||
<el-empty description="非叶子节点,请继续选择子节点" />
|
||||
</template>
|
||||
|
||||
<template v-if="selectedNode && selectedNode.isLeaf === 1">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">
|
||||
{{ buildTreePathLabelFromFlat(selectedNode.id, flatList, { nameKey: 'nodeName' }) }} — 格子管理
|
||||
</h2>
|
||||
<span class="sf-count">共 {{ slotList.length }} 个格子</span>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<el-button class="btn-main" type="primary" @click="handleBatchCreate">批量生成格子</el-button>
|
||||
<el-button
|
||||
v-if="slotList.length > 0"
|
||||
class="btn-ghost"
|
||||
@click="handleClearAllSlots"
|
||||
>
|
||||
清空格子
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="slotList.length === 0" class="sf-empty">
|
||||
<el-empty description="暂无格子,点击「批量生成格子」创建" />
|
||||
</div>
|
||||
|
||||
<div v-else class="sf-shelf-container">
|
||||
<div v-for="(row, ri) in shelfRows" :key="ri" class="sf-shelf-row">
|
||||
<div class="sf-shelf-label">{{ row.label }}</div>
|
||||
<div class="sf-shelf-cells">
|
||||
<div
|
||||
v-for="cell in row.cells"
|
||||
:key="cell.id"
|
||||
class="sf-shelf-cell"
|
||||
:class="{ 'is-occupied': cell.slotStatus === '占用' }"
|
||||
@click="handleEditSlot(cell)"
|
||||
>
|
||||
<div class="sf-cell-top">
|
||||
<span class="sf-cell-code">{{ cell.slotCode }}</span>
|
||||
<span
|
||||
class="sf-cell-status"
|
||||
:class="cell.slotStatus === '占用' ? 'is-occupied' : 'is-empty'"
|
||||
>
|
||||
{{ cell.slotStatus || '空' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="sf-cell-body">
|
||||
<span class="sf-cell-reserved">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="nodeDialogVisible"
|
||||
:title="nodeDialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@close="handleNodeDialogClose"
|
||||
>
|
||||
<el-form
|
||||
ref="nodeFormRef"
|
||||
:model="nodeFormData"
|
||||
:rules="nodeFormRules"
|
||||
label-position="top"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="上级节点">
|
||||
<el-select
|
||||
v-model="nodeFormData.parentId"
|
||||
placeholder="请选择上级节点(空为根节点)"
|
||||
clearable
|
||||
filterable
|
||||
>
|
||||
<el-option
|
||||
v-for="item in flatList"
|
||||
:key="item.id"
|
||||
:label="buildTreePathLabelFromFlat(item.id, flatList, { nameKey: 'nodeName' })"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="节点名称" prop="nodeName">
|
||||
<el-input v-model="nodeFormData.nodeName" placeholder="请输入节点名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="节点类型">
|
||||
<el-select v-model="nodeFormData.nodeType" placeholder="请选择节点类型">
|
||||
<el-option label="设施" value="facility" />
|
||||
<el-option label="层" value="layer" />
|
||||
<el-option label="盒" value="box" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否叶子">
|
||||
<el-switch v-model="nodeFormData.isLeaf" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="nodeFormData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="nodeDialogVisible = false">取消</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleNodeSubmit" :loading="nodeSubmitLoading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="slotDialogVisible"
|
||||
:title="slotDialogTitle"
|
||||
width="480px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@close="handleSlotDialogClose"
|
||||
>
|
||||
<el-form
|
||||
ref="slotFormRef"
|
||||
:model="slotFormData"
|
||||
:rules="slotFormRules"
|
||||
label-position="top"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="格子编码" prop="slotCode">
|
||||
<el-input v-model="slotFormData.slotCode" placeholder="请输入格子编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="格子状态">
|
||||
<el-select v-model="slotFormData.slotStatus" placeholder="请选择状态">
|
||||
<el-option label="空" value="空" />
|
||||
<el-option label="占用" value="占用" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="slotDialogVisible = false">取消</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSlotSubmit" :loading="slotSubmitLoading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="batchDialogVisible"
|
||||
title="批量生成格子"
|
||||
width="440px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="行数">
|
||||
<el-input-number v-model="batchForm.rows" :min="1" :max="20" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="列数">
|
||||
<el-input-number v-model="batchForm.cols" :min="1" :max="20" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="编码前缀">
|
||||
<el-input v-model="batchForm.codePrefix" placeholder="如 A、B-、01-(可选)" />
|
||||
<template #label>
|
||||
编码前缀
|
||||
<span class="form-label-tip">生成格式:前缀+行号-列号,如 A-1-1</span>
|
||||
</template>
|
||||
</el-form-item>
|
||||
<div class="sf-batch-preview">
|
||||
将生成 <strong>{{ batchForm.rows * batchForm.cols }}</strong> 个格子({{ batchForm.rows }} 行 × {{ batchForm.cols }} 列)
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="batchDialogVisible = false">取消</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleBatchSubmit" :loading="batchSubmitLoading">确认生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
fetchStorageFacilityList,
|
||||
fetchCreateStorageFacility,
|
||||
fetchUpdateStorageFacility,
|
||||
fetchDeleteStorageFacility,
|
||||
fetchStorageSlotList,
|
||||
fetchBatchCreateSlots,
|
||||
fetchUpdateStorageSlot,
|
||||
fetchDeleteStorageSlot
|
||||
} from '@/api/admin'
|
||||
import { buildTreePathLabelFromFlat } from '@/utils/tree-path'
|
||||
|
||||
defineOptions({ name: 'StorageFacility' })
|
||||
|
||||
const ROW_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T']
|
||||
|
||||
const hoveredNodeId = ref('')
|
||||
const treeKeyword = ref('')
|
||||
const treeRef = ref()
|
||||
const loading = ref(false)
|
||||
const flatList = ref<any[]>([])
|
||||
const treeData = ref<any[]>([])
|
||||
const selectedNode = ref<any>(null)
|
||||
const slotLoading = ref(false)
|
||||
const slotList = ref<any[]>([])
|
||||
|
||||
const buildTree = (list: any[]) => {
|
||||
const map = new Map<string, any>()
|
||||
const roots: any[] = []
|
||||
list.forEach(item => { map.set(item.id, { ...item, children: [] }) })
|
||||
list.forEach(item => {
|
||||
const node = map.get(item.id)!
|
||||
if (item.parentId && item.parentId !== '0' && map.has(item.parentId)) {
|
||||
map.get(item.parentId)!.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
return roots
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await fetchStorageFacilityList({ keyword: treeKeyword.value || undefined })
|
||||
const list = res?.list || res || []
|
||||
flatList.value = list
|
||||
treeData.value = buildTree(list)
|
||||
} catch (error) {
|
||||
console.error('加载库位数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filterNode = (value: string, data: any) => {
|
||||
if (!value) return true
|
||||
return data.nodeName?.includes(value)
|
||||
}
|
||||
|
||||
watch(treeKeyword, val => { treeRef.value?.filter(val) })
|
||||
|
||||
const handleNodeClick = async (data: any) => {
|
||||
selectedNode.value = data
|
||||
if (data.isLeaf === 1) {
|
||||
await loadSlots(data.id)
|
||||
} else {
|
||||
slotList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadSlots = async (facilityNodeId: string) => {
|
||||
slotLoading.value = true
|
||||
try {
|
||||
const res: any = await fetchStorageSlotList({ facilityNodeId })
|
||||
slotList.value = res?.list || res || []
|
||||
} catch (error) {
|
||||
console.error('加载格子数据失败:', error)
|
||||
slotList.value = []
|
||||
} finally {
|
||||
slotLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const shelfRows = computed(() => {
|
||||
const cols = batchForm.cols
|
||||
const result: { label: string; cells: any[] }[] = []
|
||||
for (let r = 0; r * cols < slotList.value.length; r++) {
|
||||
const rowCells = slotList.value.slice(r * cols, (r + 1) * cols)
|
||||
if (rowCells.length > 0) {
|
||||
result.push({ label: ROW_LABELS[r] || String(r + 1), cells: rowCells })
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const nodeDialogVisible = ref(false)
|
||||
const nodeDialogTitle = ref('新增根节点')
|
||||
const isNodeEdit = ref(false)
|
||||
const nodeSubmitLoading = ref(false)
|
||||
const nodeFormRef = ref()
|
||||
const nodeFormData = reactive({
|
||||
id: '',
|
||||
parentId: '',
|
||||
nodeName: '',
|
||||
nodeType: 'facility',
|
||||
isLeaf: 0 as 0 | 1,
|
||||
remark: ''
|
||||
})
|
||||
const nodeFormRules = { nodeName: [{ required: true, message: '请输入节点名称', trigger: 'blur' }] }
|
||||
|
||||
const handleAddRoot = () => {
|
||||
isNodeEdit.value = false
|
||||
nodeDialogTitle.value = '新增根节点'
|
||||
Object.assign(nodeFormData, { id: '', parentId: '', nodeName: '', nodeType: 'facility', isLeaf: 0, remark: '' })
|
||||
nodeDialogVisible.value = true
|
||||
nextTick(() => nodeFormRef.value?.clearValidate?.())
|
||||
}
|
||||
|
||||
const handleAddChild = (row: any) => {
|
||||
isNodeEdit.value = false
|
||||
nodeDialogTitle.value = '新增子节点'
|
||||
Object.assign(nodeFormData, { id: '', parentId: row.id, nodeName: '', nodeType: 'layer', isLeaf: 0, remark: '' })
|
||||
nodeDialogVisible.value = true
|
||||
nextTick(() => nodeFormRef.value?.clearValidate?.())
|
||||
}
|
||||
|
||||
const handleEditNode = (row: any) => {
|
||||
isNodeEdit.value = true
|
||||
nodeDialogTitle.value = '编辑节点'
|
||||
Object.assign(nodeFormData, {
|
||||
id: row.id,
|
||||
parentId: row.parentId || '',
|
||||
nodeName: row.nodeName || '',
|
||||
nodeType: row.nodeType || 'facility',
|
||||
isLeaf: row.isLeaf ?? 0,
|
||||
remark: row.remark || ''
|
||||
})
|
||||
nodeDialogVisible.value = true
|
||||
nextTick(() => nodeFormRef.value?.clearValidate?.())
|
||||
}
|
||||
|
||||
const handleNodeSubmit = async () => {
|
||||
await nodeFormRef.value?.validate?.()
|
||||
nodeSubmitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
parentId: nodeFormData.parentId || undefined,
|
||||
nodeName: nodeFormData.nodeName.trim(),
|
||||
nodeType: nodeFormData.nodeType,
|
||||
isLeaf: nodeFormData.isLeaf,
|
||||
remark: nodeFormData.remark?.trim() || undefined
|
||||
}
|
||||
if (isNodeEdit.value && nodeFormData.id) {
|
||||
await fetchUpdateStorageFacility(nodeFormData.id, payload)
|
||||
} else {
|
||||
await fetchCreateStorageFacility(payload)
|
||||
}
|
||||
ElMessage.success(isNodeEdit.value ? '更新成功' : '创建成功')
|
||||
nodeDialogVisible.value = false
|
||||
await loadData()
|
||||
} catch { /* handled globally */ } finally {
|
||||
nodeSubmitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleNodeDialogClose = () => {
|
||||
Object.assign(nodeFormData, { id: '', parentId: '', nodeName: '', nodeType: 'facility', isLeaf: 0, remark: '' })
|
||||
nodeFormRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
const handleDeleteNode = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('删除节点将一并删除其子节点和格子。确定继续吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteStorageFacility(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
if (selectedNode.value?.id === row.id) {
|
||||
selectedNode.value = null
|
||||
slotList.value = []
|
||||
}
|
||||
await loadData()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const slotDialogVisible = ref(false)
|
||||
const slotDialogTitle = ref('编辑格子')
|
||||
const isSlotEdit = ref(false)
|
||||
const slotSubmitLoading = ref(false)
|
||||
const slotFormRef = ref()
|
||||
const editingSlotId = ref<string | null>(null)
|
||||
const slotFormData = reactive({ slotCode: '', slotStatus: '空' })
|
||||
const slotFormRules = { slotCode: [{ required: true, message: '请输入格子编码', trigger: 'blur' }] }
|
||||
|
||||
const handleEditSlot = (row: any) => {
|
||||
isSlotEdit.value = true
|
||||
slotDialogTitle.value = '编辑格子'
|
||||
editingSlotId.value = row.id
|
||||
Object.assign(slotFormData, { slotCode: row.slotCode || '', slotStatus: row.slotStatus || '空' })
|
||||
slotDialogVisible.value = true
|
||||
nextTick(() => slotFormRef.value?.clearValidate?.())
|
||||
}
|
||||
|
||||
const handleSlotSubmit = async () => {
|
||||
await slotFormRef.value?.validate?.()
|
||||
slotSubmitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
facilityNodeId: selectedNode.value?.id,
|
||||
slotCode: slotFormData.slotCode.trim(),
|
||||
slotStatus: slotFormData.slotStatus
|
||||
}
|
||||
if (editingSlotId.value) {
|
||||
await fetchUpdateStorageSlot(editingSlotId.value, payload)
|
||||
}
|
||||
ElMessage.success('更新成功')
|
||||
slotDialogVisible.value = false
|
||||
if (selectedNode.value?.id) await loadSlots(selectedNode.value.id)
|
||||
} catch { /* handled globally */ } finally {
|
||||
slotSubmitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSlotDialogClose = () => {
|
||||
Object.assign(slotFormData, { slotCode: '', slotStatus: '空' })
|
||||
editingSlotId.value = null
|
||||
slotFormRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
const batchDialogVisible = ref(false)
|
||||
const batchSubmitLoading = ref(false)
|
||||
const batchForm = reactive({ rows: 4, cols: 6, codePrefix: '' })
|
||||
|
||||
const handleBatchCreate = () => {
|
||||
Object.assign(batchForm, { rows: 4, cols: 6, codePrefix: '' })
|
||||
batchDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleBatchSubmit = async () => {
|
||||
if (!selectedNode.value?.id) return
|
||||
batchSubmitLoading.value = true
|
||||
try {
|
||||
await fetchBatchCreateSlots({
|
||||
facilityNodeId: selectedNode.value.id,
|
||||
rows: batchForm.rows,
|
||||
cols: batchForm.cols,
|
||||
codePrefix: batchForm.codePrefix || undefined
|
||||
})
|
||||
ElMessage.success('批量生成成功')
|
||||
batchDialogVisible.value = false
|
||||
await loadSlots(selectedNode.value.id)
|
||||
} catch { /* handled globally */ } finally {
|
||||
batchSubmitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAllSlots = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要清空当前节点的全部 ${slotList.value.length} 个格子吗?`, '提示', { type: 'warning' })
|
||||
for (const slot of slotList.value) {
|
||||
await fetchDeleteStorageSlot(slot.id)
|
||||
}
|
||||
ElMessage.success('清空成功')
|
||||
if (selectedNode.value?.id) await loadSlots(selectedNode.value.id)
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') console.error('清空失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadData() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sf-tree-aside__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sf-tree-search {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sf-tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sf-tree-node__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sf-tree-node__leaf {
|
||||
font-size: 10px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--el-color-success-light-9, #ecfdf5);
|
||||
color: var(--el-color-success, #10b981);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sf-tree-node__ops {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sf-empty {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.sf-count {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary, #909399);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Shelf layout */
|
||||
.sf-shelf-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sf-shelf-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sf-shelf-label {
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.sf-shelf-cells {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sf-shelf-cell {
|
||||
flex: 1;
|
||||
min-height: 80px;
|
||||
border: 1px solid var(--el-border-color-lighter, #d1fae5);
|
||||
border-right: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
background: #fff;
|
||||
padding: 8px 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sf-shelf-cell:last-child {
|
||||
border-right: 1px solid var(--el-border-color-lighter, #d1fae5);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.sf-shelf-cell:first-child {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.sf-shelf-cell:hover {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.sf-shelf-cell.is-occupied {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
.sf-shelf-cell.is-occupied:hover {
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.sf-cell-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sf-cell-code {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.sf-shelf-cell.is-occupied .sf-cell-code {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.sf-cell-status {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sf-cell-status.is-empty {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.sf-cell-status.is-occupied {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.sf-cell-body {
|
||||
flex: 1;
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sf-cell-reserved {
|
||||
font-size: 11px;
|
||||
color: #dcdfe6;
|
||||
}
|
||||
|
||||
.sf-batch-preview {
|
||||
background: #ecfdf5;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: #059669;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 lint 通过**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && npx eslint src/views/operations/storage-facility/index.vue --no-error-on-unmatched-pattern 2>&1 | tail -10`
|
||||
Expected: 无错误输出
|
||||
|
||||
- [ ] **Step 3: 验证 dev server 正常**
|
||||
|
||||
Run: `cd /Users/wuzilong/work/00code/zkxw/web-admin && npx vite build --mode development 2>&1 | tail -5`
|
||||
Expected: 构建成功,无编译错误
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ 左侧 el-tree 树形列表(自由层级)
|
||||
- ✅ 右侧货架行布局格子展示(最末层叶子节点)
|
||||
- ✅ 编号+状态在格子顶部一行
|
||||
- ✅ 下方预留空间
|
||||
- ✅ 批量生成格子(指定行列数)
|
||||
- ✅ 格子点击编辑(dialog)
|
||||
- ✅ 节点 CRUD(弹窗)
|
||||
- ✅ 节点删除级联删除子节点和格子
|
||||
|
||||
**Placeholder scan:** 无 TBD/TODO/待定内容。所有代码完整。
|
||||
|
||||
**Type consistency:** 所有函数签名、变量名前后一致。`fetchBatchCreateSlots` 在 admin.ts 定义并在 index.vue 中使用。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 细胞存储管理(2.2.6~2.2.10)— 实现设计(方案 C)
|
||||
|
||||
**依据**:`docs/中科兴皖需求V0.3.md` 菜单 2.2.6~2.2.10;数据模型 `docs/sql/zkxw-requirements-v0.3-schema.sql`(`biz_collection_protocol` 及其子表)。
|
||||
**依据**:`docs/中科兴皖需求V0.3.md` 菜单 2.2.6~2.2.10;数据模型 `docs/sql/zkxw.sql`(`biz_collection_protocol` 及其子表)。
|
||||
|
||||
**范围**:Web 管理端 `web-admin` + FastAPI `backend`;租户隔离与既有 `/web/admin` 响应风格保持一致。
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
### 3.3 列表默认排序
|
||||
|
||||
- 无自定义排序时:`order_num ASC, create_time DESC`(与工程规范一致);预警列表可按 `installment_due_date ASC` 等业务需求单独约定。
|
||||
- 无自定义排序时:`create_time DESC`(与工程规范一致);预警列表可按 `installment_due_date ASC` 等业务需求单独约定。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
# 工作流模块设计文档
|
||||
|
||||
**模块**: 2.1.11 流程管理
|
||||
**日期**: 2026-05-13
|
||||
**状态**: 待审核
|
||||
**决策记录**:
|
||||
- 流程设计器: AntV X6(蚂蚁图编辑引擎)
|
||||
- V1范围: 全量实现(配置管理+设计器+待办中心+已办中心+后端引擎)
|
||||
- 业务集成: API调用+回调模式
|
||||
- 引擎架构: 图遍历引擎(方案A)
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构与模块划分
|
||||
|
||||
### 1.1 后端(`backend/app/`)
|
||||
|
||||
```
|
||||
models/models.py ← 新增6个wf_* SQLAlchemy模型
|
||||
schemas/workflow.py ← Pydantic schemas(请求/响应模型)
|
||||
crud/workflow.py ← 数据库CRUD操作
|
||||
services/
|
||||
workflow_engine.py ← 核心引擎:节点分发、条件评估、审批人解析
|
||||
workflow_callback.py ← 回调服务:通知业务模块状态变更
|
||||
api/workflow.py ← API路由(注册到admin前缀)
|
||||
```
|
||||
|
||||
### 1.2 前端(`web-admin/src/`)
|
||||
|
||||
```
|
||||
api/workflow.ts ← API调用
|
||||
types/api.d.ts ← 新增Workflow相关TS类型
|
||||
views/system/workflow/
|
||||
process/index.vue ← 流程配置管理(列表+设计器弹窗)
|
||||
designer/
|
||||
FlowDesigner.vue ← AntV X6流程设计器组件
|
||||
nodes/ ← 自定义节点组件(Start/End/Approval/Condition)
|
||||
config/ ← 节点属性配置面板
|
||||
task-pending/index.vue ← 待办中心
|
||||
task-completed/index.vue ← 已办中心
|
||||
components/
|
||||
ApprovalDialog.vue ← 审批操作弹窗(通过/驳回+意见)
|
||||
ProcessStatusTag.vue ← 流程状态标签
|
||||
router/modules/sqxp.ts ← 新增3个路由
|
||||
```
|
||||
|
||||
### 1.3 核心数据流
|
||||
|
||||
1. 管理员在流程配置页 → AntV X6设计器画流程 → 保存 `graph_json` + 同步写 `wf_node`/`wf_transition`
|
||||
2. 业务模块调用 `POST /api/admin/workflow/instances` 发起流程 → 引擎锁定当前版本 → 创建 start 任务
|
||||
3. 引擎按 `wf_transition` 遍历节点 → 审批节点创建 `wf_task` → 待办人收到待办
|
||||
4. 待办人审批 → 引擎判断 any/all 策略 → 推进到下一节点 → 到达 end 节点 → 回调业务模块
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库模型
|
||||
|
||||
已有 6 张表 DDL 见全库导出 `docs/sql/zkxw.sql`(搜索 `wf_process_def` 等表名);基线表结构以当前导出为准。
|
||||
|
||||
| 表名 | 用途 | 关键字段 |
|
||||
|------|------|----------|
|
||||
| `wf_process_def` | 流程定义 | process_code, process_name, status |
|
||||
| `wf_process_version` | 流程版本 | process_def_id, version_no, graph_json, is_latest |
|
||||
| `wf_node` | 流程节点 | process_version_id, node_key, node_type, config_json |
|
||||
| `wf_transition` | 流程连线 | process_version_id, from_node_key, to_node_key, condition_expr |
|
||||
| `wf_process_instance` | 流程实例 | process_version_id, business_type, business_id, variables_json, status |
|
||||
| `wf_task` | 流程任务 | instance_id, node_key, assignee_user_id, status, comment_text |
|
||||
|
||||
**node_type 枚举值**: `start`, `end`, `approval`, `condition`
|
||||
**instance.status 枚举值**: `running`, `completed`, `rejected`, `cancelled`
|
||||
**task.status 枚举值**: `pending`, `approved`, `rejected`, `withdrawn`
|
||||
|
||||
所有表均遵循公共字段规范:id(VARCHAR 64 UUID) → 业务字段 → tenant_no → order_num → remark → create_by → create_dept_id → create_time → update_by → update_time → del_flag
|
||||
|
||||
### 2.1 Schema 变更(需新增字段)
|
||||
|
||||
`wf_process_instance` 需新增 `callback_url` 字段(VARCHAR 512, 可空),用于业务回调地址。DDL 变更将在实施阶段维护到 `docs/sql/`(全量基线为 `docs/sql/zkxw.sql`,或附加增量脚本)。
|
||||
|
||||
### 2.2 双存储语义
|
||||
|
||||
工作流存在两套数据,各有用途:
|
||||
- **`graph_json`**(wf_process_version): 前端设计器画布快照,用于设计器加载/编辑/渲染。仅供前端使用,后端引擎不解析此字段。
|
||||
- **`wf_node` + `wf_transition`**(关系表): 后端引擎运行时使用的规范化数据。引擎只读这些表来执行节点分发和条件评估。
|
||||
- 两者在"发布版本"时同步写入,保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 3. 工作流引擎核心设计
|
||||
|
||||
### 3.1 节点分发器模式
|
||||
|
||||
每种节点类型对应一个 handler,引擎根据 node_type 调度:
|
||||
|
||||
```
|
||||
WorkflowEngine
|
||||
├── StartNodeHandler → 自动推进到下一个节点
|
||||
├── EndNodeHandler → 标记实例完成,触发回调
|
||||
├── ApprovalNodeHandler → 解析审批人,创建wf_task记录
|
||||
└── ConditionNodeHandler → 评估条件表达式,选择分支
|
||||
```
|
||||
|
||||
引擎入口方法:`advance(instance_id, from_node_key)` — 从指定节点开始,执行当前节点逻辑,然后沿出边推进到下一个节点。
|
||||
|
||||
### 3.2 条件表达式格式
|
||||
|
||||
`wf_transition.condition_expr` 字段存储的是 JSON 规则格式(VARCHAR 512),不是任意文本表达式。安全可控,不支持任意代码执行:
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": [
|
||||
{"field": "amount", "op": "<=", "value": 100},
|
||||
{"field": "amount", "op": ">", "value": 100, "and": [{"field": "amount", "op": "<", "value": 300}]},
|
||||
{"field": "amount", "op": ">=", "value": 300}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**支持的操作符**: `==`, `!=`, `>`, `<`, `>=`, `<=`, `in`, `not_in`, `contains`
|
||||
|
||||
**字段取值**: 从 `wf_process_instance.variables_json` 中读取。支持嵌套路径用 `.` 分隔(如 `applicant.dept_id`)。
|
||||
|
||||
**条件评估逻辑**: ConditionNodeHandler 遍历所有出边(wf_transition),依次评估每条边的 condition_expr。第一条匹配的边即为执行路径。最后一条边可省略条件作为"其他情况"默认分支。
|
||||
|
||||
### 3.3 审批人解析
|
||||
|
||||
`config_json` 中的 `approver` 字段定义解析策略:
|
||||
|
||||
```json
|
||||
{
|
||||
"strategy": "users",
|
||||
"pass_type": "any",
|
||||
"user_ids": ["uuid1", "uuid2"]
|
||||
}
|
||||
```
|
||||
|
||||
**strategy 枚举**:
|
||||
|
||||
| 策略 | 配置字段 | 说明 |
|
||||
|------|----------|------|
|
||||
| `users` | `user_ids: string[]` | 指定具体用户 |
|
||||
| `roles` | `role_ids: string[]` | 指定角色,运行时解析角色下的所有用户 |
|
||||
| `sql` | `sql: string` | SQL查询,返回user_id列表 |
|
||||
|
||||
**pass_type 枚举**:
|
||||
- `any` — 任一审批人通过即推进
|
||||
- `all` — 所有审批人都必须通过才推进
|
||||
|
||||
**SQL策略安全约束**:
|
||||
- 只允许 SELECT 语句
|
||||
- 禁止包含 INSERT/UPDATE/DELETE/DROP/ALTER
|
||||
- 只取结果第一列作为 user_id
|
||||
- 流程变量作为参数注入(`:var_name` 占位符)
|
||||
- 查询结果受 tenant_no 隐式过滤
|
||||
|
||||
### 3.4 版本锁定
|
||||
|
||||
- 发起流程时,`wf_process_instance.process_version_id` 锁定当前 `is_latest=1` 的版本
|
||||
- 发布新版本时:将旧版本的 `is_latest` 置为 0,创建新版本记录 `is_latest=1`
|
||||
- 已发起的实例始终使用锁定的版本运行,不受后续版本变更影响
|
||||
|
||||
### 3.5 实例状态流转
|
||||
|
||||
```
|
||||
发起 → running → completed(到达end节点)
|
||||
→ rejected(审批驳回,无其他分支时)
|
||||
→ cancelled(业务主动取消)
|
||||
```
|
||||
|
||||
### 3.6 任务状态流转
|
||||
|
||||
```
|
||||
创建 → pending → approved(审批通过)
|
||||
→ rejected(审批驳回)
|
||||
→ withdrawn(发起人撤回)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API路由设计
|
||||
|
||||
共13个路由,全部走 `/api/admin/workflow/` 前缀,带JWT认证和租户隔离。
|
||||
|
||||
### 4.1 流程定义管理
|
||||
|
||||
| 路由 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/admin/workflow/defs` | GET | 流程定义列表(分页+搜索,支持按名称/编码/状态筛选) |
|
||||
| `/api/admin/workflow/defs` | POST | 新建流程定义 |
|
||||
| `/api/admin/workflow/defs/{id}` | PUT | 更新流程定义基本信息 |
|
||||
| `/api/admin/workflow/defs/{id}` | DELETE | 软删除流程定义(del_flag=1) |
|
||||
| `/api/admin/workflow/defs/{id}/publish` | POST | 发布新版本(接收graph_json+nodes+transitions,创建新版本) |
|
||||
| `/api/admin/workflow/defs/{id}/versions` | GET | 版本历史列表 |
|
||||
|
||||
### 4.2 流程实例
|
||||
|
||||
| 路由 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/admin/workflow/instances` | POST | 发起流程(业务模块调用,传入process_code+business_type+business_id+variables) |
|
||||
| `/api/admin/workflow/instances/{id}` | GET | 流程实例详情(含任务列表和流程图高亮当前节点) |
|
||||
|
||||
### 4.3 任务办理
|
||||
|
||||
| 路由 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/admin/workflow/tasks/pending` | GET | 我的待办列表(按assignee_user_id=当前用户+status=pending过滤) |
|
||||
| `/api/admin/workflow/tasks/completed` | GET | 我的已办列表(按assignee_user_id=当前用户+status in [approved,rejected]过滤) |
|
||||
| `/api/admin/workflow/tasks/{id}/approve` | POST | 审批通过(传comment_text) |
|
||||
| `/api/admin/workflow/tasks/{id}/reject` | POST | 审批驳回(传comment_text) |
|
||||
|
||||
### 4.4 请求/响应格式
|
||||
|
||||
**发起流程请求**:
|
||||
```json
|
||||
POST /api/admin/workflow/instances
|
||||
{
|
||||
"process_code": "agreement_payment",
|
||||
"business_type": "agreement",
|
||||
"business_id": "agr_uuid_123",
|
||||
"variables": {
|
||||
"amount": 5000,
|
||||
"dept_id": "dept_001",
|
||||
"applicant_id": "user_001"
|
||||
},
|
||||
"callback_url": "/api/admin/agreements/workflow-callback"
|
||||
}
|
||||
```
|
||||
|
||||
**审批操作请求**:
|
||||
```json
|
||||
POST /api/admin/workflow/tasks/{id}/approve
|
||||
{
|
||||
"comment_text": "同意,费用已核实"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端页面设计
|
||||
|
||||
### 5.1 流程配置管理(`process/index.vue`)
|
||||
|
||||
**模式**: 标准列表页(参考 `unit/index.vue` 模板)
|
||||
|
||||
**列表字段**: 流程编码、流程名称、状态(启用/停用)、最新版本号、创建时间、操作
|
||||
|
||||
**操作按钮**: 新增、编辑、**流程设计**(打开X6设计器弹窗)、发布新版本、停用/启用、删除
|
||||
|
||||
**设计器入口**: 点击"流程设计"→ 全屏弹窗打开 AntV X6 设计器
|
||||
|
||||
### 5.2 AntV X6 流程设计器(`designer/FlowDesigner.vue`)
|
||||
|
||||
**三栏布局**:
|
||||
- **左侧**: 节点组件面板(开始/结束/审批人/条件分支),拖拽到画布
|
||||
- **中间**: 画布区域(AntV X6 Graph),支持节点拖拽、连线、删除、缩放
|
||||
- **右侧**: 属性配置面板(选中节点后展示,根据节点类型动态切换)
|
||||
- 审批人节点: 策略选择(用户/角色/SQL)、具体配置、通过策略(any/all)
|
||||
- 条件分支节点: 每条出边的条件规则编辑
|
||||
- 开始/结束节点: 只显示名称编辑
|
||||
|
||||
**顶部工具栏**: 保存草稿、发布新版本、撤销/重做、放大/缩小/适应画布
|
||||
|
||||
**保存逻辑**: 保存时序列化为 `{graph_json, nodes: [{node_key, node_type, config_json, ...}], transitions: [{from_node_key, to_node_key, condition_expr, ...}]}` 提交到后端
|
||||
|
||||
**自定义节点样式**: 每种节点类型使用不同的形状和颜色
|
||||
- 开始节点: 圆形,绿色
|
||||
- 结束节点: 圆形,红色
|
||||
- 审批人节点: 矩形,蓝色
|
||||
- 条件分支节点: 菱形,橙色
|
||||
|
||||
### 5.3 待办中心(`task-pending/index.vue`)
|
||||
|
||||
**模式**: 复杂列表页(参考 `order/index.vue`)
|
||||
|
||||
**筛选**: 流程名称、业务类型、发起时间范围
|
||||
|
||||
**列表字段**: 流程名称、业务类型、业务单号、发起人、发起时间、到达时间、操作
|
||||
|
||||
**操作**: 审批(打开审批弹窗)、查看详情
|
||||
|
||||
**审批弹窗**(`ApprovalDialog.vue`): 通过/驳回选择 + 审批意见输入框 + 查看流程图(只读模式,高亮当前节点)
|
||||
|
||||
### 5.4 已办中心(`task-completed/index.vue`)
|
||||
|
||||
**模式**: 标准列表页
|
||||
|
||||
**筛选**: 同待办中心 + 审批结果筛选(通过/驳回)
|
||||
|
||||
**列表字段**: 流程名称、业务类型、业务单号、发起人、审批结果、审批意见、审批时间
|
||||
|
||||
**操作**: 查看详情
|
||||
|
||||
---
|
||||
|
||||
## 6. 回调与业务集成
|
||||
|
||||
### 6.1 回调机制
|
||||
|
||||
引擎在以下事件触发 HTTP POST 回调到 `callback_url`:
|
||||
|
||||
**实例完成**:
|
||||
```json
|
||||
{
|
||||
"event": "completed",
|
||||
"instance_id": "inst_uuid",
|
||||
"process_code": "agreement_payment",
|
||||
"business_type": "agreement",
|
||||
"business_id": "agr_uuid_123"
|
||||
}
|
||||
```
|
||||
|
||||
**实例驳回**:
|
||||
```json
|
||||
{
|
||||
"event": "rejected",
|
||||
"instance_id": "inst_uuid",
|
||||
"process_code": "agreement_payment",
|
||||
"business_type": "agreement",
|
||||
"business_id": "agr_uuid_123",
|
||||
"reject_comment": "费用异常,请核实"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 业务模块集成步骤
|
||||
|
||||
1. 在流程配置页创建流程定义,发布版本
|
||||
2. 业务模块在需要触发审批的地方调用 `POST /api/admin/workflow/instances`
|
||||
3. 业务模块实现回调接口接收审批结果
|
||||
4. 用户在待办中心处理审批任务
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全与权限
|
||||
|
||||
- 所有API带JWT认证,租户隔离(tenant_no过滤)
|
||||
- SQL审批人解析策略限制为只读SELECT,禁止DDL/DML
|
||||
- 流程定义管理需要系统管理权限
|
||||
- 待办/已办中心只展示当前用户的任务
|
||||
- 审批操作只能操作 status=pending 且 assignee_user_id=当前用户 的任务
|
||||
|
||||
---
|
||||
|
||||
## 8. 已确认的约束
|
||||
|
||||
- 遵循项目公共字段规范(id→业务→tenant_no→...→del_flag)
|
||||
- 软删除(del_flag 0/1)
|
||||
- 默认排序(create_time DESC)
|
||||
- Element Plus组件自动导入,不手动导入
|
||||
- Fresh Mint主题色 #10b981
|
||||
- label-position="top",.form-grid-2 布局
|
||||
- 操作列固定右侧(2按钮260px,3按钮320px)
|
||||
- 标题绿色竖条(需注册到App.vue白名单)
|
||||
@@ -0,0 +1,48 @@
|
||||
# 运营支撑模块设计规格
|
||||
|
||||
## 概述
|
||||
实现需求文档 2.4 节中运营支撑下的三个子模块:洁净室管理、库位管理、展馆预约。
|
||||
新增一级菜单【运营支撑】,下设三个二级菜单。
|
||||
|
||||
## SQL 表(已存在,无需新建)
|
||||
1. `biz_clean_room` - 洁净室信息(编号、名称、等级A/B/C/D、状态正常/异常、面积)
|
||||
2. `biz_clean_room_access_log` - 洁净室人员进出(人员、部门、进入时间、目的、离开时间、体温)
|
||||
3. `biz_storage_facility` - 细胞存储设施树节点(树形结构:parent_id, node_name, node_type, is_leaf)
|
||||
4. `biz_storage_facility_slot` - 存储位置格子(叶子节点下的小方格)
|
||||
5. `biz_exhibition_appointment` - 展馆预约(预约编号、来访单位、联系人、到访日期、人数、接待人员)
|
||||
|
||||
## 模块设计
|
||||
|
||||
### 模块 1: 洁净室管理
|
||||
- **类型**: 标准列表 + 子表模式
|
||||
- **后端**: Model + CRUD (`clean_room.py`) + API 路由
|
||||
- **前端**: 参照 `system/unit/index.vue`,增加"人员进出"按钮弹窗查看进出记录
|
||||
- **路由**: `/operations/clean-room`
|
||||
- **API**: `/web/admin/clean-rooms` + `/web/admin/clean-room-access-logs`
|
||||
|
||||
### 模块 2: 库位管理
|
||||
- **类型**: 树形结构模式
|
||||
- **后端**: Model + CRUD (`storage_facility.py`) + API 路由(树查询 + 节点 CRUD + 格子 CRUD)
|
||||
- **前端**: 参照 `system/category/index.vue`,左侧树形结构,右侧节点详情+格子列表
|
||||
- **路由**: `/operations/storage-facility`
|
||||
- **API**: `/web/admin/storage-facilities` + `/web/admin/storage-facility-slots`
|
||||
- **工具**: 使用 `tree-path.ts` 构建完整路径
|
||||
|
||||
### 模块 3: 展馆预约
|
||||
- **类型**: 标准列表模式
|
||||
- **后端**: Model + CRUD (`exhibition_appointment.py`) + API 路由
|
||||
- **前端**: 参照 `system/unit/index.vue` 标准列表
|
||||
- **路由**: `/operations/exhibition-appointment`
|
||||
- **API**: `/web/admin/exhibition-appointments`
|
||||
|
||||
## 技术规范
|
||||
- 后端模式:flat 函数式 CRUD(参照 `app/crud/item_unit.py`),不用 service 层
|
||||
- 前端模式:API 函数加入 `admin.ts`,路由加入 `sqxp.ts`
|
||||
- 所有查询带 `tenant_no` 过滤,删除为软删除
|
||||
- Fresh Mint 配色 (`--primary #10b981`)
|
||||
- 树形模块使用 `tree-path.ts` 工具
|
||||
|
||||
## 实施顺序
|
||||
1. 后端:Models → CRUD → API 路由 → 注册
|
||||
2. 前端:API 函数 → 路由 → 页面组件
|
||||
3. 测试:API 验证 + 页面功能验证
|
||||
+52
-5
@@ -67,11 +67,15 @@
|
||||
- 展馆预约[2.4.3、展馆预约]
|
||||
- 物料管理(暂时不开发,后续在完善)
|
||||
- 财务管理(暂时不开发,后续在完善)
|
||||
- 生产管理(暂时不开发,后续在完善)
|
||||
- 耗材管理
|
||||
- 耗材包管理
|
||||
- 工艺库管理
|
||||
- 工艺路线配置
|
||||
- 生产管理
|
||||
- 耗材管理[2.5.1、耗材管理]
|
||||
- 耗材包管理[2.5.2、耗材包管理]
|
||||
- 试剂管理[2.5.3、试剂管理]
|
||||
- SOP文档[2.5.4、SOP文档]
|
||||
- 工艺工序分类[2.5.5、工艺工序分类]
|
||||
- 工艺产出物类型[2.3.6、工艺产出物类型]
|
||||
- 工艺库管理[2.3.7、工艺库管理]
|
||||
- 工艺路线配置[2.3.8、工艺路线配置]
|
||||
- 细胞存储协议单
|
||||
- 中试服务单
|
||||
- 生产工单
|
||||
@@ -84,6 +88,49 @@
|
||||
- 采集协议号管理[2.2.4、采集协议号管理]
|
||||
- 样本编码管理[2.2.5、样本编码管理]
|
||||
|
||||
|
||||
## 2.5、生产管理
|
||||
### 2.5.1、耗材管理
|
||||
- 此模块用于生产耗材管理,支持耗材的新增、修改、删除等功能
|
||||
- 生产耗材表核心字段:耗材编码、耗材名称、规格型号、标准用量、描述
|
||||
|
||||
### 2.5.2、耗材包管理
|
||||
- 此模块将生产耗材组合成一个耗材包,方便日常数据录入
|
||||
- 耗材包主表:编号、名称、使用场景、类型(bzhcb - 标准耗材包、 zdyhcb - 自定义耗材包),状态 (1 - 启用 、 0 - 停用)、备注
|
||||
- 耗材包耗材明细表:耗材包id、耗材编码、耗材名称、耗材规格型号、标准用量、备注
|
||||
|
||||
### 2.5.3、试剂管理
|
||||
- 此模块用于生产试剂信息的管理
|
||||
- 试剂信息表的核心字段:试剂编码、试剂名称、规格、标准用量、单位[下拉数据从 2.1.9、物品单位管理 获取]、描述
|
||||
|
||||
### 2.5.4、SOP文档
|
||||
- 此模块用于管理SOP文档管理
|
||||
- SOP文档表核心字段:SOP编号、SOP文档名称、版本号、SOP文档附件
|
||||
|
||||
### 2.5.5、工艺工序分类
|
||||
- 此模块用于工艺工序分类管理
|
||||
- 工艺工序分类表核心字段:分类编码、分类名称
|
||||
|
||||
### 2.3.6、工艺产出物类型
|
||||
- 此模块用于工艺产出物类型管理
|
||||
- 工艺产出物类型表核心字段:类型编码、类型名称
|
||||
|
||||
### 2.3.7、工艺库管理
|
||||
- 此模块用于定义细胞生产过程中的每个工艺的信息,包括预计使用的试剂、预计使用的耗材、预计产出物等关联信息。工艺信息理解就是一个预设的情况,等后续真正生产时候,有这些预制的值可以起到指导作用,同时也方便用户录入信息
|
||||
- 生产工艺主表 核心字段:工序代码、工序分类[下拉数据来自于 2.5.5、工艺工序分类]、工序名称、描述、预计使用设备名称、预估耗时、关联的SOP文[从 2.5.4、SOP文档 关联选择, 支持多个附件]
|
||||
- 生产工艺试剂关联表 核心字段: 试剂编码、试剂名称、规格、标准用量、单位、是否关键物料 。 (通过关联2.5.3、试剂管理 下拉后,将试剂编码、试剂名称、规格、标准用量、单位都带过来,但是用户可以自己改 标准用量)
|
||||
- 生产工艺耗材关联表:核心字段:耗材编码、耗材名称、耗材规格型号、标准用量、是否必需(1 - 必须 , 0 - 可选)、备注, 可以通过从2.5.2、耗材包管理 关联一次性将耗材批量带过来,然后修改。 也可以从2.5.1、耗材管理 待过来
|
||||
- 生产工艺产出关联表:核心字段:产出类型(下拉数据来源于 2.3.6、工艺产出物类型)、产出物名称、产出物条码。 产出物条码只有在真实生产时候才会有数据
|
||||
|
||||
### 2.3.8、工艺路线配置
|
||||
- 此模块用于将多个生产工艺组织成一个工艺路线图,方便实验室人员在进行细胞生产过程中,快速勾选一个工艺路线图,调整后就可以将生产过程记录下来了
|
||||
- 工艺路线图主表 核心字段:路线编码、产品类型(下拉数据来自于 2.3.1、细胞产品类型管理)、路线名称、备注
|
||||
- 工艺路线图工艺明细表:工序代码、工序分类[下拉数据来自于 2.5.5、工艺工序分类]、工序名称、描述、预计使用设备名称、预估耗时、关联的SOP文[从 2.5.4、SOP文档 关联选择, 支持多个附件]、排序号
|
||||
- 工艺路线图工艺明细表试剂关联表 核心字段: 试剂编码、试剂名称、规格、标准用量、单位、是否关键物料 。 (通过关联2.5.3、试剂管理 下拉后,将试剂编码、试剂名称、规格、标准用量、单位都带过来,但是用户可以自己改 标准用量)
|
||||
- 工艺路线图工艺明细表耗材关联表:核心字段:耗材编码、耗材名称、耗材规格型号、标准用量、是否必需(1 - 必须 , 0 - 可选)、备注, 可以通过从2.5.2、耗材包管理 关联一次性将耗材批量带过来,然后修改。 也可以从2.5.1、耗材管理 待过来
|
||||
- 工艺路线图工艺明细表产出关联表:核心字段:产出类型(下拉数据来源于 2.3.6、工艺产出物类型)、产出物名称、产出物条码。 产出物条码只有在真实生产时候才会有数据
|
||||
- 工艺路线配置这个界面,是可以做成拖拉配置的。界面分三部分、左边是内置的生产工艺的卡片、中间是生产工艺排版的界面,右侧是每个工艺对应的内容(工艺信息、试剂关联信息、耗材关联信息、产出物信息等)
|
||||
|
||||
## 2.4、运营支撑
|
||||
### 2.4.1、洁净室管理
|
||||
- 此模块用于洁净室信息录入 和 人员出入数据录入就行了
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-13
|
||||
@@ -0,0 +1,77 @@
|
||||
## Context
|
||||
|
||||
`docs/sql/zkxw.sql`(全库导出基线)已包含 `biz_customer`、`biz_cell_sample_type`、`biz_collection_region`、`biz_collection_hospital`、`biz_collection_protocol_no`、`biz_sample_code` 等表;需求文档 `docs/中科兴皖需求V0.3.md` 2.2.1~2.2.5 描述了字段与交互。后端 `app/models` 与路由中尚未覆盖上述业务主数据的完整 CRUD;Web 管理端缺少对应页面。
|
||||
|
||||
**与需求文档的差异(以 DDL 为准)**:2.2.3 文案提到「医院名称逗号分隔」;当前 schema 为地区表 + 医院子表 1:N,实现 SHALL 以子表为准,弹窗内维护医院行,避免双写。
|
||||
|
||||
**现有代码模式参考:**
|
||||
|
||||
- 后端:`openspec/changes/dict-management` 与 `crud/tenant.py` 类独立函数、`api/admin.py` 路由风格;租户 `tenant_no`、软删 `del_flag`、列表默认排序 `create_time DESC`(见 engineering-standards)。
|
||||
- 前端:标准列表 `views/system/unit/index.vue`;复杂子表弹窗可参考字典「字典数据配置」或订单模块弹窗;API 封装于 `src/api/*.ts`。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 五类主数据在租户范围内的分页列表、详情(可选)、创建、更新、软删除。
|
||||
- 客户身份证去重(同租户、非空身份证、未删除记录唯一);样本类型编码、协议号、样本编码在租户内唯一。
|
||||
- 地区与医院:主列表为地区;「地区医院配置」弹窗内对 `biz_collection_hospital` 增删改查(按 `region_id`)。
|
||||
- Web UI 符合 `docs/web-admin-ui-spec-fresh-mint.md` 与 `.cursor/rules/web-admin-crud-dialog.mdc`。
|
||||
- 菜单与权限 SQL 或初始化说明写入 `docs/sql/`(与仓库惯例一致)。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- `biz_customer_follow` 客户随访(2.2.1 未在本次菜单范围单独展开时可列为后续)。
|
||||
- 小程序端维护上述主数据。
|
||||
- 协议号/样本编码的「下发」与下游业务流程联动(仅维护 `issued` 字段与展示)。
|
||||
- 批量导入导出、复杂审批流。
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: 后端路由组织
|
||||
|
||||
**选择**:新增独立路由模块(例如 `app/api/basic_info.py` 或按域拆分为 `customer.py` + 聚合少量路由的文件),在 `app/api/__init__.py` 与 `app/main.py` 注册;路径前缀与现有 `/api` + admin 子前缀保持一致(如 `/web/admin/...` 或与 `dict` 同级命名空间)。
|
||||
|
||||
**理由**:五组资源边界清晰,避免单个 `admin.py` 过度膨胀;仍属同一业务域「基础信息」。
|
||||
|
||||
**替代方案**:全部写入 `admin.py` → 可行但不利于评审与合并冲突控制。
|
||||
|
||||
### D2: SQLAlchemy 模型与 CRUD
|
||||
|
||||
**选择**:在 `app/models/models.py`(或按项目惯例的拆分文件)增加与 DDL 一致的 ORM 模型;`app/crud/` 下每域独立模块或 `basic_info` 包内多文件,函数命名 `list_*`、`get_*`、`create_*`、`update_*`、`soft_delete_*`。
|
||||
|
||||
**理由**:与现有 tenant、dict 等模式一致,满足 database-conventions 公共字段与软删查询约定。
|
||||
|
||||
### D3: 客户身份证唯一校验
|
||||
|
||||
**选择**:创建与更新时若 `id_card_no` 非空,则查询同租户下是否存在其他未删除记录占用相同证件号;存在则返回 4xx 与明确业务文案(与需求「不要重复创建…提醒」一致)。
|
||||
|
||||
**理由**:表上已有 `idx_customer_idcard`;应用层给出可读错误信息。
|
||||
|
||||
### D4: 前端信息架构
|
||||
|
||||
**选择**:五个菜单入口(或二级菜单「基础信息」下五项),每页一个标准列表 + 表单弹窗;地区页主表 + 「地区医院配置」子弹窗(内嵌表格 + 分页)。
|
||||
|
||||
**理由**:与需求操作描述及工程 UI 模板一致。
|
||||
|
||||
### D5: 初始化数据(样本类型)
|
||||
|
||||
**选择**:可选 SQL 种子插入三种需求既定编码(wcqzxgxb、qdjczgxb、tpyqngxb),若环境已手动录入则脚本需幂等(`INSERT IGNORE` 或先判存)。
|
||||
|
||||
**理由**:需求 2.2.2 明确当前三种类型。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[需求与 DDL 不一致]**:2.2.3 逗号分隔 vs 子表 → **缓解**:以 DDL 为准,在 proposal/design 中写明,避免实现回退到反范式存储。
|
||||
- **[唯一索引与软删]**:`uk_sample_type` 等包含 `del_flag` → 删除后再建同编码可能冲突 → **缓解**:软删后「复活」走更新 `del_flag=0` 或换编码;接口文档说明。
|
||||
- **[权限与菜单遗漏]** → **缓解**:tasks 中单列菜单 SQL 与路由权限码登记。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- 无破坏性 schema 变更前提:部署顺序为 DB 已执行 V0.3 DDL → 后端 → 前端;回滚为下线路由与菜单,不物理删数据。
|
||||
- 若某环境缺表:以 `docs/sql/zkxw.sql` 为基线导入/对齐,或从其中摘取对应 `CREATE TABLE` 段落执行。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Web 菜单父级挂载在「Demo」下还是正式「业务/基础信息」根节点:建议与产品确认后写入 `docs/sql` 初始化脚本。
|
||||
- `biz_customer_follow` 是否与本迭代一并提供只读/编辑入口:默认本变更 Non-Goal,若需可追加 capability。
|
||||
@@ -0,0 +1,34 @@
|
||||
## Why
|
||||
|
||||
中科兴皖 V0.3「基础信息管理」下 2.2.1~2.2.5 为后续采集协议单、样本等业务的主数据入口;当前仓库仅有 SQL 模型定义,缺少租户内一致的 REST API 与 Web 管理端 CRUD,无法支撑业务录入与引用。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在 `backend` 为五类主数据提供租户隔离的列表/详情/新增/修改/软删接口,字段与校验对齐 `docs/sql/zkxw.sql` 与 `docs/中科兴皖需求V0.3.md` 2.2.1~2.2.5。
|
||||
- 在 `web-admin` 增加五个列表+弹窗表单页(或等价结构),遵循 Fresh Mint 规范与既有 Demo 模板页(如 `unit`、`dict` 类页面)的布局与交互。
|
||||
- 客户:同一租户下身份证号非空且重复时拒绝创建并提示;姓名不强制唯一。
|
||||
- 细胞样本类型:`type_code` 租户内唯一(与表 `uk_sample_type` 一致)。
|
||||
- 采集地区与医院:地区 CRUD;医院为子表(`biz_collection_hospital`),通过「地区医院配置」弹窗维护,**不**采用需求文中「医院名称逗号分隔」的冗余存储(以当前 schema 1:N 为准)。
|
||||
- 采集协议号池、样本编码池:`protocol_no` / `sample_code` 租户内唯一;维护 `issued`(是否下发)。
|
||||
- 提供初始化菜单/SQL 片段(若项目惯例为 `docs/sql/`),便于挂到 Web 侧栏。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `customer-management`:客户 `biz_customer` 的展示、增删改、身份证重复校验、标签逗号分隔编辑。
|
||||
- `cell-sample-type-management`:细胞样本类型 `biz_cell_sample_type` 的展示与增删改、编码唯一性。
|
||||
- `collection-region-hospital-management`:采集地区 `biz_collection_region` 与下属医院 `biz_collection_hospital` 的维护及子表弹窗配置。
|
||||
- `collection-protocol-no-management`:采集协议号池 `biz_collection_protocol_no` 的维护与下发标记。
|
||||
- `sample-code-management`:样本编码池 `biz_sample_code` 的维护与下发标记。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- (无)不修改 `openspec/changes/zkxw-engineering-standards` 中既有 SHALL 语义;本变更在其实现约束下新增业务能力。
|
||||
|
||||
## Impact
|
||||
|
||||
- **后端**:新增路由模块(建议 `app/api/` 下业务域路由)、对应 `schemas/`、`crud/`、`services/`(如需);在 `app/api/__init__.py` 与 `app/main.py` 注册;SQLAlchemy 模型若尚未覆盖上述表则补充。
|
||||
- **前端**:`src/api/`、`src/router/`、`src/views/`(建议 `views/business/` 或按项目惯例)、菜单静态或后端菜单数据。
|
||||
- **数据库**:以现有 `docs/sql/zkxw.sql` 全库导出基线为准;本变更可附带种子数据(如三种预设样本类型)的 **可选** 增量 SQL(置于 `docs/sql/`)。
|
||||
- **权限**:菜单与按钮权限与现有 `sys_menu` / 角色授权模式对齐。
|
||||
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 租户内样本类型列表与分页
|
||||
|
||||
系统 SHALL 提供细胞样本类型分页查询接口,数据限定为当前租户且排除已软删记录,默认排序 SHALL 为 `create_time DESC`。
|
||||
|
||||
#### Scenario: 查询列表
|
||||
|
||||
- **WHEN** 请求样本类型列表
|
||||
- **THEN** 系统返回当前租户下 `biz_cell_sample_type` 未删除数据分页结果
|
||||
|
||||
### Requirement: 样本类型编码租户内唯一
|
||||
|
||||
系统 SHALL 在创建与更新时保证 `type_code` 在同一租户、未删除记录范围内唯一;违反时返回可展示错误。
|
||||
|
||||
#### Scenario: 重复编码拒绝
|
||||
|
||||
- **WHEN** 创建或更新样本类型使 `type_code` 与同租户另一未删除记录冲突
|
||||
- **THEN** 系统拒绝操作并返回业务错误
|
||||
|
||||
### Requirement: 样本类型字段维护
|
||||
|
||||
系统 SHALL 支持样本类型的创建、更新与软删除,业务字段 SHALL 包含样本类型编码、样本类型名称、样本存储年份、样本存储价格(元)、备注等,与 `biz_cell_sample_type` 一致。
|
||||
|
||||
#### Scenario: 完整字段保存
|
||||
|
||||
- **WHEN** 提交包含编码、名称、存储年份、存储价格的合法创建请求
|
||||
- **THEN** 系统持久化各字段并在详情/列表中可回显
|
||||
|
||||
### Requirement: Web 管理端样本类型页
|
||||
|
||||
Web 管理端 SHALL 提供样本类型列表与新增/编辑/删除(软删)交互,UI SHALL 符合 Web 管理端工程 UI 规范。
|
||||
|
||||
#### Scenario: 用户编辑一条记录
|
||||
|
||||
- **WHEN** 用户在列表选择编辑并修改名称后保存
|
||||
- **THEN** 系统调用更新接口且列表展示更新后名称
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 采集协议号池列表与分页
|
||||
|
||||
系统 SHALL 提供 `biz_collection_protocol_no` 的分页查询,租户隔离且排除已软删记录,默认排序 SHALL 为 `create_time DESC`。
|
||||
|
||||
#### Scenario: 列表查询
|
||||
|
||||
- **WHEN** 请求采集协议号池列表
|
||||
- **THEN** 返回当前租户未删除记录分页数据
|
||||
|
||||
### Requirement: 协议号租户内唯一
|
||||
|
||||
系统 SHALL 保证 `protocol_no` 在同一租户、未删除记录范围内唯一;冲突时拒绝写入并返回业务错误。
|
||||
|
||||
#### Scenario: 重复协议号
|
||||
|
||||
- **WHEN** 创建或更新导致与同租户另一未删除记录 `protocol_no` 相同
|
||||
- **THEN** 系统拒绝并返回错误
|
||||
|
||||
### Requirement: 是否下发字段维护
|
||||
|
||||
系统 SHALL 支持维护 `issued` 字段,取值为 0(否)或 1(是),与 DDL 注释一致。
|
||||
|
||||
#### Scenario: 更新下发标记
|
||||
|
||||
- **WHEN** 请求将某条记录 `issued` 从 0 改为 1
|
||||
- **THEN** 系统持久化后查询结果反映新值
|
||||
|
||||
### Requirement: 协议号池 CRUD 与 Web 页
|
||||
|
||||
系统 SHALL 支持协议号池行的创建、更新、软删除。Web 管理端 SHALL 提供列表与表单弹窗完成上述维护。
|
||||
|
||||
#### Scenario: 用户在页面保存新协议号
|
||||
|
||||
- **WHEN** 用户提交合法新协议号与下发标记
|
||||
- **THEN** 系统创建成功且列表可见新行
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 采集地区 CRUD
|
||||
|
||||
系统 SHALL 提供采集地区 `biz_collection_region` 的分页列表、创建、更新与软删除,数据 SHALL 按当前用户租户隔离。
|
||||
|
||||
#### Scenario: 地区列表仅本租户
|
||||
|
||||
- **WHEN** 请求采集地区列表
|
||||
- **THEN** 返回结果仅含当前租户未删除地区
|
||||
|
||||
### Requirement: 地区下属医院子表维护
|
||||
|
||||
系统 SHALL 提供按 `region_id` 查询、创建、更新、软删除 `biz_collection_hospital` 记录的接口。医院与地区关系 SHALL 为数据库 1:N(子表),SHALL NOT 将多医院合并为地区表上的逗号分隔字段存储。
|
||||
|
||||
#### Scenario: 某地区下新增医院
|
||||
|
||||
- **WHEN** 请求为指定 `region_id` 创建医院行且医院名称合法
|
||||
- **THEN** 系统持久化 `biz_collection_hospital` 且关联正确 `region_id`
|
||||
|
||||
#### Scenario: 软删除地区时级联软删下属医院
|
||||
|
||||
- **WHEN** 用户软删除某地区
|
||||
- **THEN** 系统在同一事务中将该地区 `del_flag` 置 1,并将其下属未删除医院记录全部 `del_flag` 置 1
|
||||
|
||||
### Requirement: Web 地区与医院配置交互
|
||||
|
||||
Web 管理端 SHALL 展示采集地区列表,并提供「地区医院配置」入口打开弹窗;弹窗内 SHALL 展示该地区下属医院表格并支持医院的分页列表、新增、编辑、软删除。
|
||||
|
||||
#### Scenario: 打开配置弹窗并新增医院
|
||||
|
||||
- **WHEN** 用户点击某行的「地区医院配置」并在弹窗内新增一条医院
|
||||
- **THEN** 弹窗列表出现新医院且主列表无需离开当前页即可再次打开验证
|
||||
@@ -0,0 +1,47 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 租户内客户列表与分页
|
||||
|
||||
系统 SHALL 提供已认证管理端用户可用的客户分页查询接口,数据范围限定为当前用户所属租户,且默认排除 `del_flag=1` 的记录。列表默认排序 SHALL 为 `create_time DESC`(若无自定义排序)。
|
||||
|
||||
#### Scenario: 分页查询成功
|
||||
|
||||
- **WHEN** 调用方请求客户列表且传入合法分页参数
|
||||
- **THEN** 系统返回当前租户下未删除客户的分页数据,且不包含其他租户客户
|
||||
|
||||
### Requirement: 客户新增与身份证去重
|
||||
|
||||
系统 SHALL 支持创建客户,字段包含客户姓名(必填)、性别、身份证号、年龄、联系电话、客户标签(逗号分隔)、备注等,与 `biz_customer` 一致。当 `id_card_no` 非空时,系统 SHALL 拒绝在同一租户下创建与已有未删除客户相同身份证号的新记录,并返回可展示的业务错误信息。
|
||||
|
||||
#### Scenario: 同名不同人可共存
|
||||
|
||||
- **WHEN** 请求创建客户 A 与客户 B,二者 `customer_name` 相同但 `id_card_no` 不同或其一为空
|
||||
- **THEN** 系统允许分别创建成功
|
||||
|
||||
#### Scenario: 身份证重复拒绝创建
|
||||
|
||||
- **WHEN** 请求创建客户且 `id_card_no` 与同一租户下已存在未删除客户相同
|
||||
- **THEN** 系统拒绝创建并返回明确提示(含义与需求「已创建」一致)
|
||||
|
||||
### Requirement: 客户修改与删除
|
||||
|
||||
系统 SHALL 支持按主键更新未删除客户的可编辑字段;更新时若修改 `id_card_no` 为非空,SHALL 执行与同租户未删除记录的去重校验(排除自身)。系统 SHALL 仅支持软删除(`del_flag=1`),SHALL NOT 物理删除业务数据。
|
||||
|
||||
#### Scenario: 更新身份证冲突
|
||||
|
||||
- **WHEN** 将某客户的 `id_card_no` 改为另一未删除客户已占用的号码
|
||||
- **THEN** 系统拒绝更新并返回业务错误
|
||||
|
||||
#### Scenario: 软删除客户
|
||||
|
||||
- **WHEN** 请求删除某客户主键
|
||||
- **THEN** 系统将对应行 `del_flag` 置为 1,列表默认查询不再返回该记录
|
||||
|
||||
### Requirement: Web 管理端客户维护页
|
||||
|
||||
Web 管理端 SHALL 提供客户列表页,包含筛选(至少支持姓名/电话/身份证关键字类检索,若后端实现)、工具栏、表格、分页;SHALL 提供新增与编辑弹窗表单(`label-position="top"`,布局符合 Fresh Mint 与项目 CRUD 弹窗规范)。
|
||||
|
||||
#### Scenario: 用户在页面完成一次新增
|
||||
|
||||
- **WHEN** 用户在客户页填写合法信息并提交保存
|
||||
- **THEN** 系统调用后端创建接口并在成功后刷新列表
|
||||
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 样本编码池列表与分页
|
||||
|
||||
系统 SHALL 提供 `biz_sample_code` 的分页查询,租户隔离且排除已软删记录,默认排序 SHALL 为 `create_time DESC`。
|
||||
|
||||
#### Scenario: 列表查询
|
||||
|
||||
- **WHEN** 请求样本编码池列表
|
||||
- **THEN** 返回当前租户未删除记录分页数据
|
||||
|
||||
### Requirement: 样本编码租户内唯一
|
||||
|
||||
系统 SHALL 保证 `sample_code` 在同一租户、未删除记录范围内唯一;冲突时拒绝写入并返回业务错误。
|
||||
|
||||
#### Scenario: 重复样本编码
|
||||
|
||||
- **WHEN** 创建或更新导致与同租户另一未删除记录 `sample_code` 相同
|
||||
- **THEN** 系统拒绝并返回错误
|
||||
|
||||
### Requirement: 是否下发字段维护
|
||||
|
||||
系统 SHALL 支持维护 `issued` 字段,取值为 0(否)或 1(是)。
|
||||
|
||||
#### Scenario: 更新下发标记
|
||||
|
||||
- **WHEN** 请求将某条记录 `issued` 从 0 改为 1
|
||||
- **THEN** 系统持久化后查询结果反映新值
|
||||
|
||||
### Requirement: 样本编码池 CRUD 与 Web 页
|
||||
|
||||
系统 SHALL 支持样本编码池行的创建、更新、软删除。Web 管理端 SHALL 提供列表与表单弹窗完成上述维护。
|
||||
|
||||
#### Scenario: 用户在页面保存新样本编码
|
||||
|
||||
- **WHEN** 用户提交合法新样本编码与下发标记
|
||||
- **THEN** 系统创建成功且列表可见新行
|
||||
@@ -0,0 +1,35 @@
|
||||
## 1. 数据模型与 CRUD 基础
|
||||
|
||||
- [ ] 1.1 在 `backend/app/models` 中为 `biz_customer`、`biz_cell_sample_type`、`biz_collection_region`、`biz_collection_hospital`、`biz_collection_protocol_no`、`biz_sample_code` 增加 ORM 定义(字段顺序与公共字段对齐 `database-conventions`)
|
||||
- [ ] 1.2 新增 `backend/app/crud/` 模块(按 design 拆分文件或包),实现各实体的 list/get/create/update/soft_delete,查询统一 `tenant_no` + `del_flag=0`,列表默认 `create_time DESC`
|
||||
- [ ] 1.3 实现客户 `id_card_no` 非空时的租户内去重校验(创建与更新排除自身)
|
||||
|
||||
## 2. Pydantic Schema 与路由
|
||||
|
||||
- [ ] 2.1 在 `backend/app/schemas/` 为六类资源(含医院子资源)定义请求/响应模型
|
||||
- [ ] 2.2 新增 API 路由(建议 `app/api/basic_info.py` 或等价拆分),挂载客户、样本类型、地区、医院、协议号池、样本编码池端点;注册到 `app/api/__init__.py` 与 `app/main.py`
|
||||
- [ ] 2.3 为地区软删实现「同级联软删下属医院」事务逻辑(与 `collection-region-hospital-management` spec 一致)
|
||||
- [ ] 2.4 唯一约束冲突(`type_code`、`protocol_no`、`sample_code`、客户身份证)映射为可读 HTTP 4xx 与业务 message
|
||||
|
||||
## 3. 菜单与 SQL
|
||||
|
||||
- [ ] 3.1 在 `docs/sql/` 增加 Web 端菜单初始化脚本(五项菜单 + 合理 `order_num` / `parent_id`),权限标识与路由 path 对齐现有 `sys_menu` 惯例
|
||||
- [ ] 3.2 (可选)增加三种细胞样本类型初始数据的幂等 SQL,与需求 2.2.2 编码一致
|
||||
|
||||
## 4. Web 管理端 — API 与路由
|
||||
|
||||
- [ ] 4.1 在 `web-admin/src/api/` 增加封装上述端点的模块(如 `basicInfo.ts` 或分文件)
|
||||
- [ ] 4.2 在 `web-admin/src/router/` 注册五个页面路由(hash 路径与菜单 component 一致)
|
||||
|
||||
## 5. Web 管理端 — 页面实现
|
||||
|
||||
- [ ] 5.1 客户管理页:列表 + 筛选 + 新增/编辑弹窗(`label-position="top"`、`.dialog-form-layout`),对齐 `unit` 模板与 Fresh Mint
|
||||
- [ ] 5.2 细胞样本类型管理页:同上
|
||||
- [ ] 5.3 采集地区管理页:主表 +「地区医院配置」弹窗(内嵌医院表分页与 CRUD)
|
||||
- [ ] 5.4 采集协议号管理页:列表字段含协议号、`issued`、公共审计列
|
||||
- [ ] 5.5 样本编码管理页:同上
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- [ ] 6.1 本地启动后端,用 Swagger 或脚本验证各列表/创建/更新/软删与异常路径(身份证重复、编码重复、级联软删)
|
||||
- [ ] 6.2 `pnpm build` 或 `pnpm build:typecheck` 通过;关键页面手测无控制台错误
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-13
|
||||
@@ -0,0 +1,43 @@
|
||||
## Context
|
||||
|
||||
参见 `docs/superpowers/specs/2026-05-13-cell-storage-protocol-modules-design.md`。当前仓库已实现基础信息管理 CRUD,但 `biz_collection_protocol` 及相关缴费/审核表在后端尚无 Model/API。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 最小闭环:协议单列表 + 概要详情;审核记录;财务两确认;分期预警。
|
||||
- 租户隔离、软删除、默认排序符合 `zkxw-engineering-standards`。
|
||||
- 审核不改变 `protocol_status`;审核支持多条历史(A1)。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不对 2.2.6 全部子表做一次性完整表单。
|
||||
- 不实现退费发起与非 `jfqrz`/`tfqrz` 入口的财务动作。
|
||||
- 不用工作流引擎替换表驱动逻辑。
|
||||
|
||||
## Decisions
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| 建模范围 | 仅 `biz_collection_protocol`、`biz_protocol_audit`、`biz_protocol_payment`、`biz_protocol_payment_detail` | 方案 C,对齐已确认边界 |
|
||||
| API 前缀 | 延续 `/web/admin`,新路由文件例如 `collection_protocol.py`,在 `api/__init__.py` 注册 | 与 `basic_info.py` 一致 |
|
||||
| 审核 | 仅插入 `biz_protocol_audit` | 用户选定方案 A |
|
||||
| 财务确认 | 列表过滤固定状态;POST 校验当前行状态后更新 | 防止误操作 |
|
||||
| 预警日期 | 服务器日期与 `installment_due_date`(YYYYMMDD)比较 | 简单可靠 |
|
||||
| 前端路由 | `/cell-storage/*` 五页 | 已定稿 |
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **详情信息不足** → 第一版标注「概要」,后续迭代补子表 Tab。
|
||||
- **无退费发起** → 退费确认页可能长期为空,依赖外部流程或后续变更。
|
||||
- **并发确认** → 依赖数据库行更新与状态校验;必要时可加重试提示。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- 无破坏性 schema 变更:部署顺序为后端 → 前端;可选执行菜单 SQL 补丁(若有)。
|
||||
- 回滚:下线路由与菜单项,禁用新 API 路由注册。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 协议单第一版是否允许「新建/编辑」:若需求未定,建议第一版只读列表 + 详情,创建单独后续变更。
|
||||
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
需求文档 `docs/中科兴皖需求V0.3.md` 中「细胞存储管理」下 2.2.6~2.2.10 尚未在 web-admin 与后端落地;业务依赖 `biz_collection_protocol` 及缴费/审核表。需要在租户隔离与既有 `/web/admin` 约定下交付列表、审核与财务确认、分期预警的最小可用闭环。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增一级菜单 **细胞存储管理**及 5 个子路由页面(与 `sqxp.ts`、`backend/app/api/menu.py` 静态菜单对齐)。
|
||||
- 后端新增采集协议单查询、审核写入、缴费明细财务确认、分期预警查询等 API;补充对应表的 SQLAlchemy Model 与 CRUD(方案 C:最小表集)。
|
||||
- 前端新增 `views/cell-storage/**` 五个列表页(及必要弹窗),对接上述接口;样式对齐 Fresh Mint 与 CRUD 模板页。
|
||||
- **不包含**:退费发起、协议单子表(采集信息/样本等)全量录入 UI;工作流引擎驱动本组菜单。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `cell-storage-collection-protocol`: 采集协议单列表与概要详情 API;Web 列表/详情(第一版以主表 + 缴费概要为主)。
|
||||
- `cell-storage-protocol-audit`: 协议附件类审核(写入 `biz_protocol_audit`,不修改协议单状态);多条审核历史(A1);审核列表与提交。
|
||||
- `cell-storage-receipt-confirm`: 财务收款确认列表(仅 `jfqrz`)与确认动作(`jfqrz`→`yjf`)。
|
||||
- `cell-storage-refund-confirm`: 财务退费确认列表(仅 `tfqrz`)与确认动作(`tfqrz`→`ytf`);不包含退费发起。
|
||||
- `cell-storage-installment-warning`: 分期应缴预警列表(仅 `dfj`、有分期序号;15 天内预警;逾期为应缴日早于今日)。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- (无)本变更为新增业务能力,不修改已归档基线 spec 的既有 REQUIREMENTS 表述。
|
||||
|
||||
## Impact
|
||||
|
||||
- **后端**:`backend/app/models/models.py`、`app/crud/`、`app/api/`(新建 `collection_protocol.py` 或合并至既有 router)、`app/api/__init__.py`、`app/main.py`。
|
||||
- **前端**:`web-admin/src/router/modules/sqxp.ts`、`web-admin/src/api/*.ts`、`web-admin/src/views/cell-storage/**`。
|
||||
- **菜单**:`backend/app/api/menu.py`。
|
||||
- **数据库**:沿用既有 DDL,原则上不新增表;若需菜单种子则可选 `docs/sql/` 补丁脚本。
|
||||
- **权威交叉**:`docs/superpowers/specs/2026-05-13-cell-storage-protocol-modules-design.md`。
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 租户内采集协议单分页列表
|
||||
|
||||
系统 SHALL 向已认证管理端用户提供采集协议单分页查询接口,数据来源为 `biz_collection_protocol`。查询 SHALL 限定当前用户所属 `tenant_no`,且默认排除 `del_flag=1`。列表默认排序 SHALL 为 `create_time DESC`(若无自定义排序)。列表 SHALL 至少包含协议号展示字段、协议状态、创建时间等便于识别的列。
|
||||
|
||||
#### Scenario: 仅返回本租户数据
|
||||
|
||||
- **WHEN** 用户请求采集协议单分页列表且租户为 T
|
||||
- **THEN** 响应中每条记录的 `tenant_no` SHALL 为 T,且不包含已软删除行
|
||||
|
||||
### Requirement: 采集协议单概要详情
|
||||
|
||||
系统 SHALL 提供按主键查询单条采集协议单的接口,返回 `biz_collection_protocol` 业务字段及公共字段。系统 SHOULD 在同响应或次级结构中附带同一协议单关联的缴费主表 `biz_protocol_payment` 概要(若存在),用于详情页展示缴费模式与金额类信息。
|
||||
|
||||
#### Scenario: 详情命中未删除记录
|
||||
|
||||
- **WHEN** 请求详情且 id 对应当租户下未删除协议单
|
||||
- **THEN** 系统返回该协议单数据;若存在缴费主表记录则一并返回概要
|
||||
|
||||
#### Scenario: 跨租户或已删除拒绝
|
||||
|
||||
- **WHEN** 请求详情 id 不属于当前租户或记录已软删除
|
||||
- **THEN** 系统返回 404 或与项目一致的业务错误,且不泄漏其他租户数据
|
||||
|
||||
### Requirement: Web 采集协议单列表与概要详情页
|
||||
|
||||
Web 管理端 SHALL 提供采集协议单列表页(筛选、工具栏、表格、分页),对齐 `web-admin` 标准列表模板与 Fresh Mint 规范。SHALL 支持打开概要详情(抽屉或弹窗),展示主表及缴费概要;第一版不要求录入全部子表字段。
|
||||
|
||||
#### Scenario: 用户打开列表页
|
||||
|
||||
- **WHEN** 用户进入「采集协议单」菜单路由
|
||||
- **THEN** 页面加载分页列表并可通过操作查看概要详情
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 分期预警数据范围
|
||||
|
||||
系统 SHALL 仅统计满足以下条件的 `biz_protocol_payment_detail` 行:当前租户、`del_flag=0`、`pay_status = dfj`(待缴费)、`installment_no` 非空(分期明细)。系统 SHALL NOT 将 `pay_status = jfqrz` 或其他状态纳入本列表。
|
||||
|
||||
#### Scenario: 待缴费过滤
|
||||
|
||||
- **WHEN** 请求分期预警列表
|
||||
- **THEN** 返回行均满足 `dfj` 且存在分期期序号
|
||||
|
||||
### Requirement: 预警与逾期判定
|
||||
|
||||
系统 SHALL 使用服务器当前日期与 `installment_due_date`(存储为 `YYYYMMDD` 字符串)比较。对每一行 SHALL 可判定:
|
||||
|
||||
- **逾期**:应缴日早于当天;
|
||||
- **预警**:应缴日在当天或未来 15 天(含)内,且未逾期。
|
||||
|
||||
系统 SHALL 支持按「预警 / 逾期 / 全部」类筛选参数过滤(具体 query 名称以实现为准,须在前后端一致)。
|
||||
|
||||
#### Scenario: 超出 15 天未到期不标为预警
|
||||
|
||||
- **WHEN** 某分期应缴日晚于今天起算第 16 天
|
||||
- **THEN** 该行在仅查「预警」类结果中 SHALL NOT 出现(「全部」是否出现由产品约定;默认实现可仍列出但标签非预警)
|
||||
|
||||
### Requirement: Web 分期缴费预警页
|
||||
|
||||
Web 管理端 SHALL 提供「分期缴费用户预警」列表页,展示应缴日、预警/逾期标签、关联协议单标识等;对齐标准列表模板与 Fresh Mint 规范。
|
||||
|
||||
#### Scenario: 用户查看逾期列表
|
||||
|
||||
- **WHEN** 用户选择逾期筛选并加载列表
|
||||
- **THEN** 展示的行均为应缴日早于今天的待缴费分期明细
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 审核记录写入且不修改协议单状态
|
||||
|
||||
系统 SHALL 支持对指定采集协议单提交审核结果,持久化到 `biz_protocol_audit`,字段包含审核状态(`shtg` 审核通过、`shth` 审核退回)、审核备注、审核人(当前用户)及关联协议单 ID。系统 SHALL NOT 因审核提交而修改 `biz_collection_protocol.protocol_status` 或协议单其他状态字段。
|
||||
|
||||
#### Scenario: 提交审核成功写入一行
|
||||
|
||||
- **WHEN** 审核人对某协议单提交合法审核结果
|
||||
- **THEN** 系统插入一条 `biz_protocol_audit` 记录且协议单主表状态字段保持不变
|
||||
|
||||
### Requirement: 同一协议单多条审核历史
|
||||
|
||||
系统 SHALL 允许同一 `collection_protocol_id` 存在多条审核记录(按时间保留历史),SHALL NOT 以唯一约束强制单行覆盖。
|
||||
|
||||
#### Scenario: 同一协议单两次审核
|
||||
|
||||
- **WHEN** 同一协议单先后两次提交审核
|
||||
- **THEN** 系统存在两条审核记录且均可被列表查询到
|
||||
|
||||
### Requirement: 审核列表查询
|
||||
|
||||
系统 SHALL 提供审核记录分页列表接口,限定当前租户未删除数据,并支持按关键字(如协议号关联合理性由实现约定)、审核状态筛选。默认排序 SHALL 符合项目列表约定。
|
||||
|
||||
#### Scenario: 列表仅含本租户审核
|
||||
|
||||
- **WHEN** 请求审核列表
|
||||
- **THEN** 返回记录均属当前租户且 `del_flag=0`
|
||||
|
||||
### Requirement: Web 采集协议单审核页
|
||||
|
||||
Web 管理端 SHALL 提供「采集协议单审核」页面,包含列表与审核操作入口(通过/退回及备注);弹窗表单 SHALL 符合 `label-position="top"` 与 CRUD 弹窗规范。
|
||||
|
||||
#### Scenario: 用户完成一次审核提交
|
||||
|
||||
- **WHEN** 用户填写审核结论并提交
|
||||
- **THEN** 系统调用审核接口成功后刷新列表或局部更新
|
||||
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user