This commit is contained in:
zlwu committed 2026-05-17 08:36:37 +08:00
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]
@@ -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天) / 已逾期(&lt;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
+7 -2
View File
@@ -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`.
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
9695
92212
+12
View File
@@ -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
View File
@@ -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)}")
+860
View File
@@ -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))
+513
View File
@@ -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})
+24
View File
@@ -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})
+205
View File
@@ -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
View File
@@ -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},
},
],
},
]
+598
View File
@@ -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 -1
View File
@@ -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
+514
View File
@@ -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,
})
+1 -1
View File
@@ -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"
+174
View File
@@ -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)))
+344
View File
@@ -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
+339
View File
@@ -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)))
+308
View File
@@ -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)))
+194
View File
@@ -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)))
+1 -1
View File
@@ -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]
+2 -2
View File
@@ -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]
+1 -1
View File
@@ -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()
+30
View File
@@ -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]
+238
View File
@@ -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)))
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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()
+144
View File
@@ -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()
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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()
+6 -7
View File
@@ -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,
+341
View File
@@ -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)))
+1 -1
View File
@@ -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()
+356
View File
@@ -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]
+1 -1
View File
@@ -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()
+87 -7
View File
@@ -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
+760
View File
@@ -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
View File
@@ -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")
+699 -2
View File
@@ -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="删除标记")
+13
View File
@@ -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
+40
View File
@@ -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
+115
View File
@@ -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
+1 -1
View File
@@ -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]]
+106
View File
@@ -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,
)
+389
View File
@@ -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 {}
+1
View File
@@ -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()
+17
View File
@@ -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已作废';
+83
View File
@@ -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;
-20
View File
@@ -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);
-24
View File
@@ -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);
-24
View File
@@ -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);
-28
View File
@@ -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);
-24
View File
@@ -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
View File
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
View File
@@ -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、洁净室管理
- 此模块用于洁净室信息录入 和 人员出入数据录入就行了
BIN
View File
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** 系统调用更新接口且列表展示更新后名称
@@ -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** 系统创建成功且列表可见新行
@@ -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`。
@@ -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** 页面加载分页列表并可通过操作查看概要详情
@@ -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** 展示的行均为应缴日早于今天的待缴费分期明细
@@ -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