Compare commits

..
Author SHA1 Message Date
zlwuandCursor 8020e667f1 fix: 多张大图并行上传时不再拆掉进行中的请求,并放宽附件上传超时。
审批附件在第一张成功后会重建上传组件,其余请求被取消并显示超时。现保留进行中的文件,附件请求与 Nginx 上传超时放宽到 5 分钟,存储写入不再堵住事件循环。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 10:42:07 +08:00
zlwuandCursor 50d56ce0bb chore: 添加仓库根目录实验室场景图 fmtp.png。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 09:37:50 +08:00
zlwuandCursor 4efd423d2d chore: 同步提交后自动更新的知识图谱。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:52:38 +08:00
zlwuandCursor 5e563a6669 fix: 子表保存改为就地同步,避免整批换 id 打断外键。
协议样本、生产产出和盘点明细按行更新并保留原 id;补齐存储证明质检预填、离职人员显示名,以及样本采集通知模板字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:51:53 +08:00
zlwuandCursor d66e55af65 fix: 小程序新增样本发微信通知,并放宽部分 Web 菜单强校验。
移动端协议保存后调度样本采集服务号通知;收款确认/储存证等写接口改为运营端登录即可。同步日期字段规范化与 OA 日期选择器复开修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 16:02:47 +08:00
zlwuandCursor 045ee8d0e3 fix: Drone 打包改用阿里云 Alpine 源,避免 apk 偶发失败
国内机访问 dl-cdn.alpinelinux.org 会 Permission denied,导致 bash/python3 装不上整包失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:18:58 +08:00
zlwuandCursor 4583c54568 chore: 同步 web-admin graphify manifest
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:02:43 +08:00
zlwuandCursor 5dad1bb0e3 fix: 协议子资源单事务保存,并补齐样本编码/OA/质检等近期能力
锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:02:25 +08:00
zlwuandCursor 19bc66ba45 docs: Drone runner 增加 hzdyjs 发版目录与 Maven 公共仓库挂载
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 11:39:47 +08:00
zlwuandCursor 076e892df9 fix: Drone 打包命令对自定义环境变量使用 $${},避免版本被预处理成空
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 11:09:24 +08:00
zlwuandCursor 07508e677b fix: Drone 流水线去掉与 runner 重复的 host volume 挂载
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 10:57:01 +08:00
zlwuandCursor bab304abd3 fix: Drone 打包步骤去掉 privileged,避免未 trusted 仓库被拒绝
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 10:53:05 +08:00
zlwuandCursor 6a9a8bddd6 fix: Drone 生产包镜像与文件名固定为 v1.1.0,便于现网部署
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 10:51:20 +08:00
zlwuandCursor c2c5078ee6 feat: Drone 仅在 tag 时打生产 Docker 交付包到 /root/zkxw-releases
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 10:44:42 +08:00
zlwuandCursor ef7ea3c190 docs: 补充 Drone 构建 swap 与 clone depth 说明
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 16:15:29 +08:00
zlwuandCursor 61fdb6ebcd fix: Drone web-admin 构建提高 Node 堆到 3GB 以降低 OOM
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 16:07:51 +08:00
zlwuandCursor 5651bcd85e fix: Drone 恢复默认 clone(depth=1),去掉慢速 apt 安装 git
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 16:02:01 +08:00
zlwuandCursor 17f3948dc7 docs: Drone 对外端口改为 18088
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 15:58:57 +08:00
zlwuandCursor 85f99ed757 fix: Drone 分步构建并限制 Node 堆,避免测试机 OOM
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 15:55:59 +08:00
zlwuandCursor b8b1631d46 fix: Drone 流水线去掉 pipefail,兼容 /bin/sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 15:35:55 +08:00
zlwuandCursor 97d73d835b fix: Drone 流水线改为自 clone,避免拉取 drone/git 失败
关闭默认 clone,使用预拉的 node:20-bookworm;文档补充 daoCloud 预拉步骤。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 15:27:49 +08:00
zlwuandCursor a51d4e10aa feat: 测试机 Gogs 对接 Drone CI,仅编译打包不部署
同机部署模板与 .drone.yml(web-admin/OA H5/backend 产物),入口端口 18080。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 15:14:09 +08:00
zlwuandCursor 33c24129b4 chore: 同步提交钩子更新后的知识图谱报告
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:23:35 +08:00
zlwuandCursor 063727a7d8 feat: 完善协议缴费分期、样本编码搜索与鸿蒙附件选择
支持移动端样本编码远程关键字检索、分期起始日刷新与鸿蒙微信附件选取,并同步收款账户选择、角色页与相关设计文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:23:17 +08:00
zlwuandCursor 6e2656f999 fix: 顺丰月结导出失败时只提示一次,且不把其它错误说成没有数据
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 22:16:12 +08:00
zlwuandCursor a81f177cb6 feat: 增加顺丰月结列表,支持导出、结算和补填运费
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 20:17:45 +08:00
zlwuandCursor 77e8c78cb6 fix: 移动端关联已有顺丰批次显示月结
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 20:08:13 +08:00
zlwuandCursor 9b34f9e1ba feat: 移动端和小程序的协议单、运费信息在选择顺丰时改为月结并填写单号
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 19:49:57 +08:00
zlwuandCursor 0fb3ee50e3 fix: 运费列表保留金额格式,空顺丰运费显示月结
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 19:31:11 +08:00
zlwuandCursor d259004f9d fix: 关联已有和协议查看里的空顺丰运费显示月结
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 19:23:33 +08:00
zlwuandCursor 9561f94074 feat: 网页配置顺丰运费时改为月结并填写顺丰单号
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 19:20:43 +08:00
zlwuandCursor afcb4e9ebe fix: 收款账户下拉在搜索无结果时不再说成没有账户
有账户但关键字未命中时显示无匹配数据,只有列表本身为空才提示暂无本人的收款账户。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 19:17:54 +08:00
zlwuandCursor 0506fe7e8a fix: 发起审批时说明当前用户还没有收款账户
两端选择器都只展示 options 接口返回的本人账户,空列表不再像是系统没有账户。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 19:13:30 +08:00
zlwuandCursor 545a4c5fc1 feat: 把顺丰月结菜单放在运费信息和缴费财务确认之间
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 19:08:43 +08:00
zlwuandCursor d37bb41948 feat: 收款账户管理页展示不可修改的所属人
列表和查看能看出登记人,编辑时只读,避免把账户转给别人。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 19:08:32 +08:00
zlwuandCursor f76ef8a6e5 feat: 发起审批时拒绝不属于发起人的收款账户
直接发起和草稿提交都走 start_instance,因此只在这一处核对账户归属。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 19:03:38 +08:00
zlwuandCursor 41e8d6f7c4 feat: 增加顺丰月结列表、结算和补填接口,市场端不能调用
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 18:54:16 +08:00
zlwuandCursor 7aa685840f feat: 收款账户选项只返回当前登记人并带上所属人姓名
发起校验能识别已删除、他租户和他人账户,编辑账户不会改掉 create_by。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 18:45:43 +08:00
zlwuandCursor bd669ea77e fix: 已结算运费在协议保存时拒绝取消关联
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 18:41:31 +08:00
zlwuandCursor 96468f3c48 test: 锁定收款账户按登记人过滤和发起归属校验
这些用例先失败,用来约束后续的选项接口和管理页展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 18:37:52 +08:00
zlwuandCursor 11a46bf1eb feat: 顺丰运费保存单号并清空金额,已结算批次禁止改关联
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 18:25:54 +08:00
zlwuandCursor d67965aae2 fix: 样本上的空运费按空返回,避免顺丰月结显示成 0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 18:10:46 +08:00
zlwuandCursor dd00dba39c fix: 运费金额为空时返回空值,便于顺丰批次展示月结
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 18:08:11 +08:00
zlwuandCursor 92c59d67b4 feat: 为运费批次增加顺丰单号和结算字段,并补上顺丰月结菜单
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:58:43 +08:00
zlwuandCursor 9325f8cf61 feat: 抽出顺丰月结的单号、金额和结算规则,供网页与市场端共用
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:45:38 +08:00
zlwuandCursor ac8e471aca fix: align sample registration, freight scope, and OA datetime display
Let customer-service quick register write storage, certificate, and insurance separately without extra menu or data-scope checks. List freight by document data scope like the web admin, allow protocol staff outside the collection region, and pad OA datetimes to seconds so PC date pickers can show historical minute values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 17:30:30 +08:00
zlwu 03fcc01d6d 提交 2026-10-17 17:36:14 +08:00
zlwuandCursor 3ceee48c00 style(web-admin): skin Fresh Mint info tags as barley soft.
Align page/dialog el-tag--info with mobile market neutral tags
(#fff7e8 / #9a7020) so storage-service pending states match H5.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:08:03 +08:00
zlwuandCursor 19dffbaa49 fix(mobile-oa-h5): skin market neutral tags as barley soft.
Map Element Plus info tones through Vant default and style them as
浅底大麦黄 so unpaid/pending tags stay visible and distinct from solid semantic tags.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:05:46 +08:00
zlwu be2226e4ca 提交 2026-10-17 11:46:53 +08:00
zlwuandCursor 48dcf991dc fix(mobile-oa-h5): finish storage-service tag tone parity for market details
Wire detail header statusTone for protocol/payment/freight/chase from the
registry mirror, stop hardcoding primary on nested tags, and map 已过期 to
warning so title and field colors stay consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 11:27:29 +08:00
zlwuandCursor d355cd0640 feat(mobile-oa-h5): inline payment detail transfers aligned with web dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 11:12:23 +08:00
zlwuandCursor c825c170e7 refactor(mobile-oa-h5): extract ScheduleTransferEditor for shared transfer UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 11:04:14 +08:00
zlwuandCursor 9de7f5456e refactor(mobile-oa-h5): allow transfer helpers to use payment contextId
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 10:56:43 +08:00
zlwuandCursor e2fee1d9c5 feat(mobile-oa-h5): open protocol from payment as readonly detail
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 10:55:59 +08:00
zlwuandCursor 80d238d31d feat(mobile-oa-h5): add payment-entry helpers for readonly protocol view
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 10:53:11 +08:00
zlwuandCursor 526c9029c8 fix(web-admin): rename presign collector label to 市场专员
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 17:45:30 +08:00
zlwuandCursor b545e5987c fix(web-admin): remove collector staff from payment confirm timeline
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 17:45:30 +08:00
zlwuandCursor d85c70a860 fix(web-admin): hide collector staff on research donation UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 17:45:30 +08:00
zlwuandCursor b76a7d65be fix(web-admin): hide collector staff on collection protocol UI
Retain silent collectorUserId defaults and edit hydration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 17:45:30 +08:00
zlwuandCursor d998378e12 fix(mobile-oa-h5): rename presign collector label to 市场专员
Keep collectorUserId for convert-to-protocol mapping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 17:44:41 +08:00
zlwuandCursor 90c1b83a54 fix(mobile-oa-h5): hide collector staff on protocol UI
Keep collectorUserId in form state as a hidden field with create defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 17:39:39 +08:00
zlwuandCursor 75f00a6472 docs: add plan for hiding collector staff UI
Break down protocol/donation hide, presign rename to 市场专员, and silent collectorUserId write rules into TDD tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 17:38:14 +08:00
zlwuandCursor 241dd2861c docs: specify hide collector staff UI with presign rename
Capture the agreed design: hide collector displays on protocol/donation/timeline, keep DB/API fields with silent defaults, and rename presign visible labels to 市场专员 while retaining collectorUserId for convert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 17:33:59 +08:00
zlwuandCursor d29a18db86 chore(graphify): refresh graphs after OA bank receipt feature
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:58:44 +08:00
zlwuandCursor 3a148b2985 feat(web-admin): add bank receipt upload on OA done center
Show permission-gated upload action and wire the receipt dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:58:24 +08:00
zlwuandCursor 6c8b8025d7 feat(web-admin): add OA bank receipt upload dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:58:24 +08:00
zlwuandCursor 209fb7bff3 feat(web-admin): add OA bank receipt API clients
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:58:24 +08:00
zlwuandCursor e5ae28f0c7 docs(sql): seed oa:done:bank-receipt button permission
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:58:24 +08:00
zlwuandCursor 0402f797c0 feat(oa): expose bank-receipts GET/PUT with menu permission
Gate instance bank receipt APIs behind oa:done:bank-receipt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:58:24 +08:00
zlwuandCursor b042266c5b fix(oa): stabilize bank receipt list order via order_num
Preserve PUT item sequence when create_time ties on replace.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:56:09 +08:00
zlwuandCursor 5e38dc1eca feat(oa): add bank receipt attachment service for instances
Store multiple bank receipts on biz_attachment keyed by instance id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:49:33 +08:00
zlwuandCursor 00a315b61c docs(plan): OA done-center bank receipt upload implementation
Break the approved design into TDD tasks for service, API, SQL, and web-admin UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:47:18 +08:00
zlwuandCursor 35532eceee docs(spec): design OA done-center bank receipt upload
Capture approved approach A (biz_attachment + permission-gated dialog)
and the Fresh Mint HTML demo for the done-center upload flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:44:20 +08:00
zlwuandCursor a148961b55 feat(oa): add department filter on history list and export
Allow filtering OA history by initiator department including descendants, while keeping the existing all-history permission scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:23:18 +08:00
zlwuandCursor 6cd22b218d docs(plan): OA history department filter implementation
Break down backend tests, engine/API wiring, and web-admin filter UI into executable tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:16:15 +08:00
zlwuandCursor 3ecd8f4a74 docs(spec): design OA history department filter
Add confirmed design for initiator-dept tree filter on the web-admin OA history list and export APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:13:44 +08:00
zlwu 06d021807b 提交 2026-10-15 23:21:07 +08:00
zlwu ecde2ba07d 提交 2026-10-15 14:27:42 +08:00
zlwuandCursor b9d981d46b fix(mobile-oa-h5): finish tagType wiring for market detail sections
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 10:07:32 +08:00
zlwuandCursor 2b5f2f939c fix(mobile-oa-h5): align presign and freight tags with registry mirror
Use info for draft; full payMode codes; receptionSource/transportType colors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 10:06:07 +08:00
zlwuandCursor 7ba336169a fix(mobile-oa-h5): align protocol and chase tags with registry mirror
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 10:01:43 +08:00
zlwuandCursor 79f9c8785b fix(mobile-oa-h5): align payment list/detail tags with registry mirror
Prefer progressStatus codes and explicit tagType over Chinese heuristics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:57:21 +08:00
zlwuandCursor 87f1e9a4eb feat(mobile-oa-h5): add market status-tag registry mirror
Mirror Web status-tag-registry for market modules with resolveStatusTag and parity tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:52:54 +08:00
zlwuandCursor a7a5a914f1 fix(web-admin): align freight source and transport tags with registry
Use receptionSource and transportType tag types instead of hard-coded success/primary plain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:51:26 +08:00
zlwuandCursor 920b432491 fix(web-admin): use registry tag types for protocol payMode and auditStatus
Align ProtocolViewDialog tags with status-tag-registry instead of hard-coded success/warning.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:50:45 +08:00
zlwuandCursor b592aaec2e feat(web-admin): add paymentScheduleStatus to status-tag registry
Lift schedule open/cleared/void labels from PaymentBySampleTab so Web and mobile share one source.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:47:18 +08:00
zlwuandCursor 012527ed35 docs: mark payment detail transfer display design as implemented
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 15:24:34 +08:00
zlwuandCursor 9d7a72673d feat(mobile-oa-h5): restyle payment schedule transfers as amount-first cards
Render schedule cards with metric grids and white transfer cards (amount, method·time, notes) in payment and protocol detail views.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 15:24:25 +08:00
zlwuandCursor 5ce18e56a0 docs: design payment detail transfer cards as white amount-first layout
Capture approved option A demo and implementation outline for schedule/transfer hierarchy in payment detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 15:17:21 +08:00
zlwuandCursor f045841f1a fix(mobile-oa-h5): drop duplicate pay-mode tag on protocol detail payment cards
Sample payment cards already show 缴费模式 in the field list; remove the redundant status tag beside the sample code title.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 15:11:02 +08:00
zlwuandCursor 82b8a9c1b2 fix(mobile-oa-h5): slim payment list card fields to a two-column grid
Drop protocol number, storage years, and discount amount; remove wide spans so summary fields sit two per row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 15:08:49 +08:00
zlwuandCursor 6eafec3fa9 test(mobile-oa-h5): align payment Excel-column parity with periodBlocks
Assert installment amount labels on the list card and periodBlocks/footerFields in the presenter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 15:03:29 +08:00
zlwuandCursor 321dd3ca43 feat(mobile-oa-h5): collapse payment installment rows on list cards
Render periodBlocks as independent expandable summary rows with footer totals after the period section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 14:41:34 +08:00
zlwuandCursor 4e586e905b feat(mobile-oa-h5): present payment periods as collapsible periodBlocks
Stop flattening six installment field groups into list cards; emit periodBlocks and footerFields instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 14:40:00 +08:00
zlwuandCursor bbc24b7c73 docs: add implementation plan for payment card period collapse
Break down presenter periodBlocks, MarketListCard UI, CSS, and TDD steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 14:26:45 +08:00
zlwuandCursor 42dde4b0bc docs: design payment list card period collapse for mobile market H5
Spec and interactive demo for collapsing payment installments on list cards (summary row + expand details).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 14:24:12 +08:00
zlwu 3950b5c334 提交 2026-10-14 12:32:03 +08:00
zlwuandCursor a2125374a8 fix(mobile-oa-h5): limit presign detail bottom bar to edit convert delete
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 12:13:23 +08:00
zlwuandCursor 6e190665c0 test(mobile-oa-h5): expect presign detail actions to be order-level only
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 12:12:59 +08:00
zlwuandCursor f040f579d1 docs: design presign detail bottom bar with order-level actions only
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 12:09:18 +08:00
zlwuandCursor e16afdee95 feat(web-admin): auto-submit paid earnest transfers after saving presign order
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 12:04:22 +08:00
zlwuandCursor e56d8d7f51 feat(web-admin): add presign save auto-submit helpers and silent finance submit option
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 12:02:24 +08:00
zlwuandCursor 0398cb3897 feat(mobile-oa-h5): auto-submit paid earnest transfers after presign save
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 12:00:48 +08:00
zlwuandCursor ee95036666 feat(mobile-oa-h5): add helpers to auto-submit dfj presign transfers on save
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 11:58:11 +08:00
zlwuandCursor b217ffcdca docs: design presign save auto-submit finance confirmation
Document frontend orchestration so saving a presign order submits all dfj earnest transfers on web and mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 11:51:11 +08:00
zlwuandCursor e4ec917a03 style(mobile-oa-h5): JD-style left status dropdown on presign list
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 10:55:55 +08:00
zlwuandCursor 63f1d7c2f9 fix(mobile-oa-h5): remove reset button from presign list filters
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 10:53:33 +08:00
zlwuandCursor c767373a8f test(mobile-oa-h5): expect JD-style presign status filter without reset
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 10:51:17 +08:00
zlwuandCursor 8dd816ee1d docs: plan JD-style status filter for mobile presign list
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 10:49:47 +08:00
zlwuandCursor 1c53bcd226 docs: design JD-style status filter for mobile presign list
Remove the redundant reset button and adopt a left-aligned lightweight dropdown under the search bar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 10:49:03 +08:00
zlwu f5c514c520 docs: plan complete mobile market parity implementation 2026-10-13 19:41:55 +08:00
zlwu f5daab0d44 docs: define complete mobile market parity scope and boundaries 2026-10-13 19:37:15 +08:00
zlwuandCursor a7b8560f5f feat(mobile-oa-h5): integrate sample payment panel into protocol form
Validate and persist samplePayments on cell_storage protocol save.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 15:30:22 +08:00
zlwuandCursor 3354e66d82 feat(mobile-oa-h5): add ProtocolSamplePaymentPanel for mobile protocol forms
Mobile card UI for per-sample pay modes, schedules, and autofill rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 15:30:22 +08:00
zlwuandCursor 20a8bf538a feat(mobile-oa-h5): wire samplePayments into protocol payload and detail
Drop read-only payment stubs; detail shows per-sample payment cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 15:30:22 +08:00
zlwuandCursor 66e1c8334c feat(mobile-oa-h5): port protocol sample payment helpers
Share web-aligned installment autofill and mode-switch helpers for
mobile protocol forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 15:28:39 +08:00
zlwuandCursor be22c37196 feat(backend): persist and return samplePayments for market protocols
Mobile protocol create/update saves v2 per-sample payments; detail
includes samplePayments for edit hydration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 15:23:24 +08:00
zlwuandCursor 9c61f36db8 feat(backend): accept samplePayments on market protocol body
Whitelist per-sample payment payload for mobile protocol create/update.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 15:21:07 +08:00
zlwu cc843535f5 提交 2026-10-13 15:18:01 +08:00
zlwuandCursor d08f03ab03 docs: add mobile protocol payment parity P1 implementation plan
Six TDD tasks covering backend samplePayments, shared helpers, and
mobile ProtocolSamplePaymentPanel integration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 15:16:32 +08:00
zlwuandCursor 6d56572f8b docs: add mobile protocol payment parity P1 design
Phased plan to align mini-program protocol payment editing with
web SamplePaymentPanel while keeping admin actions closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 15:13:04 +08:00
zlwuandCursor 618d8fe21c feat(web-admin): wire fq last-period and dj deadline autofill in SamplePaymentPanel
Auto-recalculate the final installment due amount when earlier periods
change (with manual override), and sync lump-sum deadline to due date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:53:11 +08:00
zlwuandCursor 6ec4ec6730 feat(web-admin): sync lump-sum deadline to last due date
Add applyLumpSumDeadlineToLastDueDate for dj payment schedules,
skipping locked or void rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:50:41 +08:00
zlwuandCursor 5fd5550e0d feat(web-admin): add last-period due amount calc helpers
Pure functions for fq auto-fill of the final installment amount
with override/lock gates, covered by unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:48:43 +08:00
zlwuandCursor 13ac45d10d docs: add protocol payment last-period autofill implementation plan
Bite-sized TDD tasks for SamplePaymentPanel fq/dj autofill helpers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:44:51 +08:00
zlwuandCursor 0efcd6a6e5 docs: add protocol payment last-period autofill design
Document confirmed rules for fq last-period due amount auto-fill
and dj lump-sum deadline to due date sync in SamplePaymentPanel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:43:07 +08:00
zlwuandCursor 08150167a8 docs: mark research donation list excel parity design as implemented
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:17:20 +08:00
zlwuandCursor 6c66eb164e chore: update backend graphify after monthly verify no
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:14:25 +08:00
zlwuandCursor 99e71096ac feat(mobile-oa-h5): align research donation protocol card with excel fields
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:14:09 +08:00
zlwuandCursor 0ab0774e8a feat(web-admin): align research donation audit list with excel columns
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:14:09 +08:00
zlwuandCursor aa231ea5f8 feat(web-admin): flatten research donation excel-parity list fields
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:14:09 +08:00
zlwuandCursor bee2c0d193 feat(backend): persist monthly verify no and expose research list fields
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:14:09 +08:00
zlwuandCursor 60efc5d5d3 feat(backend): allocate research donation monthly verify numbers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:11:25 +08:00
zlwuandCursor e0ef78925a feat(db): add research donation monthly_verify_no column
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:05:54 +08:00
zlwuandCursor 56f379dd0c docs: add research donation list Excel parity implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:05:10 +08:00
zlwuandCursor c22c9467f5 docs: add research donation list Excel parity design
Capture confirmed field mapping, monthly verify number rules, and Web/mini list scope before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:03:04 +08:00
zlwuandCursor 22a6e1f4e4 docs: add customer-service management design and plan; note mobile follow-up.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 11:04:45 +08:00
zlwuandCursor efbf8f6ac9 feat: switch protocol view cert/insurance handle panels to sample level.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 11:04:25 +08:00
zlwuandCursor 8cebb8e0b8 feat(web-admin): customer-service toolbar and Excel-aligned wide list columns.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 11:02:11 +08:00
zlwuandCursor 1c225ec89d feat(web-admin): expand sample quick-register dialog for customer-service kinds.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:59:39 +08:00
zlwuandCursor ad18204eb9 feat: rename sample-info menu label and extend quick-register client API.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:58:23 +08:00
zlwuandCursor 9c264d7942 feat(backend): enrich cell-storage sample list with customer-service wide columns.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:57:55 +08:00
zlwuandCursor aeadaa9919 feat(backend): extend sample-info quick-register for sms/followup/fulfillment/all.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:56:41 +08:00
zlwuandCursor 50400ad19c feat(backend): write storage cert and insurance at sample level in quick-register.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:52:45 +08:00
zlwuandCursor edabf70287 feat(backend): upsert sample customer-service fields with empty-skip merge.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:50:36 +08:00
zlwuandCursor d676bfadd7 feat(backend): add customer-service dict list and ensure-on-write helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:47:11 +08:00
zlwuandCursor eee70e417c feat(backend): add models for customer-service and sample-level cert/insurance.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:45:54 +08:00
zlwuandCursor da35921f58 docs(sql): add sample-level storage cert and insurance with migrate-from-protocol.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:43:36 +08:00
zlwuandCursor 8618141c2f docs(sql): add customer-service tables and rename sample-info menu.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:42:34 +08:00
zlwuandCursor af992b5680 fix: drop 付款确认中 from sample payment progressStatus
Sample-level 缴费进度 is now only 待缴费 / 部分已缴 / 已缴清.
Transfer jfqrz still blocks canConfirm but no longer surfaces as progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 18:26:36 +08:00
zlwuandCursor cc0b217c89 feat(mobile-oa-h5): align transfer payStatus labels with 付款 wording
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 18:10:14 +08:00
zlwuandCursor 727002761d fix(backend): align pay-status user messages with 付款 wording
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 18:10:14 +08:00
zlwuandCursor a5bf4252b9 chore: rename payment-confirm menu to 缴费财务确认
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 18:10:14 +08:00
zlwuandCursor a6f56f3453 feat(web-admin): rename transfer pay statuses to 付款 wording
Use 已付款/付款确认中/付款完成 for payStatus; keep period money
labels 应缴费/已缴费/欠缴费 unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 18:10:13 +08:00
zlwuandCursor c71bb5a11c fix(web-admin): align schedule paid/arrears with yjf-only rule
Period 已缴费 now counts only 付款完成 transfers, matching backend
summarize_period.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 18:10:13 +08:00
zlwuandCursor d2bb1e0fa1 test(backend): align market chase assertions with yjf-only paid
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 18:06:22 +08:00
zlwuandCursor b8b20dcebe fix(backend): count period paid amount as yjf only
Align displayPaidAmount/arrears with business rule: 已缴费 excludes
已付款 and 付款确认中 transfers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 18:01:06 +08:00
zlwuandCursor a1be31c694 docs: add payment status and paid-amount semantics implementation plan
Break the confirmed spec into TDD tasks covering backend yjf-only paid
amounts, web/mobile label renames, and menu SQL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 17:50:54 +08:00
zlwuandCursor 06a30ff658 docs: specify payment status labels and paid-amount semantics
Clarify transfer statuses (已付款/付款确认中/付款完成) vs period money
(应缴费/已缴费/欠缴费), with 已缴费 counting only yjf, and rename the
finance confirm menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 17:47:48 +08:00
zlwuandCursor 97bf67c962 feat(mobile-oa-h5): rename freight unlink action to 取消运费
Also include the two-button freight config/cancel design spec and plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:07:51 +08:00
zlwuandCursor f659ec602e feat(web-admin): align sample-freight page cancel UX with two-button rules
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:07:39 +08:00
zlwuandCursor 8c0cbb8ab8 feat(web-admin): gate protocol freight config and unify cancel dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:07:07 +08:00
zlwuandCursor 75bae974e4 feat(web-admin): add shared freight unlink dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:06:28 +08:00
zlwuandCursor d72e28959d feat(web-admin): add shared freight unlink dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 13:49:18 +08:00
zlwuandCursor ce9d836194 feat(web-admin): add freight config sample partition helper
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 13:46:54 +08:00
zlwu 55e321f266 docs: document the market card overflow fix. 2026-10-11 16:22:20 +08:00
zlwu 54ec8e1cd1 fix: serialize mobile presign conversion amounts. 2026-10-11 14:02:20 +08:00
zlwu e0dc7f4cee fix: preserve mobile presign decimal precision. 2026-10-11 13:50:47 +08:00
zlwu f761c7021e fix: decouple mobile market API contracts from page routes. 2026-10-11 13:34:03 +08:00
zlwu 06255d1e41 fix: harden mobile presign contracts and authorization. 2026-10-11 13:22:04 +08:00
zlwu 0927a514d2 chore: add the mobile market contract dependency closure. 2026-10-11 13:14:56 +08:00
zlwu 0c4a2408ee docs: add the remaining mobile market parity demo. 2026-10-11 13:04:23 +08:00
zlwu a3a2ffcce0 fix: isolate mobile presign action scopes and transfer locks. 2026-10-11 12:59:22 +08:00
zlwu 7a750e7e81 docs: plan the remaining mobile market parity demo. 2026-10-11 12:51:26 +08:00
zlwu b28ed3e280 feat: add tested mobile presign contracts and form state. 2026-10-11 12:49:21 +08:00
zlwu 702ff0e074 docs: define the remaining mobile market parity demo design. 2026-10-11 12:49:19 +08:00
zlwu 81bda0cb79 fix: scope user organization labels to the current tenant. 2026-10-11 12:37:32 +08:00
zlwu 02d1e16036 fix: scope authentication roles to the current tenant. 2026-10-11 12:25:50 +08:00
zlwu 3f40caa1cd fix: enforce atomic presign status and action transitions. 2026-10-11 12:06:22 +08:00
zlwu 337d31bbe8 feat: integrate required mobile market backend dependencies. 2026-10-11 12:00:52 +08:00
zlwu 7e930c77fd feat: expose complete scoped presign actions to mobile clients. 2026-10-11 11:43:38 +08:00
zlwu 34bf9aa0dc fix: make mobile presign transitions concurrency-safe. 2026-10-11 11:31:24 +08:00
zlwu c5c7e42feb fix: make mobile presign transitions concurrency-safe. 2026-10-11 11:17:06 +08:00
zlwu 8453c9c805 feat: enforce scoped presign deletion and transfer workflows on mobile. 2026-10-11 10:56:06 +08:00
zlwu e8e278be86 docs: plan verified mobile presign parity implementation. 2026-10-11 10:45:55 +08:00
zlwu 99bd4749ea docs: define mobile presign parity design for consistent market workflows. 2026-10-11 10:42:33 +08:00
zlwu d20b9f5a20 docs: record backend graph verification constraints. 2026-10-11 10:25:58 +08:00
zlwuandCursor 3018d05bc4 feat(mobile-h5): show non-prod env stripe on home
Render a short local/test banner above the green header
so the active APP_ENV is obvious before WeChat upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 10:09:21 +08:00
zlwu 7a6d804cfa fix: honor order numbers in protocol workflow lists. 2026-10-11 10:08:40 +08:00
zlwuandCursor a47a7db7dc feat(mobile-h5): export ENV_BANNER from APP_ENV
Add buildEnvBanner so the homepage can show a non-prod
stripe without hardcoding environment labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 10:01:42 +08:00
zlwuandCursor f5c999f633 docs: plan mobile-h5 non-prod env banner implementation
Break the approved homepage stripe into TDD tasks for
config mapping and the index page UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 10:00:33 +08:00
zlwuandCursor 8c82f6a6dd docs: add mobile-h5 non-prod env banner design
Capture the approved homepage top-stripe approach so local/test
builds are visually distinct before WeChat upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 09:59:51 +08:00
zlwu 28990e66be test: align API crypto coverage with the AES-GCM protocol 2026-10-11 09:53:48 +08:00
zlwu 5a24f3b433 docs: plan the backend baseline test failure fixes. 2026-10-11 09:49:22 +08:00
zlwu b2ea79710c docs: describe how to resolve the existing backend test failures. 2026-10-11 09:47:52 +08:00
zlwuandCursor daace3ff74 docs: note OA-style UI and placeholder market menu icons
Record that market H5 pages follow OA approval UI patterns, and specialist/manager storage menu icons stay empty until assets are provided.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 22:03:35 +08:00
zlwuandCursor 8ef8981d71 docs: revise market miniapp design for split menus and mobile-oa-h5
Separate specialist/manager menu trees under 移动端菜单, host business pages in mobile-oa-h5 via web-view, and scope data per entry role.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 21:45:26 +08:00
zlwuandCursor b0a4b94d7d docs: add market miniapp role feature design for specialist and region manager
Capture confirmed data scopes, shared module matrix, freight redaction, and non-goals for the on-site mini program.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 21:43:16 +08:00
zlwuandCursor db31990ebe feat: 细胞存储缴费催缴、统一样本信息与协议支付能力增强
汇总近期业务改动:催缴菜单与后端、样本信息快捷登记与进度、运费关联、客户档案扩展、协议优惠/期次与押金划转,并同步 SQL 迁移与管理端页面收敛。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 21:20:37 +08:00
zlwu 2d3472f5c4 docs: document responsive payment detail dialog adjustments 2026-10-10 16:59:16 +08:00
zlwuandCursor f5fdfc6770 chore: update graphify after payment-info detail dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 15:20:13 +08:00
zlwuandCursor 416b1cefa2 fix(web-admin): avoid typed arrow in Vue template for transfer upload
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 15:16:17 +08:00
zlwuandCursor 842aa0847f feat(web-admin): payment-info 缴费明细 dialog with list-mode transfers
Open sample-level detail from the list; manage transfers like
SamplePaymentPanel; remove direct PaymentReceiptConfirmDialog path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 15:15:56 +08:00
zlwuandCursor ea5fba9dc2 feat(web-admin): add fetchDeletePaymentTransfer API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 15:14:07 +08:00
zlwuandCursor 628a20dbbd feat(backend): allow soft-delete of dfj payment transfers
Supports 缴费明细 list-mode row delete for draft transfers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 14:58:23 +08:00
zlwuandCursor 5025aa09c2 docs: add implementation plan for payment-info 缴费明细 dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 14:17:28 +08:00
zlwuandCursor 34c8a1a62d docs: add payment-info 缴费明细 list-mode transfer design
Spec and interactive demo for replacing 缴费确认 with a sample-level
detail dialog that manages transfers in SamplePaymentPanel list mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 14:16:26 +08:00
zlwuandCursor da63c228f5 docs: align dialog label-top spacing rules with Fresh Mint
Document 4px gap, weight 400, form-grid margin reset, and stock-check exception.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 13:49:28 +08:00
zlwuandCursor de132a7c04 refactor(web-admin): drop redundant label-top overrides in large form dialogs
Rely on global label-top and form-grid margin rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 13:46:47 +08:00
zlwuandCursor 674084c511 fix(web-admin): zero form-item margin when form-grid is on el-form
Cover label-top dialogs that put form-grid-* class on the form element.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 13:42:59 +08:00
zlwuandCursor 8b42cf1b90 fix(web-admin): tighten label-top row spacing for all dialog grids
Zero form-item margin inside .form-grid* and add dialog fallback spacing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 13:39:31 +08:00
zlwuandCursor 60ad9de191 fix(web-admin): lock label-top dialog label spacing and weight
Keep 4px gap and font-weight 400 for vertical form labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 13:37:51 +08:00
zlwuandCursor 32c742c1ed docs: add dialog label-top spacing unify spec and plan
Scope B for all label-top dialogs; keep compact 11px stock-check labels as exception.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 13:35:37 +08:00
zlwuandCursor 3edfb9dd86 chore: refresh graphify after transfer remark withdraw.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 12:21:24 +08:00
zlwuandCursor 8cbbd3548b feat(web-admin): transfer remark columns and jfqrz withdraw UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 12:19:46 +08:00
zlwuandCursor c860e39363 feat: add protocol payment transfer withdraw API for jfqrz.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 12:17:49 +08:00
zlwuandCursor eb8a76d8d2 feat: add presign transfer withdraw API for jfqrz.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 12:16:58 +08:00
zlwuandCursor d4477f357d feat: add can_withdraw_receipt for jfqrz transfer withdraw.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 12:12:03 +08:00
zlwuandCursor 70fb2219de docs: add transfer remark and withdraw implementation plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 12:10:01 +08:00
zlwuandCursor 9444945add docs: add transfer remark and jfqrz withdraw design.
Capture business/finance dual remarks on each transfer and business withdraw from 缴费确认中 while keeping finance_remark.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 12:07:20 +08:00
zlwuandCursor bce0a15772 chore: refresh graphify after storage-cert QC info.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:40:41 +08:00
zlwuandCursor df5206454f feat(web-admin): mount unified sample-info page for storage-cert QC entry.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:40:41 +08:00
zlwuandCursor 5d0e3d904a docs: add storage-cert QC info implementation plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:40:22 +08:00
zlwuandCursor 500a85465f feat(web-admin): add storage-cert QC view on cell-storage sample-info.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:40:16 +08:00
zlwuandCursor f1dcafd33d feat(web-admin): add storage-cert QC fill entry on QC work-order cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:40:16 +08:00
zlwuandCursor df827e4344 feat(web-admin): add shared storage-cert QC fill/view dialog.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:40:16 +08:00
zlwuandCursor 28b10663ee feat(web-admin): add storage-cert QC field metadata and API client.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:40:15 +08:00
zlwuandCursor 5546b7aa73 feat(api): expose GET/PUT sample-storage-cert-qc by sample code.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:37:16 +08:00
zlwuandCursor e194e1ad04 feat(backend): add sample storage-cert QC get and upsert CRUD.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:36:18 +08:00
zlwuandCursor e6763241d1 feat(backend): add storage-cert QC type-key and payload helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:33:50 +08:00
zlwuandCursor e521d781e3 fix(db): keep storage-cert QC model commit free of unrelated model edits.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:32:18 +08:00
zlwuandCursor fac7b87728 feat(db): add biz_sample_storage_cert_qc for storage-cert QC payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:31:13 +08:00
zlwuandCursor 55ed0d8929 docs: add storage-cert QC info design and HTML demo.
Capture the confirmed sample-level JSON payload approach, field maps from the Excel sheets, and UI entry points for QC fill and market view.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:22:23 +08:00
zlwuandCursor 776031cb4c chore: refresh graphify after protocol-audit list mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 10:10:03 +08:00
zlwuandCursor 884d2f4a41 feat(web-admin): switch cell-storage protocol audit to flat sample table.
Replace card list with Excel-aligned columns and protocol-deduped selection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 10:08:10 +08:00
zlwuandCursor 1e12783a41 fix(backend): drop unrelated pay-status label changes from transferTime commit.
Keep only transferTime exposure for protocol-audit list mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 10:02:39 +08:00
zlwuandCursor 38f9822d49 feat(backend): expose payment detail transferTime in protocol summary.
Let protocol-audit list show first-pay times from transfer records.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 10:00:41 +08:00
zlwuandCursor 0da75fb855 feat(web-admin): add protocol-audit sample-row flatten helpers.
Support Excel-aligned list mode for cell-storage protocol audit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 09:56:42 +08:00
zlwuandCursor 4904508d9f docs: add protocol-audit list-mode implementation plan.
Break work into flatten TDD, payment transferTime, and ProtocolAuditTab table swap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 09:49:47 +08:00
zlwuandCursor 8f00db9400 docs: add protocol-audit list-mode design and Excel-aligned demo.
Capture the confirmed flat sample-row list for cell-storage protocol audit, including co-storage rule and merged collect date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 09:46:10 +08:00
zlwuandCursor 702c8a2f12 docs: add customer profile expand and overwrite sync plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 18:00:02 +08:00
zlwuandCursor e69ddd0d32 docs: add customer profile expand and protocol overwrite sync design.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 17:54:26 +08:00
zlwuandCursor 85b315a0cf chore: refresh graphify after sample freight menu feature
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 10:02:31 +08:00
zlwuandCursor 26fd37e6f9 feat(web-admin): add sample freight menu list with configure dialogs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 10:02:15 +08:00
zlwuandCursor 86ba0c2cd0 feat(web-admin): extend sample-freight API client for menu page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 10:00:03 +08:00
zlwuandCursor f092b64a33 feat(backend): add sample options for freight config dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 09:59:29 +08:00
zlwuandCursor 971af93132 feat(backend): enrich sample freight list with items and filters
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 09:58:45 +08:00
zlwuandCursor 04eb1e81bd feat: add sample freight menu route and page shell
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 09:51:48 +08:00
zlwuandCursor 68f653eb51 docs: add sample freight menu implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 09:44:38 +08:00
zlwuandCursor 57d6241804 docs: add sample freight menu design and list demo
Document the cell-storage freight info menu with rowspan list, dual-source samples, and dual entry with protocol forms; include a static HTML preview.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 09:39:07 +08:00
zlwuandCursor 5dd545ba5c feat(web-admin): wire protocol sample freight batch UI
Replace per-sample transport fee entry with shared freight batch config in storage and donation protocol dialogs, and show mode/amount in view dialogs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 07:51:56 +08:00
zlwuandCursor 468a7c5e12 feat(backend): expose sample freight APIs and wire protocol saves
Register admin sample-freight routes, enrich protocol sample details with freight, and stop writing legacy transport rows on save.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 07:48:41 +08:00
zlwuandCursor 6d542a0acf feat(backend): add sample freight CRUD and link/unlink
Provide freight batch create/update/link/unlink, sample enrichment map, and protocol-save draft grouping without writing legacy transport rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 07:47:41 +08:00
zlwuandCursor 66fc507b1a feat(backend): resolve sample transport mode dict with auto-create
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:18:56 +08:00
zlwuandCursor dbdabdce24 feat(backend): add BizSampleFreight models
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:17:40 +08:00
zlwuandCursor 719bf20c5b docs(sql): add sample freight batch tables and transport mode dict
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:15:02 +08:00
zlwuandCursor 95a28d5f0f docs: add sample freight batch implementation plan
Break the approved freight-batch design into TDD-oriented tasks covering SQL, APIs, protocol wiring, and web-admin UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:13:05 +08:00
zlwuandCursor a6138d60bc docs: add sample freight batch design for shared transport cost
Capture the agreed approach: freight batch entity linked at sample level from protocol forms, dictionary-backed transport modes, internal cost only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:09:40 +08:00
zlwuandCursor 818b809614 feat(web-admin): show related family and protocols on customer detail
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 22:36:35 +08:00
zlwuandCursor af5c6e725d chore: add idempotent backfill for party customer ids
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 22:26:59 +08:00
zlwuandCursor e7f2dfa4d4 feat: add customer relations API for family and protocols
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 22:26:15 +08:00
zlwuandCursor f005370a1e feat: wire party customer sync into storage and donation saves
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 22:20:54 +08:00
zlwuandCursor 910f61ed33 feat: sync mother/father/newborn customers on protocol party save
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 16:11:49 +08:00
zlwuandCursor fc14c51348 feat(db): add father/newborn customer ids on protocol parties
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 16:03:11 +08:00
zlwuandCursor 69e76b85ab docs: add implementation plan for customer family protocol link.
Break work into schema, sync service, save wiring, relations API, backfill, and customer detail UI tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:54:15 +08:00
zlwuandCursor e027b5abc4 docs: add customer family protocol link design.
Record agreed model for mother/father/child customer IDs on protocol parties, save-time sync, id-card merge, and customer detail relations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:52:29 +08:00
zlwuandCursor cf86c585b7 chore(graphify): refresh backend graph after menu hide for unified protocol.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:28:10 +08:00
zlwuandCursor 98ad5370dc chore(graphify): refresh graph after unified protocol page.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:20:56 +08:00
zlwuandCursor be98dbe7ac fix(menu): restore hidden research-donation sample-info route for deep links.
Task 4 incorrectly removed ResearchDonationSampleInfo; keep it isHide with parent hidden so unified sample-info deep links still work.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:18:59 +08:00
zlwuandCursor ed37a31654 docs(sql): add migration to hide research-donation protocol menus for DB menu mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:18:51 +08:00
zlwuandCursor 56fd44388b chore(menu): hide research-donation protocol menus; keep routes for unified page deep links.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:16:42 +08:00
zlwuandCursor c1f9a56404 feat(web-admin): wire protocol-audit and donation audit routes to UnifiedProtocolPage.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:15:28 +08:00
zlwuandCursor 70c92fb33f feat(web-admin): add UnifiedProtocolPage shell with cell-storage and research-donation tabs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:14:36 +08:00
zlwuandCursor db75ca6deb refactor(web-admin): extract DonationProtocolTab from research-donation audit page.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:11:32 +08:00
zlwuandCursor f447db7681 docs: add implementation plan for unified protocol page.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:06:18 +08:00
zlwuandCursor f8b0e2de11 docs: add unified protocol page design (shell + two tabs).
Record the confirmed approach to merge donation protocols into the cell-storage protocol UI with dual tabs, dual routes, and hidden research-donation menus.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:01:24 +08:00
zlwuandCursor cf4b8c0e56 feat(db): add handle_time and handler_user_id for storage cert and fullmoon todo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 15:56:44 +08:00
zlwuandCursor d5bfb895a7 chore: menu SQL, graph sync, and mark payment-info split done
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 13:12:16 +08:00
zlwuandCursor d5ad755695 feat(web-admin): add per-sample payment list and operable detail
Show one row per sample with schedules/transfers and shared receipt confirm.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 13:08:17 +08:00
zlwuandCursor edaddc9da1 refactor(web-admin): extract shared payment receipt confirm dialog
Reuse the same confirm form for flow kanban and per-sample views.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 13:04:24 +08:00
zlwuandCursor 32c01a4c69 feat: split payment-info into standalone menu with dual tabs
Remove payment tab from protocol-audit and keep deep-link redirect.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 13:03:16 +08:00
zlwuandCursor 1bc44cab13 feat(web-admin): add sample-payments API clients and progress tags
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 13:02:39 +08:00
zlwuandCursor c35e4ca3d2 feat(backend): add sample-payments list and detail read APIs
Support per-sample payment overview for the standalone payment-info menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 12:57:55 +08:00
zlwuandCursor 47f873a3e6 feat(backend): add sample payment progress aggregation helper
Pure function for per-sample list progressStatus, paid/due totals, and canConfirm.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 12:54:48 +08:00
zlwuandCursor 4849b40594 docs: add implementation plan for standalone payment-info menu
Break the approved design into TDD-friendly tasks for APIs, dual tabs, and per-sample UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 12:53:47 +08:00
zlwuandCursor 33004fe9b9 docs: record standalone payment-info menu and per-sample view design.
Capture the agreed split from protocol-audit tabs, dual flow/sample views, and read-only aggregation APIs before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 12:50:01 +08:00
zlwuandCursor f73428dcfd chore: sync graphify after P0 payment/presign and mark Task 10 steps done
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 11:26:04 +08:00
zlwuandCursor e7677d1661 feat: finish P0 per-sample payment and presign order integration
Wire finance transfer list, protocol view v2 readonly payment, and mark design/plan as P0 implemented.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 11:25:47 +08:00
zlwuandCursor a9112414c9 feat(web-admin): add presign order UI and menu entry
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 11:21:23 +08:00
zlwuandCursor fdd2c1fbc3 feat: add presign order CRUD, deposit transfers, and convert API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 11:18:32 +08:00
zlwuandCursor c404c168fe feat(web-admin): finance confirm by transfer and remove refund confirm tab
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 11:17:19 +08:00
zlwuandCursor 4e71a13165 feat(web-admin): per-sample payment UI and refund registration on protocol form
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 11:13:32 +08:00
zlwuandCursor a328b9ff2d feat: wire protocol bundle to per-sample payment model v2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 11:08:07 +08:00
zlwuandCursor 2ff5bc09f9 feat: add protocol-level refund registration API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 11:05:29 +08:00
zlwuandCursor aed7a29c1f feat: expose per-sample payment and transfer finance APIs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:59:31 +08:00
zlwuandCursor 8827ec3804 feat: add sample payment schedule and transfer status rules
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:58:21 +08:00
zlwuandCursor ca3d464761 feat: add per-sample payment and presign order schema
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:52:20 +08:00
zlwu 4928e00423 提交 2026-09-28 10:49:53 +08:00
zlwu b9b4cc2673 提交 2026-09-24 09:59:26 +08:00
zlwuandCursor 8248e5ef15 feat(web-admin): mount instance comment panel on OA detail views
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 21:32:31 +08:00
zlwuandCursor 3cc112c16c feat(web-admin): add OaCommentPanel for instance discussion comments
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 21:31:40 +08:00
zlwuandCursor a1b95b1f0b feat(web-admin): add OA instance comment API clients
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 21:29:19 +08:00
zlwuandCursor 55e1306eba feat(oa): expose instance comment APIs and embed comments in detail
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 21:28:36 +08:00
zlwuandCursor c66bfc75c0 feat(oa): add instance comment create list and revoke service
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 21:27:02 +08:00
zlwuandCursor ea8420b648 feat(oa): enumerate and validate comment form attachment refs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 21:20:58 +08:00
zlwuandCursor 8d90457cdc feat(oa): add oa_instance_comment table and model
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 21:18:59 +08:00
zlwuandCursor a4f00b2839 fix(mobile-h5): correct relative imports for web-qr login page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 14:15:58 +08:00
zlwuandCursor 66e442e1e8 chore: document wx-qr phone login and refresh graphify
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 14:06:20 +08:00
zlwuandCursor 56ddc212a7 feat: add mini program page for web-admin QR phone login
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 14:05:18 +08:00
zlwuandCursor 6a5d93165b feat: make web-admin login QR-primary with password fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 14:03:48 +08:00
zlwuandCursor f7c5a7477e feat: add mini auth web-qr scanned and confirm endpoints
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 13:57:04 +08:00
zlwuandCursor 38b11cce35 feat: add web auth wx-qr create and status endpoints
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 13:53:40 +08:00
zlwuandCursor 9d4a11339b feat: add WeChat unlimited mini program code generator
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 13:48:54 +08:00
zlwuandCursor a9b6a73823 feat: add wx-qr login session store with one-time token consume
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 13:40:20 +08:00
zlwuandCursor 68eb46c47d chore: add wx-qr login config and encrypt exclude paths
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 13:37:35 +08:00
zlwuandCursor 8d296b3da0 fix(web-admin): add icons to region staff action buttons
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 11:35:03 +08:00
zlwuandCursor 8029477e90 feat(web-admin): add region staff config dialog and list summaries
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 11:33:00 +08:00
zlwuandCursor d5e5ae52a0 feat(web-admin): add collection region staff API types and clients
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 11:29:54 +08:00
zlwuandCursor 97c241c1b3 feat(api): expose collection region staff nested CRUD
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 11:28:59 +08:00
zlwuandCursor 412ec0771f feat(backend): add collection region staff CRUD and list summaries
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 11:24:03 +08:00
zlwuandCursor 5d456a8696 feat(backend): add BizCollectionRegionStaff model
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 11:21:41 +08:00
zlwuandCursor 0be6545078 docs(sql): add biz_collection_region_staff migration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 11:20:01 +08:00
zlwuandCursor a882bb185e docs: add collection region staff binding implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 11:13:48 +08:00
zlwuandCursor e24b3dbadf docs: add collection region staff binding design and demo
Capture the approved design for region–user binding with multi-leader support, plus an interactive Fresh Mint HTML demo for UI review.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 11:09:59 +08:00
zlwu 679285dcc6 提交 2026-09-17 23:49:16 +08:00
zlwu 5623d0125c 提交 2026-09-16 10:27:24 +08:00
zlwu d294ecad84 提交 2026-09-11 21:03:53 +08:00
zlwu 195c89e6b7 提交 2026-09-03 17:12:38 +08:00
zlwu 03226cac2a 提交 2026-09-01 14:22:46 +08:00
zlwu 2cd1194d17 提交 2026-09-01 13:43:14 +08:00
zlwu 2fd826dbe1 提交 2026-09-01 11:32:49 +08:00
zlwu 7e9155fef1 提交 2026-08-31 15:18:18 +08:00
zlwuandCursor 04d462ad43 docs: 标记接种量/冻存时间改造已实现并更新图谱
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 15:00:35 +08:00
zlwuandCursor 2d6b367a67 feat(backend): 生产接收接种量改为 VARCHAR(255) 自由文本
同步模型、API、CRUD 与 SQL 迁移/基线,支持数字与说明文案组合录入。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 15:00:02 +08:00
zlwuandCursor bf6b080240 feat(web-admin): 样本接收弹窗接种量文本输入、冻存仅选日期
共用 SampleReceptionDialog,生产工单与我的生产任务一并生效。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:59:12 +08:00
zlwuandCursor 8a8ababcc0 feat(web-admin): 生产接收接种量改文本、冻存时间改日期展示
字段 kind 与只读格式对齐产品:自由文本接种量、冻存仅 YYYY-MM-DD。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:58:46 +08:00
zlwuandCursor 431d7b9835 docs: 生产接收接种量文本化与冻存仅日期实现计划
按设计拆为字段单测、弹窗、后端 SQL/API、验收四步。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:56:52 +08:00
zlwuandCursor fdd219c1e7 docs: 生产接收接种量文本化与冻存时间仅日期设计
明确接种量改为 VARCHAR(255) 自由文本,冻存时间交互与展示仅保留 YYYY-MM-DD。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:54:22 +08:00
zlwuandCursor 9aa786a8c5 chore: update graphify and mark cascade-complete design implemented
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:42:32 +08:00
zlwuandCursor 8e04d6944a feat(web-admin): cascade-complete work order via conclusion dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:41:59 +08:00
zlwuandCursor 139164c7d2 fix(web-admin): allow finish production task without step or IO gates
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:41:59 +08:00
zlwuandCursor f110f92040 feat(web-admin): support work-order mode on production conclusion dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:41:59 +08:00
zlwuandCursor a7f946450e fix(web-admin): send production work order complete payload in body
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:41:59 +08:00
zlwuandCursor 01e0d99a03 feat(backend): cascade complete tasks and steps when finishing work order
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:40:01 +08:00
zlwuandCursor c6a9745c80 feat(backend): cascade-complete steps when finishing production task
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:37:57 +08:00
zlwuandCursor 29abc79699 feat(backend): require conclusion on production work order complete
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:36:46 +08:00
zlwuandCursor 8054b2abe5 docs: add implementation plan for production cascade complete
Break cascade-complete for production tasks and work orders into backend and web-admin tasks with TDD steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:34:32 +08:00
zlwuandCursor 5367abf7f2 docs: add production cascade complete design for task and work order
Document cascading status completion when finishing a production task or work order, removing incomplete-step gates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 14:30:21 +08:00
zlwu 0d52442902 提交 2026-08-28 17:38:37 +08:00
zlwuandCursor 3d98104455 docs: mark sop fill mint highlight plan progress
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 14:55:18 +08:00
zlwuandCursor 7541b0d762 docs: document SOP fill field mint highlight convention
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 14:53:27 +08:00
zlwuandCursor 8d51457dfa feat(web-admin): highlight SOP fill fields in mint green bold
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 14:51:06 +08:00
zlwuandCursor 1ff4e842df docs: add SOP fill mint highlight implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 14:49:42 +08:00
zlwuandCursor a42c16cf93 docs: add SOP fill mint highlight design (scheme D)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 14:48:06 +08:00
zlwuandCursor cbfe592e47 docs: add OA start history design, plan, and UI demo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 10:56:24 +08:00
zlwuandCursor 3beb7ffb8a chore: update graphify after OA start history feature
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 10:55:54 +08:00
zlwuandCursor 0ec7376989 feat(web-admin): show history button on OA start approval cards
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 10:54:57 +08:00
zlwuandCursor 7cd6a87203 feat(web-admin): add OaAppHistoryDialog for per-app instance history
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 10:53:31 +08:00
zlwuandCursor 107bf66586 feat(web-admin): add fetchOaInstanceHistory API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 10:51:50 +08:00
zlwuandCursor 38b4d6803f docs(sql): seed oa:start:history:all button permission
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 10:51:28 +08:00
zlwuandCursor 37e8ce52a7 feat(oa): expose GET /instances/history for start-page history
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 10:50:06 +08:00
zlwuandCursor 32824fdbf5 feat(oa): add list_instance_history with scoped preview fields
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 10:47:44 +08:00
zlwuandCursor 416c4a0838 test: add OA instance history list failing tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 10:44:39 +08:00
zlwu a2d4d3b0d5 提交 2026-08-25 16:47:43 +08:00
zlwu d2290eb92c 提交 2026-08-25 09:19:31 +08:00
zlwuandCursor 848517479d chore: update graphify after temporary QC external target code
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 10:41:54 +08:00
zlwuandCursor ef8a50a52b feat(web-admin): show external target on temporary QC task view section
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 10:41:53 +08:00
zlwuandCursor ae248961cc feat(web-admin): temporary QC external target code UI
Add source toggle in create/edit dialog, API types, and list/detail display.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 10:41:38 +08:00
zlwuandCursor caca786051 feat(backend): persist external target code on temporary QC work orders
Wire create/update API, list keyword search, and response dict field.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 10:40:22 +08:00
zlwuandCursor 562f35a839 feat(backend): resolve temporary QC target cell product vs external code
Extract pure mutual-exclusion helper to avoid circular imports in unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 10:38:56 +08:00
zlwuandCursor 83d417b6e6 docs(sql): add external_target_code on biz_qc_work_order
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 10:33:17 +08:00
zlwuandCursor 19b0346f2d docs: add temporary QC external target code implementation plan
Break the approved design into SQL, backend validation, API, and web-admin UI tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 10:30:33 +08:00
zlwuandCursor e527b15221 docs: add temporary QC external target code design
Record the agreed approach for hand-entered external QC object codes on temporary work orders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 10:27:58 +08:00
zlwu c7a7b6fa45 提交 2026-08-19 16:33:51 +08:00
zlwuandCursor 08f3b76e24 fix(backend): default empty strings for optional month template codes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 13:07:00 +08:00
zlwuandCursor 0a5bf28e58 feat: allow empty fields on qc material month template rows
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 13:06:35 +08:00
zlwuandCursor f7ee984d04 docs: add qc material month import design and implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 12:50:46 +08:00
zlwuandCursor 6957952b4c feat(web-admin): add qc material month import template UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 12:50:37 +08:00
zlwuandCursor e4dcb43d77 feat(api): expose qc material month template endpoints
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 12:47:26 +08:00
zlwuandCursor 3aa8aebc29 feat(backend): add qc material month template CRUD preview and confirm
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 12:46:25 +08:00
zlwuandCursor 1d3f73d3b9 feat(backend): support non-committing qc material create and lookup by code
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 12:46:09 +08:00
zlwuandCursor ca4b9a15c5 feat(backend): add RK+date stock-in number allocator for qc materials
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 12:45:08 +08:00
zlwuandCursor 450b30966d feat(backend): add BizQcMaterialMonthTemplate model
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 12:41:58 +08:00
zlwuandCursor c591dab5ff docs(sql): add qc material month import template table
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 12:40:34 +08:00
zlwuandCursor 6c75b2d139 chore: update graphify after storage release dual-gate.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:51:47 +08:00
zlwuandCursor e5128a087a feat: show both release verdicts on protocol sample cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:50:45 +08:00
zlwuandCursor 8280e4bc24 feat: show production and QC verdicts in storage release view dialog.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:47:32 +08:00
zlwuandCursor 3388c1efdb feat: submit storage release reviews for one side only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:46:39 +08:00
zlwuandCursor 021abd8283 feat: split storage release list by review role and dual conclusions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:45:27 +08:00
zlwuandCursor 2c0bee3941 feat: add dual-side fields to storage release frontend API.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:43:55 +08:00
zlwuandCursor 9cc66bdc80 feat: add production and QC menus for storage release review.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:43:22 +08:00
zlwuandCursor 50c41861bf feat: require reviewSide and write menus on storage release APIs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:42:35 +08:00
zlwuandCursor 6143a99133 feat: apply dual-gate storage release submit, auto store, and unstore.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:39:00 +08:00
zlwuandCursor 217549884d feat: expose dual-side storage release fields in row dict and schemas.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:33:42 +08:00
zlwuandCursor b7e003220b feat: add production and QC review columns on storage release model.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:30:30 +08:00
zlwuandCursor 742021be3c docs: add storage release dual-gate schema and data migration SQL.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:27:59 +08:00
zlwuandCursor 309b781c0e feat: add dual-gate storage release pure functions and tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:22:15 +08:00
zlwuandCursor f880aa7d8a docs: add storage release dual-gate implementation plan.
Break the approved design into TDD tasks covering schema, APIs, three menu entries, and UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:20:10 +08:00
zlwuandCursor e9942af37f docs: record dual-gate storage release review design.
Capture production and QC dual approval, three menu entry points, auto store and rollback, and historical data rules before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:15:54 +08:00
zlwu cd8ab170ba 提交 2026-08-19 10:45:07 +08:00
zlwu 1618035e4b 提交 2026-08-19 09:18:20 +08:00
zlwu 21e772db3a 提交 2026-08-18 17:23:46 +08:00
zlwuandCursor 0707a4c95e docs: add production task set design spec and implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 16:16:57 +08:00
zlwuandCursor 4c45e52296 chore: update graphify output after production task set
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 16:16:49 +08:00
zlwuandCursor 7645ac77d2 feat: save work-order tasks as a production task set
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 16:16:28 +08:00
zlwuandCursor 893ff58441 feat: dispatch production tasks from a reusable set
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 16:16:22 +08:00
zlwuandCursor 11eb9874af feat: add production task set maintenance page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 16:16:12 +08:00
zlwuandCursor d60d4bd4b6 feat: add production task set API client and item mapping
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 16:16:04 +08:00
zlwuandCursor 49e208c241 feat: expose production task set REST APIs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 15:43:38 +08:00
zlwuandCursor a78dd29f40 feat: implement production task set CRUD and batch dispatch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 15:43:38 +08:00
zlwuandCursor d0ea38c133 test: add production task set item helper coverage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 15:38:52 +08:00
zlwuandCursor 29e8adb943 feat: add production task set SQLAlchemy models
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 15:38:09 +08:00
zlwuandCursor d850fd0027 fix: paste production task set SQL into the production sync script
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 15:33:08 +08:00
zlwuandCursor 9ac29e6ad7 feat: add production task set schema and menu SQL
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 15:11:44 +08:00
zlwu d2792174b5 提交 2026-08-18 14:40:15 +08:00
zlwuandCursor a7fbaa8fd9 chore: update graphify after waste-output feature.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:34:27 +08:00
zlwuandCursor f42f78f134 feat(web-admin): add production waste-output read-only list page.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:32:39 +08:00
zlwuandCursor c78d02a348 feat: wire production waste-output API route and menu mirror.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:30:16 +08:00
zlwuandCursor 351a742c07 fix(docs): restore pending production SQL and append waste-output menu.
Task 3 had replaced the pending sync file; reinstate prior scripts and keep the new waste-output menu migration at the end.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:29:25 +08:00
zlwuandCursor 5ec7537b92 docs(sql): add production waste-output menu migration.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:28:59 +08:00
zlwuandCursor 301df76c65 feat(backend): expose GET /production-waste-outputs list API.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:06:56 +08:00
zlwuandCursor 79f9649610 feat(backend): add production waste-output list query helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:54:00 +08:00
zlwuandCursor a089031994 docs: add production waste-output list implementation plan.
Break the approved design into TDD-backed backend list helpers, menu SQL, and a P01 read-only web-admin page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:48:56 +08:00
zlwuandCursor af337562ae docs: add production waste-output list design.
Capture the agreed read-only P01 list under Production Management (sibling to cleanroom garment), querying step outputs of type FQW without a new business table.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:45:23 +08:00
zlwuandCursor 304469f6fa feat(web-admin): add remaining-stock pills on QC material list.
Filter batches via stockStatus capsules before stock-in; default to available stock.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 09:48:04 +08:00
zlwuandCursor d8b61c523d feat(web-admin): add stockStatus to QC material batch list API.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 09:43:07 +08:00
zlwuandCursor f014e3f88a feat(backend): filter QC material batches by stockStatus.
Support available (remaining_qty > 0) and exhausted (remaining_qty = 0) on the batch list API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 09:41:10 +08:00
zlwuandCursor 0f461851b5 feat(backend): add QC material batch stockStatus helpers.
Normalize available/exhausted filter values and document remaining_qty matching for list filtering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 09:36:33 +08:00
zlwuandCursor 5fc460c69a docs: add QC material remaining-stock filter implementation plan.
Break the approved design into backend stockStatus filtering and frontend toolbar capsule tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 09:33:41 +08:00
zlwuandCursor 8e689ee771 docs: add QC material remaining-stock filter design.
Capture the approved approach for separating used-up and available batches via toolbar capsules and a backend stockStatus query param.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 09:31:33 +08:00
zlwuandCursor 00af72e11b docs: mark QC inspection item template plan tasks done.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:47:42 +08:00
zlwuandCursor bb81170b89 chore: sync baseline SQL and graphify for item templates.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:47:05 +08:00
zlwuandCursor ea5bddb1dc feat(web-admin): apply and save QC item templates in configure dialog.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:47:01 +08:00
zlwuandCursor 6da51df503 feat(web-admin): register inspection item template route.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:47:00 +08:00
zlwuandCursor 4a1444526a feat(web-admin): add inspection item template list page.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:47:00 +08:00
zlwuandCursor e80dcd8519 feat(web-admin): add QC inspection item template API module.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:46:59 +08:00
zlwuandCursor 52ec964f88 feat(api): expose QC inspection item template endpoints.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:46:59 +08:00
zlwuandCursor bc62ac796a feat(backend): add QC inspection item template CRUD helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:46:59 +08:00
zlwuandCursor 4b7d026d4e feat(backend): add BizQcInspectionItemTemplate model.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:46:59 +08:00
zlwuandCursor 15af9894bc docs(sql): add QC inspection item template table and menu.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:40:50 +08:00
zlwuandCursor bf8e8e219f docs: add QC inspection item template implementation plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:37:28 +08:00
zlwuandCursor 6e48c4f372 docs: add QC inspection item template design.
Capture the agreed master-data + configure-dialog apply/save-as approach before implementation planning.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:32:24 +08:00
zlwuandCursor 3a6e2ca128 fix(sql): avoid MySQL 1093 on production pre-transit role grants.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:10:20 +08:00
zlwuandCursor b6213a6e06 docs: mark production pre-transit sample plan tasks done.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:02:34 +08:00
zlwuandCursor 5cc0bd840d chore: update graphify after production pre-transit sample.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 12:02:28 +08:00
zlwuandCursor 781102f880 docs(sql): add production pre-transit sample menu and grants.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 11:56:45 +08:00
zlwuandCursor 9a0f48fbfd fix: allow production perm on shared pre-transit APIs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 11:55:46 +08:00
zlwuandCursor 0b83fb21b9 feat: register production pre-transit sample route.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 11:54:44 +08:00
zlwuandCursor dcf4f83ed0 feat: copy pre-transit sample views under production.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 11:53:29 +08:00
zlwuandCursor b8a89a81f9 docs: plan production pre-transit sample menu copy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 11:52:36 +08:00
zlwuandCursor 5f9d62fa04 docs: design production copy of pre-transit sample board.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 11:51:28 +08:00
zlwu 1bdf979a70 提交 2026-08-14 16:01:47 +08:00
zlwuandCursor 85b0b454bf docs: note WeChat notify entry migration to dedicated menu
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 12:23:01 +08:00
zlwuandCursor eb337e6ce3 fix(mobile-h5): remove WeChat notify shortcut from home header
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 12:22:36 +08:00
zlwuandCursor 88d863288f fix(mobile-h5): remove follow-MP entry from login page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 12:22:00 +08:00
zlwuandCursor ac197ad90e docs(sql): seed mobile menu for WeChat notify setup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 12:21:32 +08:00
zlwuandCursor 01186adb46 fix(mobile-h5): show follow confirm after opening official account profile
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 12:18:23 +08:00
zlwuandCursor df42730cc8 feat(mobile-h5): add WeChat notify setup page with follow and bind steps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 12:15:47 +08:00
zlwuandCursor b79ff2d830 feat: map oa-wechat-notify icon for mobile menu grid
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 12:14:04 +08:00
zlwuandCursor 239998ec44 feat(mobile-h5): add local follow-confirm helpers for WeChat notify steps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 12:12:56 +08:00
zlwuandCursor 16fba5e9b4 docs: align WeChat notify plan tests with existing assert style
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 12:08:55 +08:00
zlwuandCursor 514569e667 docs: add implementation plan for WeChat notify menu entry
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 12:08:34 +08:00
zlwuandCursor 26fe6182df docs: design WeChat notify menu entry consolidating follow and bind
Capture the approved UX for a single「开启微信通知」menu after 抄送我的, with step 1/2 on one page and removal of login/home header entry points.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 09:39:30 +08:00
zlwuandCursor 75ef98743d chore: update graph and docs after OA WeChat notify integration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 19:30:45 +08:00
zlwuandCursor 722f634b61 feat(mobile-h5): restore session on OA notify deep link with returnUrl
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 19:28:25 +08:00
zlwuandCursor db975dac47 fix(mobile-h5): polish WeChat notify bind entry UX on home
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 19:25:41 +08:00
zlwuandCursor 3a41902d2b feat(mobile-h5): add OA WeChat notify bind entry and web-view page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 19:22:40 +08:00
zlwuandCursor afde67fd03 feat(mobile-h5): add follow service-account entry on login page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 19:20:41 +08:00
zlwuandCursor dc53c6e871 feat(mobile-oa-h5): add service-account OAuth bind page for OA notify
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 19:17:21 +08:00
zlwuandCursor 80722d76db feat(mini-auth): add WeChat service-account bind APIs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 18:15:53 +08:00
zlwuandCursor 405808f4dd feat(mini-auth): allow silent wechat login when openid already bound
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 18:13:09 +08:00
zlwuandCursor e0cfd41b1f feat(oa): schedule WeChat MP notifies after approval/cc tasks create
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 18:08:35 +08:00
zlwuandCursor cfb7a227e3 feat(backend): add WeChat service-account template message sender
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 18:02:49 +08:00
zlwuandCursor 119882953a feat(db): add sys_user service-account WeChat bind columns
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 17:58:45 +08:00
zlwu 283b8cd1e3 提交 2026-08-13 16:50:36 +08:00
zlwu 8c49021d38 提交 2026-08-13 14:31:57 +08:00
zlwuandCursor 0e1b750457 docs: sync circle-index plan/spec with page padding follow-up.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:29:02 +08:00
zlwuandCursor 93bb28c06b fix(mobile-h5): reserve left padding so OA circle indexes clear titles.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:26:56 +08:00
zlwuandCursor 6ece55a00b feat(mobile-h5): use solid circle badge for OA list card indexes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:25:52 +08:00
zlwuandCursor ba7f625865 docs: add OA list circle index implementation plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:24:29 +08:00
zlwuandCursor b091751f4d docs: add OA list circle index badge design spec.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:23:47 +08:00
zlwuandCursor e229525a61 feat(mobile-h5): show corner index on all five OA task list cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 11:37:10 +08:00
zlwuandCursor df941a3450 fix(mobile-h5): keep Task 2 commit scoped to corner styles and reach-bottom.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 11:34:31 +08:00
zlwuandCursor 9c3decc729 feat(mobile-h5): add shared OA card corner styles and reach-bottom distance.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 11:32:30 +08:00
zlwuandCursor 349019863b feat(mobile-h5): add formatCornerIndex for OA list corner badges.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 11:31:14 +08:00
zlwuandCursor 53e4a52b4f docs(oa): expand corner index design and plan to all five mobile lists.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 11:30:08 +08:00
zlwuandCursor b2a30d78d8 docs(oa): 我发起的列表卡片折角序号设计与 Demo。
选定方案 A(左上品牌绿折角 + 两位序号),供实现前评审。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 11:27:36 +08:00
zlwuandCursor 804995ea45 fix(oa): pass short fileUrl to PDF preview to avoid WeChat navigateTo limit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 11:02:43 +08:00
zlwuandCursor 5c10b207d2 fix(oa): update mobile print button toasts for PDF export
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 11:00:09 +08:00
zlwuandCursor fb625253f6 feat(oa): add WeChat host page to preview exported OA PDF
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 10:58:59 +08:00
zlwuandCursor 8ac1eb3d40 feat(oa): export mobile PDF via upload and WeChat preview navigate
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 10:57:44 +08:00
zlwuandCursor bf22b29d80 feat(oa): generate mobile OA approval PDF via html2canvas and jspdf
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 10:56:37 +08:00
zlwuandCursor 505bf29fdd fix(oa): align mobile print HTML stamp and layout with web PDF
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 10:53:33 +08:00
zlwuandCursor 2802ef23a6 feat(oa): add mobile print PDF filename helper
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 10:52:02 +08:00
zlwuandCursor 5897b35b4e docs: add mobile OA print-to-PDF download implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 10:50:07 +08:00
zlwuandCursor 433eb5d113 docs: add mobile OA print-to-PDF download design for WeChat
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 10:47:54 +08:00
zlwuandCursor 828ada6ac3 feat(oa): fold mobile detail approve actions into more sheet
Show only return and approve on the footer, move secondary actions to ActionSheet, and prefill default remarks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 18:16:55 +08:00
zlwuandCursor ef6d0ef175 feat(oa): add mobile detail action defaults helpers
Centralize default approval remarks and more-menu action order for the folded footer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 18:14:11 +08:00
zlwuandCursor 7da691e4c6 docs: add mobile OA detail actions fold implementation plan
Break the folded footer and default-remark work into helper tests plus OaDetail.vue tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 18:11:43 +08:00
zlwuandCursor 1c844398a9 docs: add mobile OA detail footer fold and default comment design
Capture scheme A (⋯ + return + approve), ActionSheet more actions, and default remark copy for pending approval detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 18:09:59 +08:00
zlwuandCursor e4934dc64e chore(oa): note mobile task centers plan status and refresh graph
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:41:14 +08:00
zlwuandCursor cbef68199b feat(oa): add mobile approval PDF print for mine and cc
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:40:08 +08:00
zlwuandCursor 915253eefb feat(oa): show draft pending cc badges on mobile home menu
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:36:44 +08:00
zlwuandCursor 06b1468bf1 feat(oa): add cc read filters batch mark and unread styles
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:34:39 +08:00
zlwuandCursor ecdf73fe23 feat(oa): support withdraw and copy-restart on mobile mine flows
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:31:04 +08:00
zlwuandCursor d5287765ec feat(oa): add pending list and approve reject return add-sign transfer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:25:23 +08:00
zlwuandCursor 29560b743c feat(oa): add done mine cc lists opening read-only OA detail
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:21:04 +08:00
zlwuandCursor 76dab57f4e fix(oa): align mobile OA detail status labels with web
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:17:24 +08:00
zlwuandCursor d827f55bf0 feat(oa): add mobile OA detail page in read-only mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:14:59 +08:00
zlwuandCursor 47d7a1421a fix(oa): refresh draft list after submit instead of opening detail
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:09:50 +08:00
zlwuandCursor 6b0fb7b128 feat(oa): add mobile draft box list with edit submit delete
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:07:10 +08:00
zlwuandCursor 387e82426d feat(oa): add mobile OA task card for center lists
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:03:38 +08:00
zlwuandCursor cf4994ef53 feat(oa): add shared OA webview and detail URL builder
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:59:40 +08:00
zlwuandCursor 2dac9d6fbd feat(oa): extend mobile OA APIs for drafts, tasks, and detail actions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:56:31 +08:00
zlwuandCursor 730cc99ae7 feat(oa): add mobile menu seeds for five OA task centers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:52:29 +08:00
zlwu ec628e1b8d 提交 2026-08-12 15:48:15 +08:00
zlwuandCursor d310da1523 docs: OA 人员选择弹窗对齐方案 C 的设计说明
明确以流程审批人/抄送人弹窗与 Demo 方案 C 为基线,仅改 OaPersonSelect 行内结构与副信息展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 14:19:09 +08:00
zlwu 1b2896c877 提交 2026-08-12 10:02:31 +08:00
zlwuandCursor 8aab018558 fix(mobile-h5): serve legacy OA app icon SVGs from static
Most templates still use /oa-app-icons/*.svg from web-admin public; only ep: icons rendered. Copy assets and rewrite paths to /static/oa-app-icons so H5/miniprogram can load them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 15:00:32 +08:00
zlwuandCursor df07458934 feat(mobile-h5): add OaAppIcon and grouped card grid on OA start
Align the start list with web section headers and two-column cards; render ep logos with shared color pairs via base64 SVG data-URIs for WeChat image compatibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 14:32:14 +08:00
zlwuandCursor 057029eca3 feat(mobile-h5): add OA app icon parse and ep SVG data-URI helpers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 14:28:46 +08:00
zlwuandCursor 484b2471f5 docs: add mobile OA start card-grid implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 14:25:55 +08:00
zlwuandCursor fdaeb6840f docs: add mobile OA start card-grid design and HTML demo
Align the mini-program start list with web card sections and shared ep icon color pairs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 14:20:04 +08:00
zlwuandCursor ad6208a73a docs: record mobile OA start phase-2 acceptance notes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 14:06:57 +08:00
zlwuandCursor e83d925994 feat(mobile-oa-h5): port OA kit runtime and wire duration sync on oa-start
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 14:02:59 +08:00
zlwuandCursor b638c787b1 feat(mobile-oa-h5): polish form layout and replace inputNumber with decimal field
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 13:40:02 +08:00
zlwuandCursor a0775cf619 feat: wire mini menus API and OA start home entry for mobile
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 13:39:51 +08:00
zlwuandCursor 8a9d0a2f95 feat(mobile-h5): add OA start template list and web-view page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 13:39:51 +08:00
zlwuandCursor 07eeb58d15 feat(mobile-oa-h5): implement oa-start page with form and flow preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 13:39:51 +08:00
zlwuandCursor 7ea11ffdcd fix(mobile-oa-h5): remove duplicate put helper and re-export it
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 13:39:51 +08:00
zlwuandCursor 4a1802af36 feat(mobile-oa-h5): add OA start runtime API helpers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 13:36:27 +08:00
zlwuandCursor 834b1a59a7 feat(mobile-oa-h5): add kit shells, trip itinerary, and camera-friendly upload
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:23:21 +08:00
zlwuandCursor d5e61a1162 feat(mobile-oa-h5): implement P1 person/dept/post and owner field selects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:19:14 +08:00
zlwuandCursor bb86eb7ad2 feat(mobile-oa-h5): implement P0 money/upload/receiveAccount/relatedApproval fields
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:17:33 +08:00
zlwuandCursor f0e687eec9 docs: record mobile OA form-sink spike checklist and local tips
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:09:04 +08:00
zlwuandCursor 1b02c29114 feat(mobile-h5): add OA form-sink web-view entry and test menu
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:09:00 +08:00
zlwuandCursor 9adff5584e feat(mobile-oa-h5): add kitchen-sink form page with OA type stubs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:08:45 +08:00
zlwuandCursor 1b45fbaa36 chore(mobile-oa-h5): track .env.development for Vite API base
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:08:23 +08:00
zlwuandCursor 507032aedf chore: scaffold mobile-oa-h5 with Vite, Vant and form-create
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:04:27 +08:00
zlwuandCursor 6f849cbb36 chore: add OA published form type stats script against zkxw-sc
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 11:57:35 +08:00
zlwuandCursor d92472d2cd docs: narrow OA mobile form spike by zkxw-sc usage and add phase-1 plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 11:54:14 +08:00
zlwuandCursor 3c5fb79c30 docs: add mobile OA start-approval design for mini-program web-view H5 forms.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 11:47:13 +08:00
zlwu 693e8a678e 提交 2026-08-11 11:13:01 +08:00
zlwuandCursor 80138c6388 feat: add filter search to OA pending/done/mine/cc task centers
Support server-side filters (app name, initiator, apply time, instance status)
with Fresh Mint filter blocks and 300ms debounced instant search.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 10:54:59 +08:00
zlwuandCursor 80ef84e68f test: add failing OA task list filter coverage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 10:47:56 +08:00
zlwuandCursor 73abb71c65 docs: add OA approval task center filter design and HTML demo
Document server-side filters, instant search UX, and page field matrix for pending/done/mine/cc lists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 10:43:52 +08:00
zlwuandCursor c750072f6f docs: add OA overtime auto-duration implementation plan
Break down holiday cache sync, dual-end hour calculation, and start-form
wiring into TDD tasks aligned with the approved design spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 22:52:16 +08:00
zlwuandCursor 2f02a2d0f2 docs: add OA overtime auto-duration and holiday cache design
Capture agreed rules for overtime kit auto/manual hours, ailcc sync
via in-process midnight loop, and dual-end calculation aligned with leave kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 22:49:56 +08:00
zlwuandCursor efd7efbc69 feat: implement WeChat mini login with openid bind and phone match
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 16:24:19 +08:00
zlwuandCursor 89d9471f37 docs: add WeChat login implementation plan for mobile-h5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 16:22:07 +08:00
zlwuandCursor 3533c42291 docs: add mobile-h5 WeChat login design (openid + phone)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 16:14:42 +08:00
zlwuandCursor 17a1f88626 feat(mobile-h5): wire password login to backend and disable WeChat button
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:58:13 +08:00
zlwuandCursor 8efa992e21 feat(mobile-h5): add RSA login body builder with jsencrypt
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:57:35 +08:00
zlwuandCursor 2bc7f353f0 feat(mobile-h5): add thin uni.request wrapper for API calls
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:55:41 +08:00
zlwuandCursor 25d1b3454b chore(mobile-h5): add API_BASE_URL config for login
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:54:23 +08:00
zlwuandCursor 6dd7a95ce8 docs: add mobile-h5 password login API implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:52:29 +08:00
zlwuandCursor 5e0a1ce0a1 docs: add mobile-h5 password login API integration design
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:50:39 +08:00
zlwuandCursor ef259f1f0c feat(mobile-h5): apply scheme B empty state globally
Restyle van-empty as a dashed mint soft card and add optional tip on home and comps demo so all empty states stay consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:36:43 +08:00
zlwuandCursor 169781a702 docs: add mobile-h5 empty state design (scheme B)
Record the dashed soft-card empty state as the global van-empty default, with a visual demo for review.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:34:24 +08:00
zlwuandCursor 01e4b3b494 docs: link miniprogram login demo and mobile-h5 page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:44:42 +08:00
zlwuandCursor c7794f714b feat(mobile-h5): add logout on workbench for login demo loop
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:44:42 +08:00
zlwuandCursor 4420fb570e feat(mobile-h5): register login as launch page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:44:41 +08:00
zlwuandCursor 12ccc346b0 feat(mobile-h5): add employee login page UI (scheme B)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:44:41 +08:00
zlwuandCursor da33a4182c style(mobile-h5): add login page SCSS for scheme B layout
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:41:37 +08:00
zlwuandCursor fd51a0bee4 feat(mobile-h5): add mock auth helpers for login page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:39:48 +08:00
zlwuandCursor 0989d4ba0a chore(mobile-h5): add WeChat logo asset for login button
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:38:45 +08:00
zlwuandCursor e9f4350af4 chore(mobile-h5): track HBuilderX uni-app skeleton files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:03:45 +08:00
zlwuandCursor 031e456614 docs: record mobile-h5 Vant UI design, plan, and demo
Link the workspace UI demo and implementation plan from the
miniprogram UI spec for the unified mobile workbench.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:03:36 +08:00
zlwuandCursor 8793fa07cc feat(mobile-h5): add styles, mocks, components, and demo pages
Land Token-based SCSS modules, menu/biz mocks, reusable shell
components, and home plus list/detail/form/comps template pages
aligned with the Vant Weapp UI demo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:03:36 +08:00
zlwuandCursor cfe0e616ab feat(mobile-h5): add design tokens and global style entry
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:00:25 +08:00
zlwuandCursor 8a5804aa04 chore(mobile-h5): configure Vant easycom and demo page routes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 13:59:23 +08:00
zlwuandCursor e5bc54516f chore(mobile-h5): add package.json and @vant/weapp dependency
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 13:54:48 +08:00
zlwu a9ba60cf6b 提交 2026-08-06 22:21:14 +08:00
zlwu 6a8c3e750e 提交 2026-08-06 16:54:32 +08:00
zlwuandCursor ac4c3d1886 docs(oa): mark dept condition spec implemented
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:41:08 +08:00
zlwuandCursor 6beb243604 feat(oa): tree multi-select for dept condition values
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:40:54 +08:00
zlwuandCursor f874447ae2 feat(oa): whitelist dept and ownerDept for condition participation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:40:09 +08:00
zlwuandCursor d2de132853 feat(oa): allow dept and ownerDept in condition publish whitelist
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:39:58 +08:00
zlwuandCursor 86dd605ac5 feat(oa): evaluate dept contains in condition rules
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:33:41 +08:00
zlwuandCursor f1fad2d27d feat(oa): add dept_contains_match for condition id intersection
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:19:32 +08:00
zlwuandCursor e93edc77f4 chore: update graphify and add drafts inline-edit plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 14:58:23 +08:00
zlwuandCursor 3cf2f68b1a feat(web-admin): edit and submit OA drafts in-place via shared dialog.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 14:55:38 +08:00
zlwuandCursor cf009de58a refactor(web-admin): wire start page to OaStartFormDialog.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 14:47:02 +08:00
zlwuandCursor 0dcc05dca7 feat(web-admin): extract OaStartFormDialog for shared start/draft edit.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 14:38:09 +08:00
zlwuandCursor 7a1433a489 docs: add OA drafts inline edit dialog design.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 14:31:11 +08:00
zlwuandCursor de22678c87 feat: ship OA drafts/badges, role dual-track, and test-env deploy tooling.
Add approval instance drafts with menu badges, dual-track user/role read model and role bound names, form-designer outline docs, and bare-metal test deploy scripts. Ignore local test-server credential file.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:58:47 +08:00
zlwuandCursor 7af53fc344 chore: update graphify after OA app icon ep pastel.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:19:43 +08:00
zlwuandCursor 7e166de4b1 feat(web-admin): show ep pastel icons on start and designer.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:16:11 +08:00
zlwuandCursor 542f449b37 feat(web-admin): wire OA apps page to ep pastel icon picker.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:13:42 +08:00
zlwuandCursor 0cbfa7bb91 feat(web-admin): add OaAppIconPicker for ep palette and upload.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:11:16 +08:00
zlwuandCursor e4279dc953 feat(web-admin): add OaAppIcon display for ep and image URLs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:07:25 +08:00
zlwuandCursor bfb495ee77 feat(web-admin): add ep: pastel OA app icon URL helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:03:46 +08:00
zlwuandCursor 9193908400 docs: add OA app icon ep pastel implementation plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:58:58 +08:00
zlwuandCursor 0853777fdf docs: add OA app icon pastel + Element Plus design and demo.
Capture the approved encoding (ep:Icon|#bg), 8-color palette, picker UX, and interactive HTML demo for implementation planning.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:54:27 +08:00
zlwuandCursor 01d4fcf12f chore: update graphify after OA CC unread badge.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:19:45 +08:00
zlwuandCursor 98423aad7b feat(web-admin): CC list read filter, mark-read, and batch read.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:16:33 +08:00
zlwuandCursor 9c589de6d7 feat(web-admin): sidebar badge for OA CC unread count.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:14:42 +08:00
zlwuandCursor 9825d52bfb feat(web-admin): OA CC read/unread API helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:12:57 +08:00
zlwuandCursor fbfbaa6d17 feat(api): expose CC unread count and mark-read endpoints.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:11:14 +08:00
zlwuandCursor 7f8c163c47 feat(backend): CC task read status in oa_engine.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:08:31 +08:00
zlwuandCursor 4d72231ea3 feat(backend): add is_read fields on OaTask model.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:04:00 +08:00
zlwuandCursor 39892c6dc0 docs(sql): add oa_task is_read columns for CC unread badge.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:01:14 +08:00
zlwuandCursor 6fd23303d2 docs(oa): add CC unread badge implementation plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:59:52 +08:00
zlwuandCursor 48de4be893 docs(oa): add CC unread badge design and demo
Capture the approved design for sidebar unread counts and read/batch-read flows on 抄送我的, with an interactive HTML demo for UI reference.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:57:33 +08:00
zlwuandCursor 19c6d06fa0 fix(post): hide stale deleted user ids in post view dialog
Omit redundant field labels and exclude soft-deleted users from post bindings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:43:35 +08:00
zlwuandCursor a330903625 fix(web-admin): skip auto-icon on post name list links
Post names containing 查看 were matched by global button auto-icon rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:39:25 +08:00
zlwuandCursor 3e60ed56f0 feat(post): list name link and show bound user names
Enrich post list with boundUserNames and open view dialog from the name column.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:33:52 +08:00
zlwuandCursor 45ada351ba docs: add post list name-link and bound users plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:29:48 +08:00
zlwuandCursor a9298ac6ea docs: add post list name-link and bound users design
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:28:42 +08:00
zlwuandCursor 4d3fed3215 chore: ignore local PID files and Graphify AST/incremental caches
Stop tracking runtime PID and regenerable Graphify cache/state so they no longer clutter git status.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 17:52:36 +08:00
zlwuandCursor 06db3bc966 feat(permission): replace OA roles with post-bound roles and assignee posts
Unify effective roles via post-role bindings, remove dedicated OA role UI, and update OA assignee resolution plus related SQL/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 17:41:04 +08:00
zlwuandCursor a3c73d76bb docs(oa): mark trip kit design as implemented
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:03:53 +08:00
zlwuandCursor 29b11743e7 feat(oa): wire trip kit duration sync on approval start
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:03:19 +08:00
zlwuandCursor 8294361817 feat(oa): register tripKit in FormCreate designer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:02:33 +08:00
zlwuandCursor 0101e46ed9 feat(oa): add tripItinerary FormCreate control
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:00:54 +08:00
zlwuandCursor 33bf0c32a5 feat(oa): add trip kit day calc and submit validation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 18:30:00 +08:00
zlwuandCursor 14219b9a52 feat(oa): add trip kit half-day duration helpers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 18:26:38 +08:00
zlwuandCursor 1ef4385608 docs(oa): add trip kit implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 18:22:01 +08:00
zlwuandCursor feec1ca0bf docs(oa): add trip kit (出差套件) design spec
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 18:16:27 +08:00
zlwuandCursor 4ebc2dbe82 feat(oa): add owner person/dept fields with initiator defaults
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:54:48 +08:00
zlwuandCursor 3b46b10604 feat(oa): add leave kit with auto duration calculation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:54:48 +08:00
zlwuandCursor b3d267e9ca feat(oa): add overtime kit form components and designer registration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:54:16 +08:00
zlwuandCursor 684f152683 feat(oa): add resignation kit with last_work_day writeback on approval
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:51:33 +08:00
zlwuandCursor 958a6a8cb8 docs(oa): mark confirmation kit implemented; unwrap kit in print
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:29:40 +08:00
zlwuandCursor fa13440f18 feat(oa): sync initiator user from confirmation kit on approval complete
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:28:18 +08:00
zlwuandCursor 493ce698df feat(oa): confirmation kit defaults and confirmation-date linkage on start form
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:27:05 +08:00
zlwuandCursor 898a337f96 feat(oa): register confirmation kit (转正套件) in FormCreate designer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:24:06 +08:00
zlwuandCursor b22777fa2d feat(user): add hire_date and probation_months to sys_user and user admin
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:22:41 +08:00
zlwuandCursor b1b15da624 feat(oa): add confirmation date month-add helper for 转正套件
Also add the confirmation kit implementation plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:19:09 +08:00
zlwuandCursor c31ef0e809 docs: add OA confirmation kit (转正套件) design spec.
Capture product decisions for user hire/probation fields, kit composition, auto confirmation date, and post-approval writeback to initiator.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:06:21 +08:00
zlwu 310b2efe8a 提交 2026-07-30 15:02:13 +08:00
zlwuandCursor f7d1004e6b feat(oa): CC designer multi-select and preview assigneeTypes support
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:30:08 +08:00
zlwuandCursor 835c1c0836 feat(oa): resolve CC tasks with multi-strategy soft union
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:28:24 +08:00
zlwuandCursor ef91e189dd feat(oa): validate CC assigneeTypes on publish
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:27:25 +08:00
zlwuandCursor 2326f8bf99 feat(oa): add resolve_cc_assignees for multi-strategy CC soft union
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:23:05 +08:00
zlwuandCursor 6c4c0184d0 feat(web-admin): allow creating receive accounts from the form select.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:09:12 +08:00
zlwuandCursor 1b6ef504ac fix(web-admin): show receive account details as single-line text.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:04:36 +08:00
zlwuandCursor a035a9d6ad fix(web-admin): show receive account details below the form select.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:02:11 +08:00
zlwuandCursor 054991fbd0 fix(web-admin): use text inputs for bank province/city on receive accounts.
The china-region-tree API is not implemented; store province and city as free text names instead of cascading selects.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:57:21 +08:00
zlwuandCursor a73ea36f9e docs: mark receive account design implemented and refresh graphs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:54:20 +08:00
zlwuandCursor 0d59627fa3 feat(web-admin): add OA receive account management page.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:53:58 +08:00
zlwuandCursor cf61ce340d feat(web-admin): register receiveAccount FormCreate control.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:49:43 +08:00
zlwuandCursor 4e805f9625 fix(web-admin): harden receive account display helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:49:02 +08:00
zlwuandCursor d946b91dd1 feat(web-admin): add receive account snapshot value helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:46:22 +08:00
zlwuandCursor 947e6ae6f8 feat(backend): expose receive account APIs and form validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:45:02 +08:00
zlwuandCursor 9277b9c568 fix(backend): coerce receive account order_num to int or None.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:44:22 +08:00
zlwuandCursor 51f868ea44 feat(backend): add receive account CRUD helpers and validators.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:40:59 +08:00
zlwuandCursor 64fb25d3f6 feat(backend): add OaReceiveAccount model.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:38:42 +08:00
zlwuandCursor 9f796a7b96 docs(sql): add oa_receive_account table and menu.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:37:01 +08:00
zlwuandCursor 563da3626b docs: add OA receive account implementation plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:35:36 +08:00
zlwuandCursor 7aef0781a7 docs: add OA receive account management design and mockup.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:32:07 +08:00
zlwu ec4b9438b4 提交 2026-07-30 13:08:23 +08:00
zlwuandCursor 22be6d8b36 feat(oa): add initiator system field to condition branches
Allow __initiator__ contains matching in publish validation and the
flow designer candidate list, excluding it from form useInCondition locks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:07:44 +08:00
zlwuandCursor 3d88f52e2e feat(backend): match __initiator__ contains in condition groups
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:04:24 +08:00
zlwuandCursor a37bfac213 docs: add OA initiator condition implementation plan.
Cover engine matching, publish validation, and condition editor injection
for system field __initiator__.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:03:15 +08:00
zlwuandCursor a501fb1219 docs: add OA initiator contains condition design spec.
Describe system pseudo-field __initiator__ for workflow branches,
contains-only matching, and publish/engine touchpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:01:56 +08:00
zlwuandCursor cf069c74ec feat(oa): add person select control with contains conditions and print
Register FormCreate person field (dept-aligned UI), enable contains-only
workflow conditions, and format person values in print/related summary.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 12:18:19 +08:00
zlwuandCursor 85322c39e4 feat(backend): evaluate person contains rules in condition groups
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 12:16:08 +08:00
zlwuandCursor 330dd9e2ce feat(backend): validate OA person field and whitelist type
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 12:12:31 +08:00
zlwuandCursor 6aa6c6e73e feat(web-admin): add OA person value normalize helpers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 12:09:45 +08:00
zlwuandCursor e760de4bd3 docs: add OA person select implementation plan with preview notes.
Include FormCreate mobile canvas and preview dialog constraints, plus
start-page flow preview via contains matching.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 12:08:06 +08:00
zlwuandCursor 39d1031f6c docs: add OA person select control design spec.
Capture FormCreate person field (dept-aligned UI), form_data contract,
and contains-only condition branching for workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 12:03:38 +08:00
zlwuandCursor be41e8f202 feat(web-admin): mobile-friendly OA money field for phone preview
Add decimal inputmode, taller touch targets under 768px, and wrapping uppercase; document phase-1 vs phase-2 mobile scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:15:40 +08:00
zlwuandCursor 221a6fd360 docs: mark OA money field design as implemented
Update status and keep the implementation plan alongside the spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:11:13 +08:00
zlwuandCursor b5c4ba4c4f feat(oa): format money fields in print and related-approval summary
Show formatted amount with optional uppercase on print; summary uses numeric text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:11:13 +08:00
zlwuandCursor 8e98ffe950 feat(web-admin): register OA money field with uppercase display
Add DragRule, dual-host FormCreate setup, and AmountInput runtime with optional RMB uppercase.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:11:13 +08:00
zlwuandCursor 55e4134050 feat(backend): validate OA money field values on instance start
Reject negatives, non-finite values, and missing required money controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:04:30 +08:00
zlwuandCursor 0a24f707bf fix(web-admin): avoid duplicate 零 in RMB uppercase for yi+rest
Add regression tests for 亿到元段间零填充。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:01:33 +08:00
zlwuandCursor e6d7eecdc6 feat(web-admin): add RMB uppercase converter for OA money field
Pure function with node:test coverage for zero, jiao/fen, and range limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:56:03 +08:00
zlwuandCursor 98074bf201 docs: add OA money field (amount + RMB uppercase) design.
Captures DingTalk-aligned product decisions for the approval form designer amount control before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:48:55 +08:00
zlwu 43d117133b 提交 2026-07-29 17:04:36 +08:00
zlwuandCursor 1b934a2037 fix(outbound-release): use eq_unicode for schedule id EXISTS joins
Avoid Illegal mix of collations when correlating QC/production work orders to infusion schedules in list filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:36:34 +08:00
zlwu 5d17113e1a 提交 2026-07-29 16:08:34 +08:00
zlwuandCursor 111baacd2d docs: mark outbound release card align-protocol design as implemented
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:04:09 +08:00
zlwuandCursor 20e3022f07 style(web-admin): drop unused protocol-card block modifiers on outbound cards
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:03:48 +08:00
zlwuandCursor 6445e51523 feat(web-admin): align outbound release cards with protocol-card layout
Reuse protocol-card class names and structure; map outbound fields into aside and three sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:02:28 +08:00
zlwuandCursor 745802ce61 docs: add outbound release card align-protocol implementation plan
Rewrite OutboundReleaseReviewCardList to protocol-card DOM and copied SCSS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:58:45 +08:00
zlwuandCursor cccee8e2cc docs: add outbound release card align-to-protocol design and demo
Document reusing protocol-card class names with outbound fields; include HTML comparison demo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:57:05 +08:00
zlwuandCursor df973f6bdb docs: mark outbound release tabs/ready/void design as implemented
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:37:59 +08:00
zlwuandCursor 5e9e81d28e feat(web-admin): outbound release pending/reviewed tabs and ready checkbox
Match storage-release review UX for pilot/basic-experiment outbound release lists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:37:13 +08:00
zlwuandCursor 12df6d6836 feat(outbound-release): add reviewPhase, ready filter, and void hide
List supports pending/reviewed phase, readyForReviewOnly, and always hides schedules whose outbound QC or production WOs are all voided.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:34:29 +08:00
zlwuandCursor d3cc76d560 docs: add outbound release tabs/ready/void implementation plan
Break down backend list filters and web-admin tabs/checkbox aligned with storage-release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:32:15 +08:00
zlwuandCursor 5c7372ee06 docs: add outbound release tabs/ready/void design
Align outbound release review with inbound tabs, ready-for-review checkbox, and voided WO list hide.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:30:40 +08:00
zlwuandCursor b1cb13fdfe docs: mark storage-release hide-voided design as implemented
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:19:41 +08:00
zlwuandCursor 8b18d21c88 feat(storage-release): hide list rows when related docs are void
Exclude reviews whose protocol is inactive or whose inbound QC/production work orders are all voided.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:17:13 +08:00
zlwuandCursor a922ad12f4 feat(storage-release): add hide-voided predicate helpers
Document protocol inactive and all-inbound-WO-void semantics for list filtering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:10:12 +08:00
zlwuandCursor 5d15d8ad6f docs: add storage-release hide-voided implementation plan
Break down list SQL filter and predicate unit tests for voided related documents.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:06:41 +08:00
zlwuandCursor 3928d9e118 docs: add storage-release hide voided related design
Document list-only hide rules when protocol is inactive or all inbound QC/production work orders are voided.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:04:57 +08:00
zlwuandCursor 38f18f1926 docs: mark storage-release ready filter as implemented
Add design spec, plan checkboxes, and interactive filter demo for the pending readyForReviewOnly feature.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:25:04 +08:00
zlwuandCursor 63074c0032 feat(web-admin): pending storage-release ready checkbox filter
Replace QC-completed select with show-all checkbox; request readyForReviewOnly when unchecked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:21:21 +08:00
zlwuandCursor 9e5cc9188b feat(storage-release): add readyForReviewOnly list filter
Default pending list can exclude rows with unfinished QC or production work orders via SQL NOT EXISTS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:16:42 +08:00
zlwu 3317d4a077 提交 2026-07-29 10:26:07 +08:00
zlwuandCursor 56cee052d4 docs: add lineage QC design/plan and pass project fields to material dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 09:56:08 +08:00
zlwuandCursor 33c4408557 feat: expose lineage QC API and wire viewer in outbound material dialog
Add GET /cell-products/{id}/lineage-qc, frontend client, readonly dialog,
and a 查看质检 action on outbound cell rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 09:55:51 +08:00
zlwuandCursor 40d93934a9 feat: add cell product lineage QC aggregate loader
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 09:54:28 +08:00
zlwuandCursor f2e582b4dc feat: add lineage node flatten helper for cell product QC view
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 09:49:35 +08:00
zlwu b569643581 提交 2026-07-28 21:33:15 +08:00
zlwuandCursor b105bd9b7d chore: update graphify and mark batch outbound plan complete
Refresh knowledge graphs after storage facility batch outbound changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 19:15:35 +08:00
zlwuandCursor 9978ae7724 feat(web-admin): add shelf cell checkboxes for batch outbound
Occupied cells show always-on checkboxes; body click still opens detail dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 19:15:05 +08:00
zlwuandCursor 789c6443f3 feat(web-admin): add batch outbound selection state and toolbar
Wire selected slots map, confirm dialog, and serial remove occupancy calls on storage shelf.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 19:13:26 +08:00
zlwuandCursor 0a055557cb feat(storage): expose storedOccupancyId on shelf slot enrichment
Enable web-admin batch outbound to call existing remove occupancy API per selected cell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 19:11:24 +08:00
zlwuandCursor d4c927a8b9 docs: add storage facility batch outbound implementation plan
Break down shelf checkbox selection and serial occupancy remove into backend enrichment and Vue tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 19:10:29 +08:00
zlwuandCursor 45c2e3df01 docs: add storage facility batch outbound design and demo
Capture confirmed UI (always-on checkboxes) and serial remove via existing occupancy API for implementation planning.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 19:08:43 +08:00
zlwuandCursor d323e09894 chore: track app.pid and misplaced graphify AST caches
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 18:48:07 +08:00
zlwuandCursor d51bbad12d chore(web-admin): update graphify after qc conclusion and storage facility changes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 18:46:39 +08:00
zlwuandCursor a3af039e74 feat: 质检工单结论、库位批量/筛选与协议样本接收质检摘要
落库并展示质检工单级结论(可编辑/任务派生),库位树支持批量新增子节点与货架格子检索,协议/捐赠列表补充接收与门禁质检摘要,并同步生产物料与工单卡片展示优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 18:46:24 +08:00
zlwuandCursor f554d77e59 chore(web-admin): update graphify after product-lines batch fill
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 18:33:20 +08:00
zlwuandCursor d620d5d857 feat(web-admin): enable copy-first and batch slot fill on cell product create
Keep inbound product-lines UX consistent with task output dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:35:59 +08:00
zlwuandCursor 3079d5da7e feat(web-admin): enable copy-first and batch slot fill on task output dialog
Speed up multi-line cell product inbound entry in production task execution.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:35:59 +08:00
zlwuandCursor 0c24165922 feat(web-admin): add product-lines batch storage slot fill dialog
Pick same-box start/end slots and resolve fill targets for empty lines.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:35:59 +08:00
zlwuandCursor eae8743826 feat(web-admin): add storage slot range-fill helpers for product lines
Pure functions for copy-first-row attrs and same-box start/end slot pick.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:33:32 +08:00
zlwuandCursor 5942feb06e docs: add product-lines batch fill design and plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:31:49 +08:00
zlwuandCursor e6c8a9c88d feat(web-admin): expose facilityNodeId on storage slot select options
Needed so batch start/end fill can verify slots belong to the same box.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:30:56 +08:00
zlwu bff544404f 提交 2026-07-27 19:47:13 +08:00
zlwuandCursor 51f205e89b fix(oa): skip dept tree fetch inside form designer iframe
oa-fc-host has no Pinia/auth; mounting OaDeptSelect previously called
fetchDeptTree and surfaced false network errors when dragging the control.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:43:01 +08:00
zlwuandCursor 2e6e1f47d6 feat(oa): add dept select FormCreate control with print formatting
Register type=dept on main app and oa-fc-host, with PC tree-select and
narrow drawer pickers storing {id,name} path snapshots; format dept values
in print and related-summary flatten.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:32:44 +08:00
zlwuandCursor 45d84d9642 feat(oa): reject invalid dept form_data on start
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:27:27 +08:00
zlwuandCursor babe26a23d fix(oa): coerce dept max prop when validating form_data
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:26:57 +08:00
zlwuandCursor 260a461d05 feat(oa): validate dept control form_data shapes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:24:28 +08:00
zlwuandCursor 1d24f1c682 docs(oa): add department select implementation plan
Break the approved dept control design into TDD-ready tasks covering
validation, FormCreate registration, PC/narrow pickers, and print formatting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:21:54 +08:00
zlwuandCursor 19c97d148f docs(oa): add department select control design and mockup
Capture the approved OA form designer dept control contract (multi/single,
{id,name} path snapshot, mobile full-screen picker) and a Fresh Mint HTML demo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:17:38 +08:00
zlwuandCursor d5b0f2d797 chore(graphify): sync knowledge graph artifacts after protocol fix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:50:02 +08:00
zlwuandCursor 57487fd0dc fix(protocol): save DJ pay details without blocking on due-sum mismatch
Persist payment before samples to avoid consuming sample codes on validation failure, soft-delete samples on create rollback, and surface DJ due-amount mismatch as a hint only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:49:36 +08:00
zlwuandCursor f661d40f3a feat(oa): add auto-pass-when-approver-is-initiator advanced switch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:43:39 +08:00
zlwuandCursor 1fafb4a157 test(oa): cover auto-pass or/and/consecutive/commentRequired cases
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:43:18 +08:00
zlwuandCursor 8bd45561b7 feat(oa): auto-pass approval when assignee is initiator
Extract approve core and hook after creating approval tasks when the advanced setting is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:42:41 +08:00
zlwuandCursor d69388573a test(oa): add failing auto-pass-when-approver-is-initiator case
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:41:56 +08:00
zlwuandCursor b9193b3175 docs(oa): add auto-pass-when-approver-is-initiator implementation plan
Break the approved design into TDD tasks for engine core, tests, and designer switch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:37:07 +08:00
zlwuandCursor 9b76aa654e docs(oa): add auto-pass when approver is initiator design
Document advanced setting and engine behavior for skipping self-approval when the assignee equals the initiator.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:32:48 +08:00
zlwu 7f4c9720e9 提交 2026-07-25 09:37:24 +08:00
8558 changed files with 1044151 additions and 400007 deletions

No files matched your search

+9
View File
@@ -27,3 +27,12 @@ web-admin/**/*.local
web-admin/.env
web-admin/.env.*
!web-admin/.env.docker
mobile-oa-h5/node_modules
mobile-oa-h5/dist
mobile-oa-h5/.env
mobile-oa-h5/.env.*
!mobile-oa-h5/.env.docker
# 小程序原生工程不进 web 镜像构建上下文
mobile-h5/
+55
View File
@@ -0,0 +1,55 @@
# 生产 Docker 交付包:仅 tag 触发
# 镜像与包名固定为 v1.1.0(与现网 .env / 部署习惯一致),便于生产升级时不必改镜像标签
# 产物:/root/zkxw-releases/zkxw-docker-deploy-v1.1.0.tar.gz
# 脚本:./scripts/package-docker-deploy.sh
#
# docker.sock 与 /releases 由 runner 环境变量 DRONE_RUNNER_VOLUMES 注入,
# 勿在本文件再声明同路径 host volume,否则会 Duplicate mount point。
#
# 注意:Drone 会对 commands 里的 ${VAR} 做预处理替换;自定义环境变量请用 $${VAR}
# 或直接写死版本号,避免变成空字符串。
kind: pipeline
type: docker
name: package-prod
concurrency:
limit: 1
trigger:
event:
- tag
clone:
depth: 1
steps:
- name: package-docker
image: docker:27-cli
environment:
DOCKER_BUILDKIT: '1'
NODE_MAX_OLD_SPACE_SIZE: '3072'
NPM_REGISTRY: https://registry.npmmirror.com
PIP_INDEX_URL: https://mirrors.aliyun.com/pypi/simple/
PIP_TRUSTED_HOST: mirrors.aliyun.com
ZKXW_DEPLOY_VERSION: v1.1.0
commands:
- set -eu
# 国内机访问 dl-cdn.alpinelinux.org 偶发 Permission denied,改用阿里云镜像
- sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
- apk add --no-cache bash git python3
# 固定 v1.1.0;$${ } 防止 Drone 预处理把自定义变量掏空
- export ZKXW_DEPLOY_VERSION="$${ZKXW_DEPLOY_VERSION:-v1.1.0}"
- echo "==> git tag=${DRONE_TAG:-none} deploy_version=$${ZKXW_DEPLOY_VERSION} commit=${DRONE_COMMIT}"
- chmod +x scripts/package-docker-deploy.sh scripts/build-docker-images.sh
- |
if [ -f /releases/.deploy-docker.env ]; then
mkdir -p deploy/docker
cp /releases/.deploy-docker.env deploy/docker/.env
echo "using /releases/.deploy-docker.env for secret inject"
fi
- ./scripts/package-docker-deploy.sh "$${ZKXW_DEPLOY_VERSION}"
- mkdir -p /releases
- cp -v dist/zkxw-docker-deploy-"$${ZKXW_DEPLOY_VERSION}".tar.gz /releases/
- ls -lh /releases/zkxw-docker-deploy-"$${ZKXW_DEPLOY_VERSION}".tar.gz
- sha256sum /releases/zkxw-docker-deploy-"$${ZKXW_DEPLOY_VERSION}".tar.gz
+21
View File
@@ -49,10 +49,18 @@ __pycache__/
.env
.env.*
!.env.example
!mobile-oa-h5/.env.development
!mobile-oa-h5/.env.docker
!web-admin/.env.docker
*.pem
*.key
*.p12
secrets/
生产环境信息/root密码
生产环境信息/*_nginx/
生产环境信息/微信生产.env.snippet
生产环境信息/微信配置-测试号备份.txt
生产环境信息/zkxw-v110-backend.env*
# 日志与临时文件
*.log
@@ -84,3 +92,16 @@ exports/
# Local git worktrees (isolated feature checkouts)
.worktrees/
# 本地进程 PID(如 backend/app.pid,由 start-*.sh 写入,勿提交)
*.pid
# Graphify:AST 解析缓存(体积大、可本地再生成;常见于 backend/app/、web-admin/src/ 下)
**/graphify-out/cache/
# Graphify:增量检测/状态文件(commit hook 或 update-graph.sh 触发后产生)
**/.graphify_detect.json
**/.graphify_incremental.json
# 本地密钥(勿提交)
测试环境机器的账号密码
@@ -1 +0,0 @@
78976
+3
View File
@@ -70,6 +70,7 @@ Full specs: `openspec/changes/zkxw-engineering-standards/specs/*/spec.md`. Below
- **DB changes**: SHALL maintain SQL in `docs/sql/`. Schema changes SHALL align with `database-conventions` public field rules.
- **Commit messages**: SHALL be complete sentences describing "what changed and why". Follow Conventional Commits (`feat|fix|docs|…`).
- **Field consistency (CRUD ↔ Schema ↔ Frontend)**: When implementing CRUD for any entity, the `_row_to_dict` function is the **single source of truth** for all readable fields. The CreateBody/UpdateBody Pydantic schemas and the frontend `formData` initialization SHALL include every writable field present in `_row_to_dict` (excluding computed/read-only fields like `id`, `createTime`, `updateTime`, `stockQty`). Run `python scripts/check_field_consistency.py` after implementation to verify. See `docs/dev-field-consistency.md` for details.
- **Child-row save(禁止整表删除再新增)**: 保存子表时 SHALL 按行处理:已有行就地更新,只插入新增行,只软删除用户去掉的行。客户端可以提交稳定 `id`,字段变化时该行必须保留原 `id`。请求未带 `id` 时,先用稳定业务键(样本编码、物料编码、条码、组分 id 等)匹配一条尚未占用的有效行,匹配不到再插入新 UUID。属于其他父记录或其他租户的 `id` SHALL 拒绝。SHALL NOT 在保存时把子表全部软删除再整批插入新行——其他表会拿这些 id 做外键(`production_step_output_id`、`protocol_sample_id`、`donation_sample_id`),整批换 id 会把关联打断。删除父记录时可以级联软删除其子行。复制或克隆父记录时 SHALL 为子行分配新 id。实现入口:`backend/app/crud/child_row_sync.py` 的 `sync_child_rows`。
### Permission Conventions
@@ -220,3 +221,5 @@ pnpm clean:dev # strip demo data for fresh start
- **Table column widths**: All `el-table-column` SHALL use `min-width` (not `width`) for proportional scaling when viewport resizes.
- **Toolbar**: "已选 N 项" first, then primary action button (only one `type="primary"` per toolbar row), no `space-between` push.
- **Database changes**: always pair with schema SQL in `docs/sql/` and evaluate API/TypeScript type impact.
- **Child-row save**: SHALL NOT soft-delete every child row and insert new rows on save. Update in place, insert new rows, soft-delete only removed rows. See Coding Conventions.
- **进程重启**:修改 bug 时,如果前端或后端需要重启,Agent 只告知用户,由用户自行重启。Agent 不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。
+46 -2
View File
@@ -7,8 +7,10 @@ DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
# 已废弃:无角色用户不可登录,JWT role 固定为 guest(勿设为 super_admin)
# WEB_AUTH_DEFAULT_ROLE=guest
# JWT配置示例
# SECRET_KEY=your-secret-key-change-in-production
# JWT配置(必填;启动时会拒绝空值与弱占位密钥,长度至少 32)
# 生成:python -c "import secrets; print(secrets.token_urlsafe(48))"
# 注意:更换 SECRET_KEY 会使已签发的 JWT 全部失效,用户需重新登录;生产勿随意轮换。
SECRET_KEY=
# ALGORITHM=HS256
# ACCESS_TOKEN_EXPIRE_MINUTES=30
@@ -50,6 +52,7 @@ DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
# 微信支付配置示例
# 小程序支付使用 JSAPI(trade_type=JSAPI)
# 同时复用于小程序微信登录(code2session / 手机号):WXPAY_MINI_APPID + WXPAY_SECRET
# WXPAY_MCHID=1900000109
# WXPAY_MINI_APPID=wx1234567890abcdef
# WXPAY_SECRET=your-miniapp-secret
@@ -57,3 +60,44 @@ DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
# WXPAY_NOTIFY_URL=https://www.chengyantek.cn/api/payment/notify
# WXPAY_REFUND_NOTIFY_URL=https://www.chengyantek.cn/api/payment/refund-notify
# WXPAY_SANDBOX=false
# 微信服务号(OA 审批/抄送模板消息;access_token 用服务号凭证,miniprogram.appid 仍用 WXPAY_MINI_APPID)
# 总开关:配好三套模板 ID 后改为 true
# WX_MP_NOTIFY_ENABLED=true
# WX_MP_APPID=wx_service_account_appid
# WX_MP_SECRET=wx_service_account_secret
# WX_MP_TEMPLATE_APPROVAL=ZKKnm3PrH97iKt4SDrONiw0TvpDyRbQXdmbF6wz-eak
# WX_MP_TEMPLATE_CC=rHu7intrAqOqSlxmjqq7FfyC1GKbRi3XaP1-ZEcnvqs
# WX_MP_TEMPLATE_APPLICANT_PROGRESS=rHu7intrAqOqSlxmjqq7Fb6ylcB3u3quKSBYUcR6NU4
# 审批:申请事项/操作人/操作时间/当前环节/申请类型
# WX_MP_TPL_FIELD_TITLE=thing12
# WX_MP_TPL_FIELD_APPLICANT=thing18
# WX_MP_TPL_FIELD_NODE=thing7
# WX_MP_TPL_FIELD_TIME=time17
# WX_MP_TPL_FIELD_PROCESS=thing8
# 抄送:业务名称/申请人/申请时间/流程名称
# WX_MP_TPL_CC_FIELD_TITLE=thing5
# WX_MP_TPL_CC_FIELD_APPLICANT=thing2
# WX_MP_TPL_CC_FIELD_TIME=time3
# WX_MP_TPL_CC_FIELD_PROCESS=thing1
# 申请人进度:业务名称/流程名称/审批人/审批结果(通过|失败|转办|其他)/审批时间
# WX_MP_TPL_PROGRESS_FIELD_BUSINESS=thing5
# WX_MP_TPL_PROGRESS_FIELD_PROCESS=thing1
# WX_MP_TPL_PROGRESS_FIELD_APPROVER=thing6
# WX_MP_TPL_PROGRESS_FIELD_RESULT=const4
# WX_MP_TPL_PROGRESS_FIELD_TIME=time11
# 基础实验交付日前一天提醒(出库提醒模板)
# WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY=oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM
# WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER=thing6
# WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT=thing10
# WX_MP_TPL_BE_DELIVERY_FIELD_QTY=character_string8
# WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT=thing7
# WX_MP_TPL_BE_DELIVERY_FIELD_TIME=time14
# 协议新增样本采集通知(工单类模板)
# WX_MP_TEMPLATE_SAMPLE_COLLECT=iOnQ9k2mavAOwFbp_6yH4t1SHqpEkaiwNWKueY0kpwE
# WX_MP_SAMPLE_COLLECT_ROLE_NAME=样本采集通知人员
# WX_MP_TPL_SC_FIELD_TITLE=thing2
# WX_MP_TPL_SC_FIELD_APPLICANT=thing8
# WX_MP_TPL_SC_FIELD_TIME=time3
# WX_MP_TPL_SC_FIELD_TYPE=phrase10
# WX_MP_TPL_SC_FIELD_SAMPLE_TYPE=thing17
-1
View File
@@ -1 +0,0 @@
20826
+22
View File
@@ -5,6 +5,10 @@ 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.protocol_sample_payment import router as protocol_sample_payment_router
from app.api.protocol_refund_record import router as protocol_refund_record_router
from app.api.presign_order import router as presign_order_router
from app.api.sample_freight import router as sample_freight_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
@@ -34,6 +38,13 @@ from app.api.cleanroom_garment import router as cleanroom_garment_router
from app.api.market import router as market_router
from app.api.pre_transit_sample import router as pre_transit_sample_router
from app.api.oa_approval import router as oa_approval_router
from app.api.mini_auth import router as mini_auth_router
from app.api.mini_menu import router as mini_menu_router
from app.api.market_mobile import router as market_mobile_router
from app.api.sample_storage_cert_qc import router as sample_storage_cert_qc_router
from app.api.customer_service_dict import router as customer_service_dict_router
from app.api.sample_cert_insurance import router as sample_cert_insurance_router
from app.api.sample_code_correction import router as sample_code_correction_router
__all__ = [
"public_router",
@@ -42,6 +53,10 @@ __all__ = [
"workflow_router",
"basic_info_router",
"collection_protocol_router",
"protocol_sample_payment_router",
"protocol_refund_record_router",
"presign_order_router",
"sample_freight_router",
"leave_router",
"operations_router",
"ethnic_router",
@@ -71,4 +86,11 @@ __all__ = [
"market_router",
"pre_transit_sample_router",
"oa_approval_router",
"mini_auth_router",
"mini_menu_router",
"market_mobile_router",
"sample_storage_cert_qc_router",
"customer_service_dict_router",
"sample_cert_insurance_router",
"sample_code_correction_router",
]
+128 -47
View File
@@ -1,4 +1,5 @@
"""运营端管理接口(租户、角色下拉等)"""
import asyncio
import os
import uuid
from typing import Any, List, Optional
@@ -26,8 +27,11 @@ 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.crud.collection_protocol import _lookup_attachment_by_url
from app.models.models import BizAttachment, SysDept, SysRole, SysUser, SysUserRole
from app.services.web_auth_roles import _map_role_name_to_web
from app.models.models import BizAttachment, SysDept, SysRole, SysUser
from app.services.web_auth_roles import (
_map_role_name_to_web,
list_user_ids_with_effective_role_name,
)
router = APIRouter(prefix="/web/admin", tags=["运营端管理"])
@@ -77,7 +81,7 @@ async def list_users_for_select(
):
"""返回用户下拉选项(label=昵称,value=用户ID),供流程设计器审批人选择使用。
可选 roleName:按 sys_role.role_name 精确过滤拥有该角色的用户。
可选 roleName:按有效角色(直接绑定 ∪ 职位继承)精确过滤。
"""
tenant_no = _user.get("tenant_no", "")
q = db.query(SysUser).filter(SysUser.del_flag == 0, SysUser.status == "1")
@@ -85,18 +89,12 @@ async def list_users_for_select(
q = q.filter(SysUser.tenant_no == tenant_no)
rn = (roleName or "").strip()
if rn:
q = (
q.join(SysUserRole, SysUserRole.user_id == SysUser.id)
.join(SysRole, SysRole.id == SysUserRole.role_id)
.filter(
SysUserRole.del_flag == 0,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.role_name == rn,
)
matched_ids = list_user_ids_with_effective_role_name(
db, tenant_no=tenant_no or settings.DEFAULT_TENANT_NO, role_name=rn
)
if tenant_no:
q = q.filter(SysUserRole.tenant_no == tenant_no, SysRole.tenant_no == tenant_no)
if not matched_ids:
return success_response([])
q = q.filter(SysUser.id.in_(matched_ids))
rows = q.order_by(SysUser.create_time.desc()).distinct().all()
out = [{"label": (r.nick_name or r.user_no or ""), "value": r.id} for r in rows]
return success_response(out)
@@ -239,6 +237,25 @@ def _get_tenant_no(db: Session, user: dict) -> str:
return (sys_user.tenant_no if sys_user else None) or "zkxw"
def _assert_user_tenant_access(db: Session, user: dict, target_user_id: str):
"""同租户或 super_admin 才可写操作用户;返回目标用户行。"""
row = user_svc.get_user(db, target_user_id)
if not row:
raise ValueError("用户不存在")
tenant_no = _get_tenant_no(db, user)
if row.tenant_no != tenant_no and user.get("role") != "super_admin":
raise ValueError("无权操作其他租户的用户")
return row
def _generate_temp_password(length: int = 12) -> str:
"""生成可读的临时密码(字母数字,避免易混淆字符)。"""
import secrets
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
return "".join(secrets.choice(alphabet) for _ in range(length))
# ---------------------------------------------------------------------------
# 用户管理
# ---------------------------------------------------------------------------
@@ -253,6 +270,9 @@ class UserCreateBody(BaseModel):
supervisor_user_id: Optional[str] = Field(None, alias="supervisorUserId")
email: Optional[str] = Field("", alias="email")
phone_no: Optional[str] = Field("", alias="phoneNo")
hire_date: Optional[str] = Field(None, alias="hireDate")
probation_months: Optional[int] = Field(None, alias="probationMonths")
last_work_day: Optional[str] = Field(None, alias="lastWorkDay")
sex: Optional[str] = Field("0", alias="sex")
avatar: Optional[str] = Field(None, alias="avatar")
status: Optional[str] = Field("1", alias="status")
@@ -269,6 +289,9 @@ class UserUpdateBody(BaseModel):
supervisor_user_id: Optional[str] = Field(None, alias="supervisorUserId")
email: Optional[str] = Field(None, alias="email")
phone_no: Optional[str] = Field(None, alias="phoneNo")
hire_date: Optional[str] = Field(None, alias="hireDate")
probation_months: Optional[int] = Field(None, alias="probationMonths")
last_work_day: Optional[str] = Field(None, alias="lastWorkDay")
sex: Optional[str] = Field(None, alias="sex")
avatar: Optional[str] = Field(None, alias="avatar")
status: Optional[str] = Field(None, alias="status")
@@ -311,17 +334,14 @@ async def list_users(
query = query.filter(SysUser.status == status)
rn = (roleName or "").strip()
if rn:
query = (
query.join(SysUserRole, SysUserRole.user_id == SysUser.id)
.join(SysRole, SysRole.id == SysUserRole.role_id)
.filter(
SysUserRole.del_flag == 0,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.role_name == rn,
)
matched_ids = list_user_ids_with_effective_role_name(
db, tenant_no=tenant_no, role_name=rn
)
query = query.filter(SysUserRole.tenant_no == tenant_no, SysRole.tenant_no == tenant_no)
if not matched_ids:
return success_response(
{"list": [], "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0}
)
query = query.filter(SysUser.id.in_(matched_ids))
query = apply_data_scope(query, SysUser, user, db, tenant_no)
total = query.distinct().count()
items = (
@@ -339,6 +359,7 @@ async def list_users(
result_list = [
user_svc._row_to_dict(r, dept_name=dept_map.get(r.dept_id or "", "")) for r in items
]
user_svc.enrich_user_dicts_with_relations(db, result_list)
tp = user_svc.total_pages(total, pageSize)
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
@@ -378,6 +399,9 @@ async def create_user(
supervisor_user_id=body.supervisor_user_id,
email=body.email,
phone_no=body.phone_no,
hire_date=body.hire_date,
probation_months=body.probation_months,
last_work_day=body.last_work_day,
sex=body.sex,
avatar=body.avatar,
status=body.status,
@@ -401,24 +425,30 @@ async def update_user(
db: Session = Depends(get_db),
):
try:
_assert_user_tenant_access(db, user, user_id)
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
obj = user_svc.update_user(
db,
user_id,
nick_name=payload.get("nick_name"),
dept_id=payload.get("dept_id"),
supervisor_user_id=payload.get("supervisor_user_id"),
email=payload.get("email"),
phone_no=payload.get("phone_no"),
sex=payload.get("sex"),
avatar=payload.get("avatar"),
status=payload.get("status"),
order_num=payload.get("order_num"),
remark=payload.get("remark"),
user_id_updater=user.get("user_id"),
role_ids=payload.get("role_ids"),
post_ids=payload.get("post_ids"),
)
update_kwargs: dict[str, Any] = {
"nick_name": payload.get("nick_name"),
"dept_id": payload.get("dept_id"),
"supervisor_user_id": payload.get("supervisor_user_id"),
"email": payload.get("email"),
"phone_no": payload.get("phone_no"),
"sex": payload.get("sex"),
"avatar": payload.get("avatar"),
"status": payload.get("status"),
"order_num": payload.get("order_num"),
"remark": payload.get("remark"),
"user_id_updater": user.get("user_id"),
"role_ids": payload.get("role_ids"),
"post_ids": payload.get("post_ids"),
}
if "hire_date" in payload:
update_kwargs["hire_date"] = payload.get("hire_date")
if "probation_months" in payload:
update_kwargs["probation_months"] = payload.get("probation_months")
if "last_work_day" in payload:
update_kwargs["last_work_day"] = payload.get("last_work_day")
obj = user_svc.update_user(db, user_id, **update_kwargs)
return success_response(user_svc._row_to_dict(obj), message="更新成功")
except ValueError as e:
return error_response(400, str(e))
@@ -427,10 +457,11 @@ async def update_user(
@router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
_user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
_assert_user_tenant_access(db, user, user_id)
user_svc.soft_delete_user(db, user_id)
return success_response(None, message="删除成功")
except ValueError as e:
@@ -440,12 +471,17 @@ async def delete_user(
@router.post("/users/{user_id}/reset-password")
async def reset_user_password(
user_id: str,
_user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
user_svc.reset_password(db, user_id, "111111")
return success_response(None, message="密码已重置为 111111")
_assert_user_tenant_access(db, user, user_id)
temp_password = _generate_temp_password(12)
user_svc.reset_password(db, user_id, temp_password)
return success_response(
{"tempPassword": temp_password},
message="密码已重置,请将临时密码告知用户并督促尽快修改",
)
except ValueError as e:
return error_response(400, str(e))
@@ -1032,6 +1068,46 @@ async def update_post_bound_users(
return error_response(400, str(e))
class PostRolesUpdateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
role_ids: List[str] = Field(default_factory=list, alias="roleIds")
@router.get("/posts/{post_id}/roles")
async def list_post_bound_roles(
post_id: str,
user: dict = Depends(require_menu_code("system:post:list")),
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 = post_svc.list_role_ids_for_post(db, post_id, tenant_no)
return success_response({"roleIds": ids})
@router.put("/posts/{post_id}/roles")
async def update_post_bound_roles(
post_id: str,
body: PostRolesUpdateBody,
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:
post_svc.sync_post_role_bindings(
db, post_id, tenant_no, body.role_ids, user.get("user_id")
)
return success_response(None, message="保存成功")
except ValueError as e:
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 物品单位管理
# ---------------------------------------------------------------------------
@@ -1541,7 +1617,10 @@ async def upload_attachment(
return error_response(400, "请选择文件")
try:
file_content = await file.read()
full_url = storage.upload_file(file_content, file.filename, folder or "")
# MinIO/本地写入是同步的。多张大图并行时若占住事件循环,其它上传会一直等不到响应,前端表现为超时后文件消失。
full_url = await asyncio.to_thread(
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:]
@@ -1595,8 +1674,10 @@ async def attachment_download_url(
return error_response(400, "文件地址不能为空")
tenant_no = _get_tenant_no(db, user)
att = _lookup_attachment_by_url(db, file_url, tenant_no)
if not att:
return error_response(403, "无权下载该附件或附件未登记")
download_name = (body.file_name or "").strip()
if not download_name and att:
if not download_name:
download_name = (att.original_name or "").strip()
if not download_name:
download_name = file_url.rsplit("/", 1)[-1] or "download"
+165 -1
View File
@@ -24,6 +24,7 @@ from app.models.models import (
BizSampleCode,
SysUser,
)
from app.services.customer_relations import build_customer_relations
router = APIRouter(prefix="/web/admin", tags=["基础信息管理"])
@@ -50,6 +51,10 @@ class CustomerCreateBody(BaseModel):
sex: Optional[str] = Field("2", alias="sex")
id_card_no: Optional[str] = Field(None, alias="idCardNo")
age: Optional[int] = None
birth_ym: Optional[str] = Field(None, alias="birthYm")
ethnic: Optional[str] = None
email: Optional[str] = None
address: Optional[str] = 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")
@@ -63,6 +68,10 @@ class CustomerUpdateBody(BaseModel):
sex: Optional[str] = None
id_card_no: Optional[str] = Field(None, alias="idCardNo")
age: Optional[int] = None
birth_ym: Optional[str] = Field(None, alias="birthYm")
ethnic: Optional[str] = None
email: Optional[str] = None
address: Optional[str] = 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")
@@ -119,6 +128,19 @@ async def list_customers(
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
@router.get("/customers/{customer_id}/relations")
async def get_customer_relations(
customer_id: str,
user: dict = Depends(require_menu_code("biz:customer:list")),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
data = build_customer_relations(db, customer_id, tenant_no)
if not data:
return error_response(404, "客户不存在")
return success_response(data)
@router.post("/customers")
async def create_customer(
body: CustomerCreateBody,
@@ -133,6 +155,10 @@ async def create_customer(
sex=body.sex or "2",
id_card_no=body.id_card_no,
age=body.age,
birth_ym=body.birth_ym,
ethnic=body.ethnic,
email=body.email,
address=body.address,
phone_no=body.phone_no,
customer_tags=body.customer_tags,
tenant_no=tenant_no,
@@ -162,6 +188,10 @@ async def update_customer(
sex=payload.get("sex"),
id_card_no=payload.get("id_card_no"),
age=payload.get("age"),
birth_ym=payload.get("birth_ym"),
ethnic=payload.get("ethnic"),
email=payload.get("email"),
address=payload.get("address"),
phone_no=payload.get("phone_no"),
customer_tags=payload.get("customer_tags"),
order_num=payload.get("order_num"),
@@ -360,8 +390,15 @@ async def list_collection_regions(
items = query.order_by(BizCollectionRegion.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
region_ids = [r.id for r in items]
hospital_map = region_svc._hospital_names_by_region_ids(db, region_ids)
staff_map = region_svc._staff_names_by_region_ids(db, region_ids)
result_list = [
region_svc._region_to_dict(r, hospital_names=hospital_map.get(r.id, [])) for r in items
region_svc._region_to_dict(
r,
hospital_names=hospital_map.get(r.id, []),
staff_names=staff_map.get(r.id, ([], []))[0],
leader_names=staff_map.get(r.id, ([], []))[1],
)
for r in items
]
tp = region_svc.total_pages(total, pageSize)
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
@@ -426,6 +463,17 @@ async def delete_collection_region(
return error_response(400, str(e))
@router.get("/collection-regions/by-staff-user/{user_id}")
async def api_list_regions_by_staff_user(
user_id: str,
user: dict = Depends(require_menu_code("biz:collectionRegion:list")),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items = region_svc.list_regions_by_staff_user(db, user_id=user_id, tenant_no=tenant_no)
return success_response({"list": items})
# ---------------------------------------------------------------------------
# 采集地区 — 下属医院
# ---------------------------------------------------------------------------
@@ -548,6 +596,122 @@ async def delete_hospital(
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 采集地区 — 工作人员
# ---------------------------------------------------------------------------
class RegionStaffCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
user_id: str = Field(..., alias="userId")
is_leader: Optional[bool] = Field(False, alias="isLeader")
order_num: Optional[int] = Field(0, alias="orderNum")
remark: Optional[str] = None
class RegionStaffUpdateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
is_leader: Optional[bool] = Field(None, alias="isLeader")
order_num: Optional[int] = Field(None, alias="orderNum")
remark: Optional[str] = None
@router.get("/collection-regions/{region_id}/staff")
async def list_region_staff(
region_id: str,
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
user: dict = Depends(require_menu_code("biz:collectionRegion:list")),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items, total = region_svc.list_staff(
db, region_id=region_id, page=page, page_size=pageSize, 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}/staff")
async def create_region_staff(
region_id: str,
body: RegionStaffCreateBody,
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_staff(
db,
region_id=region_id,
user_id=body.user_id,
is_leader=bool(body.is_leader),
tenant_no=tenant_no,
order_num=body.order_num,
remark=body.remark,
user_id_actor=user.get("user_id"),
)
# enrich response with user display fields
items, _ = region_svc.list_staff(db, region_id=region_id, page=1, page_size=100, tenant_no=tenant_no)
matched = next((x for x in items if x["id"] == obj.id), region_svc._staff_to_dict(obj))
return success_response(matched, message="创建成功")
except ValueError as e:
return error_response(400, str(e))
@router.put("/collection-regions/{region_id}/staff/{staff_id}")
async def update_region_staff(
region_id: str,
staff_id: str,
body: RegionStaffUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
s = region_svc.get_staff(db, staff_id, tenant_no=tenant_no)
if not s or s.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_staff(
db,
staff_id,
is_leader=payload.get("is_leader"),
order_num=payload.get("order_num"),
remark=payload.get("remark"),
user_id=user.get("user_id"),
tenant_no=tenant_no,
)
items, _ = region_svc.list_staff(db, region_id=region_id, page=1, page_size=100, tenant_no=tenant_no)
matched = next((x for x in items if x["id"] == obj.id), region_svc._staff_to_dict(obj))
return success_response(matched, message="更新成功")
except ValueError as e:
return error_response(400, str(e))
@router.delete("/collection-regions/{region_id}/staff/{staff_id}")
async def delete_region_staff(
region_id: str,
staff_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
s = region_svc.get_staff(db, staff_id, tenant_no=tenant_no)
if not s or s.region_id != region_id:
return error_response(400, "绑定记录不存在")
region_svc.soft_delete_staff(db, staff_id, tenant_no=tenant_no)
return success_response(None, message="删除成功")
except ValueError as e:
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 采集协议号池
# ---------------------------------------------------------------------------
+288 -63
View File
@@ -2,29 +2,60 @@
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from sqlalchemy import and_ as sa_and, func as sa_func
from app.core.database import get_db
from app.core.data_scope import get_user_dept_id
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,
BizProtocolPartyCustomer,
BizProtocolSample,
BizProtocolStorageCert,
SysUser,
)
from app.services.party_customer_sync import sync_party_customers
from app.services.sample_collect_wx_notify import (
PROTOCOL_KIND_CELL,
sample_codes_from_orm_rows,
sample_codes_from_payload,
schedule_sample_collect_notify,
)
router = APIRouter(prefix="/web/admin", tags=["细胞存储"])
_PAYMENT_DETAIL_SENSITIVE_FIELDS = {
"pay_status",
"payStatus",
"finance_confirm_attachments",
"financeConfirmAttachments",
"finance_confirm_attachment_files",
"financeConfirmAttachmentFiles",
"finance_remark",
"financeRemark",
"refund_amount",
"refundAmount",
"refund_remark",
"refundRemark",
"refund_attachments",
"refundAttachments",
"refund_attachment_files",
"refundAttachmentFiles",
"refund_finance_confirm_attachments",
"refundFinanceConfirmAttachments",
"refund_finance_confirm_attachment_files",
"refundFinanceConfirmAttachmentFiles",
"refund_finance_remark",
"refundFinanceRemark",
}
def _get_tenant_no(db: Session, user: dict) -> str:
user_id = user.get("user_id")
@@ -79,49 +110,6 @@ def _parse_datetime_fields(d: dict, camel_keys: list) -> 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
# ---------------------------------------------------------------------------
# 采集协议单
# ---------------------------------------------------------------------------
@@ -184,12 +172,16 @@ class ProtocolCreateBody(BaseModel):
health_survey_form_urls: Optional[str] = Field(None, alias="healthSurveyFormUrls")
register_date: Optional[str] = Field(None, alias="registerDate")
collector_user_id: Optional[str] = Field(None, alias="collectorUserId")
protocol_total_amount: Optional[float] = Field(None, alias="protocolTotalAmount")
source_presign_order_id: Optional[str] = Field(None, alias="sourcePresignOrderId")
payment_model_version: Optional[int] = Field(2, alias="paymentModelVersion")
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")
sample_payments: Optional[list] = Field(None, alias="samplePayments")
attachments: Optional[list] = Field(None, alias="attachments")
consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
health_survey_attachments: Optional[list] = Field(None, alias="healthSurveyAttachments")
@@ -215,18 +207,40 @@ class ProtocolUpdateBody(BaseModel):
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")
protocol_total_amount: Optional[float] = Field(None, alias="protocolTotalAmount")
source_presign_order_id: Optional[str] = Field(None, alias="sourcePresignOrderId")
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")
sample_payments: Optional[list] = Field(None, alias="samplePayments")
attachments: Optional[list] = Field(None, alias="attachments")
consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
health_survey_attachments: Optional[list] = Field(None, alias="healthSurveyAttachments")
storage_cert: Optional[dict] = Field(None, alias="storageCert")
def assert_protocol_sensitive_fields(
db: Session,
user: dict,
body: BaseModel,
*,
creating: bool,
) -> None:
"""防止通过普通协议保存接口注入财务确认或退费状态字段(不做菜单权限校验)。"""
for detail in getattr(body, "payment_details", None) or []:
normalized_detail = (
cp_svc._normalize_fields(detail) if isinstance(detail, dict) else {}
)
if _PAYMENT_DETAIL_SENSITIVE_FIELDS.intersection(normalized_detail.keys()):
raise HTTPException(
status_code=400,
detail="普通协议保存不允许提交财务确认或退费状态字段",
)
def _resolve_protocol_attachments(
protocol_attachments: Optional[str],
attachments: Optional[list],
@@ -268,28 +282,78 @@ def _persist_protocol_sub_resources(
samples: Optional[list],
payment: Optional[dict],
payment_details: Optional[list],
sample_payments: Optional[list] = None,
storage_cert: Optional[dict],
payment_model_version: int = 2,
commit: bool = True,
) -> None:
"""持久化协议子资源。
version=1:缴费(含明细校验)须在样本之前,避免校验失败后已占用样本编码。
version=2:样本先落库占码,再按 sample_code 挂样本缴费方案。
默认整段子资源共用一次事务:内部一律 commit=False,成功后再统一 commit,
避免样本已提交、缴费校验失败导致「提示失败但部分已保存」。
"""
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)
party_customer = sync_party_customers(
db,
party_customer,
tenant_no=tenant_no,
user_id=user_id,
commit=False,
)
cp_svc.upsert_party_customer(
db, protocol_id, tenant_no, user_id, commit=False, **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)
pay = None
if payment:
pay = cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment)
if payment_details is not None:
if not pay and payment:
pay = cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment)
if pay:
aligned_details = cp_svc.align_fq_payment_details_payload(pay, payment_details)
cp_svc.save_payment_details(db, pay.id, tenant_no, user_id, aligned_details)
cp_svc.upsert_collection_info(
db, protocol_id, tenant_no, user_id, commit=False, **collection_info
)
version = int(payment_model_version or 2)
if version >= 2:
if samples is not None:
cp_svc.save_samples(
db, protocol_id, tenant_no, user_id, samples, commit=False
)
if sample_payments is not None:
from app.crud import protocol_sample_payment as sp_pay_svc
sp_pay_svc.save_sample_payments_for_protocol(
db,
collection_protocol_id=protocol_id,
tenant_no=tenant_no,
user_id=user_id,
sample_payments=sample_payments,
commit=False,
)
else:
# 旧模型:缴费先于样本
if payment or payment_details is not None:
cp_svc.save_payment_with_details(
db,
protocol_id,
tenant_no,
user_id,
payment=payment,
payment_details=payment_details,
commit=False,
)
if samples is not None:
cp_svc.save_samples(
db, protocol_id, tenant_no, user_id, samples, commit=False
)
if storage_cert:
cp_svc.upsert_storage_cert(db, protocol_id, tenant_no, user_id, **storage_cert)
cp_svc.upsert_storage_cert(
db, protocol_id, tenant_no, user_id, commit=False, **storage_cert
)
if commit:
db.commit()
@router.post("/collection-protocols")
@@ -298,8 +362,10 @@ async def create_collection_protocol(
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
assert_protocol_sensitive_fields(db, user, body, creating=True)
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
protocol_id: Optional[str] = None
try:
protocol_attachments = _resolve_protocol_attachments(
body.protocol_attachments,
@@ -318,6 +384,7 @@ async def create_collection_protocol(
collector = user_id
obj = cp_svc.create_collection_protocol(
db,
create_dept_id=get_user_dept_id(db, user_id) or "",
protocol_no=body.protocol_no or "",
protocol_no_id=body.protocol_no_id,
protocol_status=body.protocol_status,
@@ -326,9 +393,15 @@ async def create_collection_protocol(
health_survey_form_urls=health_survey_form_urls,
register_date=body.register_date,
collector_user_id=collector,
protocol_total_amount=body.protocol_total_amount,
source_presign_order_id=body.source_presign_order_id,
payment_model_version=body.payment_model_version
if body.payment_model_version is not None
else 2,
remark=body.remark,
tenant_no=tenant_no,
user_id=user_id,
commit=False,
)
protocol_id = obj.id
_persist_protocol_sub_resources(
@@ -341,10 +414,28 @@ async def create_collection_protocol(
samples=body.samples,
payment=body.payment,
payment_details=body.payment_details,
sample_payments=body.sample_payments,
storage_cert=body.storage_cert,
payment_model_version=int(
getattr(obj, "payment_model_version", None) or 2
),
commit=True,
)
db.refresh(obj)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=protocol_id,
protocol_kind=PROTOCOL_KIND_CELL,
before_codes=[],
submitted_codes=(
sample_codes_from_payload(body.samples)
if body.samples is not None
else None
),
)
return success_response(cp_svc._protocol_to_dict(obj), message="创建成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@@ -355,6 +446,7 @@ async def update_collection_protocol(
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
assert_protocol_sensitive_fields(db, user, body, creating=False)
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
@@ -363,7 +455,7 @@ async def update_collection_protocol(
payload = body.model_dump(exclude_unset=True, by_alias=False)
sub_keys = {
"party_customer", "collection_info", "samples",
"payment", "payment_details", "attachments",
"payment", "payment_details", "sample_payments", "attachments",
"consent_attachments", "health_survey_attachments", "storage_cert",
}
main_payload = {k: v for k, v in payload.items() if k not in sub_keys}
@@ -388,12 +480,20 @@ async def update_collection_protocol(
)
if resolved is not None:
main_payload["health_survey_form_urls"] = resolved
before_codes = sample_codes_from_orm_rows(
cp_svc.list_samples(db, protocol_id, tenant_no)
)
obj = cp_svc.update_collection_protocol(
db,
protocol_id,
tenant_no=tenant_no,
user_id=user_id,
**{k: v for k, v in main_payload.items() if v is not None},
commit=False,
**{
k: v
for k, v in main_payload.items()
if v is not None or k == "protocol_total_amount"
},
)
_persist_protocol_sub_resources(
db,
@@ -405,10 +505,28 @@ async def update_collection_protocol(
samples=body.samples,
payment=body.payment,
payment_details=body.payment_details,
sample_payments=body.sample_payments,
storage_cert=body.storage_cert,
payment_model_version=int(
getattr(obj, "payment_model_version", None) or 1
),
commit=True,
)
db.refresh(obj)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=protocol_id,
protocol_kind=PROTOCOL_KIND_CELL,
before_codes=before_codes,
submitted_codes=(
sample_codes_from_payload(body.samples)
if body.samples is not None
else None
),
)
return success_response(cp_svc._protocol_to_dict(obj), message="更新成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@@ -479,6 +597,8 @@ async def submit_collection_protocol_audit(
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(403, "无权访问该协议或协议不存在")
try:
cp_svc.create_audit(
db,
@@ -495,6 +615,23 @@ async def submit_collection_protocol_audit(
return error_response(400, str(e))
@router.get("/collection-protocols/{protocol_id}/status-changes")
async def list_collection_protocol_status_changes(
protocol_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(403, "无权访问该协议或协议不存在")
items = cp_svc.list_protocol_status_changes(
db,
collection_protocol_id=protocol_id,
tenant_no=tenant_no,
)
return success_response({"list": items})
# ---------------------------------------------------------------------------
# 收款确认
# ---------------------------------------------------------------------------
@@ -1069,6 +1206,94 @@ async def update_fullmoon_todo_insurance(
return error_response(400, str(e))
class StorageCertHandleBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
handle_time: Optional[str] = Field(None, alias="handleTime")
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
cert_image_urls: Optional[str] = Field(None, alias="certImageUrls")
remark: Optional[str] = None
class FullmoonHandleBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
handle_time: Optional[str] = Field(None, alias="handleTime")
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
handle_remark: Optional[str] = Field(None, alias="handleRemark")
def _attach_handler_name(db: Session, payload: Dict[str, Any]) -> Dict[str, Any]:
uid = (payload.get("handlerUserId") or "").strip()
name = ""
if uid:
u = db.query(SysUser).filter(SysUser.id == uid).first()
name = (u.nick_name or "") if u else ""
payload["handlerUserName"] = name
return payload
@router.put("/storage-certs/{protocol_id}")
async def update_storage_cert_handle(
protocol_id: str,
body: StorageCertHandleBody,
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)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(404, "协议单不存在")
try:
ht = parse_datetime(body.handle_time) if body.handle_time else None
sc = cp_svc.upsert_storage_cert_handle(
db,
protocol_id,
tenant_no,
user_id,
handle_time=ht,
handler_user_id=body.handler_user_id,
cert_image_urls=body.cert_image_urls,
remark=body.remark,
)
return success_response(
_attach_handler_name(db, cp_svc._storage_cert_to_dict(sc)), message="更新成功"
)
except ValueError as e:
return error_response(400, str(e))
@router.put("/fullmoon-todos/{protocol_id}/handle")
async def update_fullmoon_todo_handle(
protocol_id: str,
body: FullmoonHandleBody,
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)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(404, "协议单不存在")
try:
ht = parse_datetime(body.handle_time) if body.handle_time else None
ft = cp_svc.upsert_fullmoon_todo_handle(
db,
protocol_id,
tenant_no,
user_id,
handle_time=ht,
handler_user_id=body.handler_user_id,
insurance_attachments=body.insurance_attachments,
handle_remark=body.handle_remark,
)
return success_response(
_attach_handler_name(db, cp_svc._fullmoon_todo_to_dict(ft)), message="更新成功"
)
except ValueError as e:
return error_response(400, str(e))
class InvoiceRequestCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
+32
View File
@@ -0,0 +1,32 @@
"""客户服务字典 API"""
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_any_menu_code
from app.core.response import success_response
from app.crud import customer_service_dict as 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
@router.get("/customer-service-dict")
async def get_customer_service_dict(
dictType: str = Query(...),
user: dict = Depends(require_any_menu_code("cell-storage:sample-info:list")),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
values = svc.list_dict_values(db, tenant_no, dictType)
return success_response({"list": values})
+1 -1
View File
@@ -319,7 +319,7 @@ async def list_env_detect_records(
query = apply_data_scope(query, BizEnvDetectRecord, user, db, tenant_no)
total = query.count()
items = (
query.order_by(BizEnvDetectRecord.create_time.desc())
query.order_by(BizEnvDetectRecord.detect_time.desc())
.offset((page - 1) * pageSize)
.limit(pageSize)
.all()
+6 -16
View File
@@ -139,22 +139,12 @@ async def submit_leave(
@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})
async def leave_workflow_callback_blocked():
"""HTTP 回调已禁用:请假审批结果仅允许工作流本地回调(register_local_callback)。"""
return error_response(
403,
"请假审批回调仅支持进程内本地回调,请勿通过 HTTP 调用本接口",
)
@router.get("/{leave_id}")
File diff suppressed because it is too large. Load diff
+1
View File
@@ -171,6 +171,7 @@ class StockCheckCreateBody(BaseModel):
class StockCheckUnusedLineItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
usage_id: Optional[str] = Field(None, alias="usageId")
batch_id: Optional[str] = Field(None, alias="batchId")
material_code: Optional[str] = Field(None, alias="materialCode")
+220 -110
View File
@@ -7,6 +7,7 @@ from app.core.response import success_response
router = APIRouter(prefix="/web/menu", tags=["菜单管理"])
# 与 web-admin/src/router/modules/sqxp.ts 一致:5 个一级菜单分组
# 市场二级顺序:存储服务、科研捐赠、市场基础信息、中试服务、基础实验
STATIC_MENU_TREE = [
# ────────────────────────────────────────────
# 1. 市场 (menu:market)
@@ -25,14 +26,14 @@ STATIC_MENU_TREE = [
"roles": ["admin"],
},
"children": [
# 细胞存储
# 存储服务(含细胞存储 / 科研捐赠统一业务入口)
{
"id": "CEL00000000000000000000000000001",
"name": "CellStorage",
"path": "cell-storage",
"component": "",
"meta": {
"title": "细胞存储",
"title": "存储服务",
"icon": "ri:test-tube-line",
"keepAlive": False,
"roles": ["admin"],
@@ -52,29 +53,82 @@ STATIC_MENU_TREE = [
"component": "/cell-storage/protocol-audit/index",
"meta": {"title": "采集协议单", "keepAlive": True, "menuCode": "biz:cellStorageProtocolAudit:list"},
},
{
"id": "50000000000000000000000000000020",
"name": "CellStoragePresignOrder",
"path": "presign-order",
"component": "/cell-storage/presign-order/index",
"meta": {"title": "预签约订单", "keepAlive": True, "menuCode": "cell-storage:presign-order:list"},
},
{
"id": "904b5173519411f1944300163e3bdd96",
"name": "CellStoragePaymentInfo",
"path": "payment-info",
"component": "/cell-storage/payment-info/index",
"meta": {
"title": "缴费信息",
"keepAlive": True,
"menuCode": "cellStorage:paymentInfo:list",
},
},
{
"id": "50000000000000000000000000000022",
"name": "CellStoragePaymentChase",
"path": "payment-chase",
"component": "/cell-storage/payment-chase/index",
"meta": {
"title": "催缴信息",
"keepAlive": True,
"menuCode": "cellStorage:paymentChase:list",
},
},
{
"id": "50000000000000000000000000000013",
"name": "CellStorageSampleInfo",
"path": "sample-info",
"component": "/cell-storage/sample-info/index",
"meta": {"title": "样本信息管理", "keepAlive": True, "menuCode": "cell-storage:sample-info:list"},
"meta": {"title": "客户服务管理", "keepAlive": True, "menuCode": "cell-storage:sample-info:list"},
},
{
"id": "50000000000000000000000000000021",
"name": "CellStorageSampleFreight",
"path": "sample-freight",
"component": "/cell-storage/sample-freight/index",
"meta": {
"title": "运费信息",
"keepAlive": True,
"menuCode": "cell-storage:sample-freight:list",
},
},
{
"id": "50000000000000000000000000000023",
"name": "CellStorageSfExpressSettlement",
"path": "sf-express-settlement",
"component": "/cell-storage/sf-express-settlement/index",
"meta": {
"title": "顺丰月结",
"keepAlive": True,
"menuCode": "cell-storage:sf-express-settlement:list",
},
},
{
"id": "50000000000000000000000000000004",
"name": "CellStoragePaymentConfirm",
"path": "payment-confirm",
"component": "/cell-storage/payment-confirm/index",
"meta": {"title": "缴退费财务确认及开票", "keepAlive": True, "menuCode": "biz:cellStoragePaymentConfirm:list"},
},
{
"id": "70000000000000000000000000000011",
"name": "CellStorageStorageReleaseReview",
"path": "storage-release-review",
"component": "/quality-inspection/storage-release-review/index",
"meta": {"title": "入库放行审核", "keepAlive": True, "menuCode": "biz:storageReleaseReview:list"},
"meta": {"title": "缴费财务确认", "keepAlive": True, "menuCode": "biz:cellStoragePaymentConfirm:list"},
},
# 市场侧入库放行审核(总览)暂下线:改由生产/质检入口审核
# {
# "id": "70000000000000000000000000000011",
# "name": "CellStorageStorageReleaseReview",
# "path": "storage-release-review",
# "component": "/quality-inspection/storage-release-review/index",
# "meta": {"title": "入库放行审核", "keepAlive": True, "menuCode": "biz:storageReleaseReview:list"},
# },
],
},
# 科研捐赠
{
"id": "RSC00000000000000000000000000001",
@@ -85,6 +139,7 @@ STATIC_MENU_TREE = [
"title": "科研捐赠",
"icon": "ri:hand-heart-line",
"keepAlive": False,
"isHide": True,
"roles": ["admin"],
},
"children": [
@@ -100,17 +155,122 @@ STATIC_MENU_TREE = [
"name": "ResearchDonationAudit",
"path": "audit",
"component": "/research-donation/audit/index",
"meta": {"title": "捐赠协议单", "keepAlive": True, "menuCode": "biz:researchDonationAudit:list"},
"meta": {"title": "捐赠协议单", "keepAlive": True, "isHide": True, "menuCode": "biz:researchDonationAudit:list"},
},
{
"id": "60000000000000000000000000000004",
"name": "ResearchDonationSampleInfo",
"path": "sample-info",
"component": "/research-donation/sample-info/index",
"meta": {"title": "样本信息管理", "keepAlive": True, "menuCode": "research-donation:sample-info:list"},
"meta": {
"title": "样本信息管理",
"keepAlive": True,
"isHide": True,
"menuCode": "research-donation:sample-info:list",
},
},
],
},
# 市场基础信息(前段:存储/捐赠相关;后段:中试/基础实验相关)
{
"id": "MKT00000000000000000000000000001",
"name": "MarketInfo",
"path": "market-info",
"component": "",
"meta": {
"title": "市场基础信息",
"icon": "ri:price-tag-3-line",
"keepAlive": False,
"roles": ["admin"],
},
"children": [
{
"id": "122",
"name": "BizCellSampleType",
"path": "cell-sample-type",
"component": "/basic-info/cell-sample-type/index",
"meta": {"title": "细胞样本基础信息维护", "keepAlive": True, "menuCode": "biz:cellSampleType:list"},
},
{
"id": "123",
"name": "BizCollectionRegion",
"path": "collection-region",
"component": "/basic-info/collection-region/index",
"meta": {"title": "采集地区及医院", "keepAlive": True, "menuCode": "biz:collectionRegion:list"},
},
{
"id": "40000000000000000000000000000007",
"name": "BasicInfoInsuranceCompany",
"path": "insurance-company",
"component": "/basic-info/insurance-company/index",
"meta": {"title": "保险公司及险种价格", "keepAlive": True, "isHide": True, "menuCode": "basic-info:insurance-company:list"},
},
{
"id": "50000000000000000000000000000009",
"name": "MarketMaterial",
"path": "material",
"component": "/cell-storage/material/index",
"meta": {"title": "市场物料库", "keepAlive": True, "menuCode": "biz:material:list"},
},
{
"id": "95000000000000000000000000000003",
"name": "MarketMyMaterialUsage",
"path": "my-material-usage",
"component": "/cell-storage/my-material-usage/index",
"meta": {
"title": "我的物料领用记录",
"keepAlive": True,
"menuCode": "market:material-usage:list",
},
},
{
"id": "121",
"name": "BizCustomer",
"path": "customer",
"component": "/basic-info/customer/index",
"meta": {"title": "客户基础信息", "keepAlive": True, "menuCode": "biz:customer:list"},
},
{
"id": "80000000000000000000000000000002",
"name": "CellProductType",
"path": "cell-product-type",
"component": "/pilot-service/cell-product-type/index",
"meta": {"title": "细胞产品基础信息维护", "keepAlive": True, "menuCode": "pilot-service:cell-product-type:list"},
},
{
"id": "80000000000000000000000000000007",
"name": "CheckupInstitution",
"path": "checkup-institution",
"component": "/pilot-service/checkup-institution/index",
"meta": {"title": "体检机构及项目价格", "keepAlive": True, "isHide": True, "menuCode": "pilot-service:checkup-institution:list"},
},
{
"id": "80000000000000000000000000000006",
"name": "Channel",
"path": "channel",
"component": "/pilot-service/channel/index",
"meta": {"title": "渠道信息管理", "keepAlive": True, "menuCode": "pilot-service:channel:list"},
},
{
"id": "80000000000000000000000000000009",
"name": "ChannelServiceOrderFinance",
"path": "channel-service-order-finance",
"component": "/pilot-service/channel-service-order-finance/index",
"meta": {"title": "渠道服务订单(财务确认)", "keepAlive": True, "menuCode": "pilot-service:channel-service-order-finance:list"},
}
],
},
# 中试服务
{
"id": "PIL00000000000000000000000000001",
@@ -140,6 +300,7 @@ STATIC_MENU_TREE = [
},
],
},
# 基础实验
{
"id": "BEX00000000000000000000000000001",
@@ -187,96 +348,7 @@ STATIC_MENU_TREE = [
},
},
],
},
# 市场基础信息
{
"id": "MKT00000000000000000000000000001",
"name": "MarketInfo",
"path": "market-info",
"component": "",
"meta": {
"title": "市场基础信息",
"icon": "ri:price-tag-3-line",
"keepAlive": False,
"roles": ["admin"],
},
"children": [
{
"id": "122",
"name": "BizCellSampleType",
"path": "cell-sample-type",
"component": "/basic-info/cell-sample-type/index",
"meta": {"title": "细胞样本基础信息维护", "keepAlive": True, "menuCode": "biz:cellSampleType:list"},
},
{
"id": "123",
"name": "BizCollectionRegion",
"path": "collection-region",
"component": "/basic-info/collection-region/index",
"meta": {"title": "细胞采集基础信息维护", "keepAlive": True, "menuCode": "biz:collectionRegion:list"},
},
{
"id": "80000000000000000000000000000002",
"name": "CellProductType",
"path": "cell-product-type",
"component": "/pilot-service/cell-product-type/index",
"meta": {"title": "细胞产品基础信息维护", "keepAlive": True, "menuCode": "pilot-service:cell-product-type:list"},
},
{
"id": "40000000000000000000000000000007",
"name": "BasicInfoInsuranceCompany",
"path": "insurance-company",
"component": "/basic-info/insurance-company/index",
"meta": {"title": "保险公司及险种价格", "keepAlive": True, "menuCode": "basic-info:insurance-company:list"},
},
{
"id": "80000000000000000000000000000007",
"name": "CheckupInstitution",
"path": "checkup-institution",
"component": "/pilot-service/checkup-institution/index",
"meta": {"title": "体检机构及项目价格", "keepAlive": True, "menuCode": "pilot-service:checkup-institution:list"},
},
{
"id": "50000000000000000000000000000009",
"name": "MarketMaterial",
"path": "material",
"component": "/cell-storage/material/index",
"meta": {"title": "市场物料库", "keepAlive": True, "menuCode": "biz:material:list"},
},
{
"id": "95000000000000000000000000000003",
"name": "MarketMyMaterialUsage",
"path": "my-material-usage",
"component": "/cell-storage/my-material-usage/index",
"meta": {
"title": "我的物料领用记录",
"keepAlive": True,
"menuCode": "market:material-usage:list",
},
},
{
"id": "121",
"name": "BizCustomer",
"path": "customer",
"component": "/basic-info/customer/index",
"meta": {"title": "客户管理", "keepAlive": True, "menuCode": "biz:customer:list"},
},
{
"id": "80000000000000000000000000000006",
"name": "Channel",
"path": "channel",
"component": "/pilot-service/channel/index",
"meta": {"title": "渠道信息管理", "keepAlive": True, "menuCode": "pilot-service:channel:list"},
},
{
"id": "80000000000000000000000000000009",
"name": "ChannelServiceOrderFinance",
"path": "channel-service-order-finance",
"component": "/pilot-service/channel-service-order-finance/index",
"meta": {"title": "渠道服务订单(财务确认)", "keepAlive": True, "menuCode": "pilot-service:channel-service-order-finance:list"},
},
],
},
}
],
},
@@ -426,6 +498,13 @@ STATIC_MENU_TREE = [
"component": "/production/production-work-order/index",
"meta": {"title": "生产工单", "keepAlive": True, "menuCode": "production:work-order:list"},
},
{
"id": "95500000000000000000000000000001",
"name": "ProductionStorageReleaseReview",
"path": "storage-release-review",
"component": "/quality-inspection/storage-release-review/index",
"meta": {"title": "入库放行审核", "keepAlive": True, "menuCode": "production:storageReleaseReview:list"},
},
{
"id": "94000000000000000000000000000001",
"name": "ProductionMyProductionTask",
@@ -466,6 +545,17 @@ STATIC_MENU_TREE = [
"menuCode": "production:cleanroom-garment:list",
},
},
{
"id": "95300000000000000000000000000001",
"name": "ProductionWasteOutput",
"path": "waste-output",
"component": "/production/waste-output/index",
"meta": {
"title": "废弃物管理",
"keepAlive": True,
"menuCode": "production:waste-output:list",
},
},
],
},
# 生产基础信息
@@ -589,6 +679,13 @@ STATIC_MENU_TREE = [
"component": "/quality-inspection/qc-work-order/index",
"meta": {"title": "质检工单", "keepAlive": True, "menuCode": "biz:qcWorkOrder:list"},
},
{
"id": "70000000000000000000000000000014",
"name": "QualityInspectionStorageReleaseReview",
"path": "storage-release-review",
"component": "/quality-inspection/storage-release-review/index",
"meta": {"title": "入库放行审核", "keepAlive": True, "menuCode": "quality:storageReleaseReview:list"},
},
{
"id": "71000000000000000000000000000002",
"name": "QualityInspectionEnvDetectRecord",
@@ -870,13 +967,6 @@ STATIC_MENU_TREE = [
"component": "/oa-approval/apps/index",
"meta": {"title": "审批管理", "keepAlive": True, "menuCode": "oa:apps:list"},
},
{
"id": "OA000000000000000000000000000102",
"name": "OaApprovalRoles",
"path": "roles",
"component": "/oa-approval/roles/index",
"meta": {"title": "工作流角色", "keepAlive": True, "menuCode": "oa:roles:list"},
},
{
"id": "OA000000000000000000000000000103",
"name": "OaApprovalStart",
@@ -884,6 +974,19 @@ STATIC_MENU_TREE = [
"component": "/oa-approval/start/index",
"meta": {"title": "发起审批", "keepAlive": True, "menuCode": "oa:start"},
},
{
"id": "OA000000000000000000000000000203",
"name": "OaApprovalHistory",
"path": "history/:appId",
"component": "/oa-approval/history/index",
"meta": {
"title": "历史记录",
"keepAlive": True,
"isHide": True,
"worktabMulti": True,
"menuCode": "oa:start",
},
},
{
"id": "OA000000000000000000000000000104",
"name": "OaApprovalPending",
@@ -912,6 +1015,13 @@ STATIC_MENU_TREE = [
"component": "/oa-approval/cc/index",
"meta": {"title": "抄送我的", "keepAlive": True, "menuCode": "oa:cc"},
},
{
"id": "OA000000000000000000000000000110",
"name": "OaApprovalMention",
"path": "mentions",
"component": "/oa-approval/mentions/index",
"meta": {"title": "提到我的", "keepAlive": True, "menuCode": "oa:mention"},
},
{
"id": "OA000000000000000000000000000201",
"name": "OaApprovalDesigner",
+232
View File
@@ -0,0 +1,232 @@
"""小程序鉴权:微信登录 / 绑定 openid。"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.core.auth import get_current_user_by_token
from app.core.database import get_db
from app.core.response import error_response, success_response
from app.crud.user import user_crud
from app.services import wx_qr_session
from app.services.issue_user_tokens import issue_tokens_for_user
from app.services.wechat_login_service import (
resolve_wechat_login_by_openid_only,
resolve_wechat_login_user,
)
from app.services.wechat_mini_auth import (
WechatMiniAuthError,
code2session,
get_phone_number,
)
from app.services.wechat_mp_auth import WechatMpAuthError, oauth_code_to_openid
router = APIRouter(prefix="/mini/auth", tags=["小程序鉴权"])
async def _resolve_user_id(request: Request, db: Session) -> str:
authorization = request.headers.get("Authorization", "")
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization.replace("Bearer ", "")
current = await get_current_user_by_token(token=token, db=db)
user_id = current.get("user_id") or current.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="未授权")
return user_id
@router.post("/wechat-login", response_model=dict)
async def wechat_login(body: dict, db: Session = Depends(get_db)):
"""微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。"""
login_code = (body.get("loginCode") or "").strip()
phone_code = (body.get("phoneCode") or "").strip() or None
try:
sess = code2session(login_code)
except WechatMiniAuthError as exc:
return error_response(400, str(exc))
if not phone_code:
user, err_msg, err_code = resolve_wechat_login_by_openid_only(
db, openid=sess["openid"]
)
if err_msg:
return error_response(err_code or 400, err_msg)
else:
try:
phone = get_phone_number(phone_code)
except WechatMiniAuthError as exc:
return error_response(400, str(exc))
user, err_msg, err_code = resolve_wechat_login_user(
db, openid=sess["openid"], phone=phone
)
if err_msg:
return error_response(err_code or 400, err_msg)
unionid = (sess.get("unionid") or "").strip()
if unionid and not (getattr(user, "wx_unionid", None) or "").strip():
user.wx_unionid = unionid
user.update_time = datetime.now()
db.add(user)
db.commit()
db.refresh(user)
try:
data = await issue_tokens_for_user(db, user)
except ValueError as exc:
return error_response(403, str(exc))
return success_response(data)
@router.post("/wechat-bind", response_model=dict)
async def wechat_bind(
request: Request,
body: dict,
db: Session = Depends(get_db),
):
"""密码登录后绑定当前微信 openid(需 Bearer)。"""
try:
user_id = await _resolve_user_id(request, db)
except HTTPException:
return error_response(401, "未授权")
login_code = (body.get("loginCode") or "").strip()
try:
sess = code2session(login_code)
except WechatMiniAuthError as exc:
return error_response(400, str(exc))
openid = sess["openid"]
user = user_crud.get(db, user_id)
if not user or user.del_flag != 0:
return error_response(401, "用户不存在")
other = user_crud.get_by_wx_openid(db, openid)
if other and other.id != user.id:
return error_response(409, "该微信已绑定其他账号")
existing_oid = (getattr(user, "wx_openid", None) or "").strip()
if existing_oid == openid:
return success_response({"bound": True, "message": "已绑定"})
if existing_oid and existing_oid != openid:
return error_response(409, "该账号已绑定其他微信,请联系管理员")
user.wx_openid = openid
user.update_time = datetime.now()
db.add(user)
db.commit()
return success_response({"bound": True, "message": "绑定成功"})
@router.get("/mp-bind-status", response_model=dict)
async def mp_bind_status(request: Request, db: Session = Depends(get_db)):
"""当前用户是否已绑定微信服务号 openid。"""
try:
user_id = await _resolve_user_id(request, db)
except HTTPException:
return error_response(401, "未授权")
user = user_crud.get(db, user_id)
bound = bool(user and (getattr(user, "wx_mp_openid", None) or "").strip())
return success_response({"bound": bound})
@router.post("/mp-bind", response_model=dict)
async def mp_bind(request: Request, body: dict, db: Session = Depends(get_db)):
"""服务号网页授权 code 绑定当前用户(需 Bearer)。"""
try:
user_id = await _resolve_user_id(request, db)
except HTTPException:
return error_response(401, "未授权")
code = (body.get("code") or "").strip()
try:
info = oauth_code_to_openid(code)
except WechatMpAuthError as exc:
return error_response(400, str(exc))
mp_openid = info["openid"]
other = user_crud.get_by_wx_mp_openid(db, mp_openid)
if other and other.id != user_id:
return error_response(409, "该微信服务号已绑定其他账号")
user = user_crud.get(db, user_id)
if not user or user.del_flag != 0:
return error_response(401, "用户不存在")
user.wx_mp_openid = mp_openid
if info.get("unionid"):
user.wx_unionid = info["unionid"]
user.wx_mp_bind_time = datetime.now()
user.update_time = datetime.now()
db.add(user)
db.commit()
return success_response({"bound": True})
@router.post("/web-qr-scanned", response_model=dict)
async def web_qr_scanned(body: dict):
raw = (body.get("sessionId") or body.get("scene") or "").strip()
sid = wx_qr_session.resolve_session_id(raw) or raw
data = wx_qr_session.get_session(sid)
if not data or data.get("status") in ("expired", "consumed"):
return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
wx_qr_session.mark_scanned(sid)
return success_response({"ok": True})
@router.post("/web-qr-confirm", response_model=dict)
async def web_qr_confirm(body: dict, db: Session = Depends(get_db)):
raw = (body.get("sessionId") or body.get("scene") or "").strip()
sid = wx_qr_session.resolve_session_id(raw) or raw
data = wx_qr_session.get_session(sid)
if not data or data.get("status") in ("expired", "consumed", "failed"):
return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
if data.get("status") == "success":
return success_response({"ok": True, "message": "电脑端即将登录"})
wx_qr_session.mark_scanned(sid)
login_code = (body.get("loginCode") or "").strip()
phone_code = (body.get("phoneCode") or "").strip()
if not login_code or not phone_code:
wx_qr_session.mark_failed(sid, "需要授权手机号才能完成扫码登录")
return error_response(400, "需要授权手机号才能完成扫码登录")
try:
sess = code2session(login_code)
phone = get_phone_number(phone_code)
except WechatMiniAuthError as exc:
wx_qr_session.mark_failed(sid, str(exc))
return error_response(400, str(exc))
user, err_msg, err_code = resolve_wechat_login_user(
db, openid=sess["openid"], phone=phone
)
if err_msg:
wx_qr_session.mark_failed(sid, err_msg)
return error_response(err_code or 400, err_msg)
unionid = (sess.get("unionid") or "").strip()
if unionid and not (getattr(user, "wx_unionid", None) or "").strip():
user.wx_unionid = unionid
user.update_time = datetime.now()
db.add(user)
db.commit()
db.refresh(user)
try:
tokens = await issue_tokens_for_user(db, user)
except ValueError as exc:
wx_qr_session.mark_failed(sid, str(exc))
return error_response(403, str(exc))
if not wx_qr_session.mark_success(sid, tokens):
return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
return success_response({"ok": True, "message": "电脑端即将登录,可关闭本页"})
+216
View File
@@ -0,0 +1,216 @@
"""小程序菜单:当前用户在「移动端菜单」树下有权限的入口。"""
from __future__ import annotations
from typing import Dict, List, Set
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.core.auth import get_current_user_by_token
from app.core.database import get_db
from app.core.response import error_response, success_response
from app.models.models import SysMenu, SysRoleMenu
from app.services.web_auth_roles import get_effective_role_ids, resolve_web_role_for_user
router = APIRouter(prefix="/mini", tags=["小程序菜单"])
async def _current_user(request: Request, db: Session) -> dict:
authorization = request.headers.get("Authorization", "")
if not authorization.startswith("Bearer "):
raise ValueError("未授权")
token = authorization.replace("Bearer ", "", 1).strip()
return await get_current_user_by_token(token=token, db=db)
def _find_mobile_root(db: Session, tenant_no: str) -> SysMenu | None:
rows = (
db.query(SysMenu)
.filter(
SysMenu.del_flag == 0,
SysMenu.parent_id == "0",
SysMenu.tenant_no == tenant_no,
)
.all()
)
for m in rows:
name = (m.menu_name or "").strip()
if "移动端" in name:
return m
return None
def _collect_descendants(
db: Session, root_id: str, tenant_no: str
) -> List[SysMenu]:
all_menus = (
db.query(SysMenu)
.filter(
SysMenu.del_flag == 0,
SysMenu.tenant_no == tenant_no,
SysMenu.status == "1",
SysMenu.visible == "1",
)
.order_by(SysMenu.order_num.asc(), SysMenu.create_time.asc())
.all()
)
by_parent: Dict[str, List[SysMenu]] = {}
for m in all_menus:
pid = (m.parent_id or "0").strip() or "0"
by_parent.setdefault(pid, []).append(m)
out: List[SysMenu] = []
def walk(pid: str) -> None:
for child in by_parent.get(pid, []):
out.append(child)
walk(child.id)
walk(root_id)
return out
def _user_menu_ids(db: Session, user_id: str, tenant_no: str) -> Set[str]:
role_ids = get_effective_role_ids(db, user_id, tenant_no=tenant_no)
if not role_ids:
return set()
rows = (
db.query(SysRoleMenu.menu_id)
.filter(
SysRoleMenu.del_flag == 0,
SysRoleMenu.tenant_no == tenant_no,
SysRoleMenu.role_id.in_(role_ids),
)
.all()
)
return {str(r[0]).strip() for r in rows if r and r[0]}
def _mobile_menu_icon(path: str) -> str:
"""按路由匹配移动端宫格图标(与 mobile-h5 van-icon 字形名对齐)。"""
p = (path or "").lower()
# 兼容有无前导斜杠、query 分隔写法
if "market/webview" in p and "entry=" in p:
# payment-chase 须先于 payment 匹配
if "payment-chase" in p:
return "market-payment-chase"
if "presign" in p:
return "market-presign"
if "protocol" in p:
return "market-protocol"
if "payment" in p:
return "market-payment"
if "freight" in p:
return "market-freight"
return ""
if "oa/start" in p:
return "approve"
if "oa/drafts" in p:
return "oa-drafts"
if "oa/pending" in p:
return "oa-pending"
if "oa/done" in p:
return "oa-done"
if "oa/mine" in p:
return "oa-mine"
if "oa/cc" in p:
return "oa-cc"
if "oa/mentions" in p:
return "oa-mentions"
if "oa/wechat-notify" in p:
return "oa-wechat-notify"
if "form-sink" in p:
return "orders-o"
return "apps-o"
def _mobile_menu_icon_colors(path: str) -> dict:
"""宫格图标底色/图形色(可按入口自定义,不强制品牌绿)。"""
p = (path or "").lower()
# OA / 市场菜单 PNG 自带颜色,宫格不套色底
if any(
x in p
for x in (
"oa/start",
"oa/drafts",
"oa/pending",
"oa/done",
"oa/mine",
"oa/cc",
"oa/mentions",
"oa/wechat-notify",
)
) or ("market/webview" in p and "entry=" in p):
return {"iconBg": "transparent", "iconFg": ""}
if "form-sink" in p:
return {"iconBg": "#eef2f1", "iconFg": "#3d524a"}
return {"iconBg": "#eef2f1", "iconFg": "#3d524a"}
def list_mobile_menu_items(
db: Session,
*,
user_id: str,
tenant_no: str,
is_super_admin: bool = False,
) -> List[dict]:
"""列出当前租户下已授权的移动端叶子入口。"""
tenant = (tenant_no or "").strip()
uid = (user_id or "").strip()
if not tenant or not uid:
return []
root = _find_mobile_root(db, tenant)
if not root:
return []
descendants = _collect_descendants(db, root.id, tenant)
by_id = {menu.id: menu for menu in descendants}
allowed_ids = None if is_super_admin else _user_menu_ids(db, uid, tenant)
items: List[dict] = []
for menu in descendants:
if (menu.menu_type or "").upper() == "F":
continue
path = (menu.path or "").strip()
if not path:
continue
if "form-sink" in path.lower() or "水槽" in str(menu.menu_name or ""):
continue
if allowed_ids is not None and menu.id not in allowed_ids:
continue
colors = _mobile_menu_icon_colors(path)
parent = by_id.get((menu.parent_id or "").strip())
item = {
"id": menu.id,
"title": menu.menu_name,
"path": path if path.startswith("/") else f"/{path}",
"icon": _mobile_menu_icon(path),
"iconBg": colors["iconBg"],
"iconFg": colors["iconFg"],
"perms": (menu.perms or "").strip() or None,
}
if parent is not None and parent.id != root.id:
item["groupTitle"] = parent.menu_name
items.append(item)
return items
@router.get("/menus")
async def list_my_mobile_menus(request: Request, db: Session = Depends(get_db)):
try:
user = await _current_user(request, db)
except Exception:
return error_response(401, "未授权")
user_id = str(user.get("user_id") or user.get("sub") or "").strip()
tenant_no = str(user.get("tenant_no") or "").strip()
if not user_id or not tenant_no:
return error_response(401, "未授权")
web_role = resolve_web_role_for_user(db, user_id, tenant_no=tenant_no)
items = list_mobile_menu_items(
db,
user_id=user_id,
tenant_no=tenant_no,
is_super_admin=web_role == "super_admin",
)
return success_response({"list": items})
File diff suppressed because it is too large. Load diff
+54 -1
View File
@@ -447,6 +447,22 @@ class StorageFacilityUpdateBody(BaseModel):
remark: Optional[str] = None
class StorageFacilityBatchCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
parent_id: str = Field("0", alias="parentId")
name_prefix: str = Field("", alias="namePrefix")
name_suffix: str = Field("", alias="nameSuffix")
start_num: int = Field(..., alias="startNum", ge=0)
end_num: int = Field(..., alias="endNum", ge=0)
pad_width: int = Field(0, alias="padWidth", ge=0, le=8)
code_prefix: str = Field("", alias="codePrefix")
code_suffix: str = Field("", alias="codeSuffix")
node_type: Optional[str] = Field(None, alias="nodeType")
is_leaf: Optional[int] = Field(0, alias="isLeaf")
remark: Optional[str] = None
@router.get("/storage-facilities")
async def list_storage_facilities(
keyword: Optional[str] = None,
@@ -500,6 +516,35 @@ async def create_storage_facility(
return error_response(400, str(e))
@router.post("/storage-facilities/batch")
async def batch_create_storage_facilities(
body: StorageFacilityBatchCreateBody,
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_facilities(
db,
parent_id=body.parent_id,
name_prefix=body.name_prefix,
name_suffix=body.name_suffix,
start_num=body.start_num,
end_num=body.end_num,
pad_width=body.pad_width,
code_prefix=body.code_prefix,
code_suffix=body.code_suffix,
node_type=body.node_type,
is_leaf=body.is_leaf or 0,
tenant_no=tenant_no,
remark=body.remark,
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.put("/storage-facilities/{node_id}")
async def update_storage_facility(
node_id: str,
@@ -603,13 +648,21 @@ async def list_storage_slots(
BizStorageFacilitySlot.order_num.asc(),
BizStorageFacilitySlot.create_time.asc(),
).all()
facility_by_id = storage_facility_svc.load_facility_map(db, tenant_no)
facility_by_id = storage_facility_svc.load_facility_ancestor_map(
db, tenant_no, facilityNodeId
)
result_list = [storage_facility_svc._slot_to_dict(r, facility_by_id) for r in items]
if availableOnly:
# 仅空格场景无需 enrich 在库产品
result_list = [
row for row in result_list
if (row.get("slotStatus") or "") == "空" or int(row.get("occupiedCount") or 0) == 0
]
else:
# 占用格附带名称/类型(轻量批量,供库位管理货架展示)
result_list = occupancy_svc.enrich_slots_with_stored_cell_products(
db, tenant_no, result_list
)
return success_response(result_list)
@@ -47,6 +47,8 @@ async def list_pilot_release_reviews(
bizType: Optional[str] = None,
infusionTimeStart: Optional[str] = None,
infusionTimeEnd: Optional[str] = None,
reviewPhase: Optional[str] = Query(None),
readyForReviewOnly: Optional[bool] = Query(None),
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:outboundReleaseReview:list")),
):
@@ -62,6 +64,8 @@ async def list_pilot_release_reviews(
biz_type=bizType,
infusion_time_start=infusionTimeStart,
infusion_time_end=infusionTimeEnd,
review_phase=reviewPhase,
ready_for_review_only=readyForReviewOnly,
page=page,
page_size=pageSize,
user_info=user,
+6
View File
@@ -151,6 +151,8 @@ class ScheduleProductNestedBody(BaseModel):
project_name: Optional[str] = Field(None, alias="projectName")
cell_type_name: Optional[str] = Field(None, alias="cellTypeName")
spec_text: Optional[str] = Field(None, alias="specText")
package_id: Optional[str] = Field(None, alias="packageId")
package_text: Optional[str] = Field(None, alias="packageText")
agreement_price: Optional[float] = Field(None, alias="agreementPrice")
actual_price: Optional[float] = Field(None, alias="actualPrice")
quantity: Optional[int] = 1
@@ -167,6 +169,8 @@ class InfusionScheduleNestedBody(BaseModel):
infusion_location: Optional[str] = Field(None, alias="infusionLocation")
infusion_method: Optional[str] = Field(None, alias="infusionMethod")
amount: Optional[float] = None
package_id: Optional[str] = Field(None, alias="packageId")
package_text: Optional[str] = Field(None, alias="packageText")
cell_products: Optional[list[ScheduleProductNestedBody]] = Field(None, alias="cellProducts")
@@ -500,6 +504,8 @@ class SaleScheduleProductLineBody(BaseModel):
project_name: Optional[str] = Field(None, alias="projectName")
cell_type_name: Optional[str] = Field(None, alias="cellTypeName")
spec_text: Optional[str] = Field(None, alias="specText")
package_id: Optional[str] = Field(None, alias="packageId")
package_text: Optional[str] = Field(None, alias="packageText")
agreement_price: Optional[float] = Field(None, alias="agreementPrice")
actual_price: Optional[float] = Field(None, alias="actualPrice")
quantity: Optional[int] = 1
+102
View File
@@ -228,6 +228,108 @@ async def delete_cell_product_spec(
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 2b. 细胞产品包装
# ---------------------------------------------------------------------------
class CellProductPackageCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
package_code: str = Field(..., alias="packageCode")
package_name: str = Field(..., alias="packageName")
order_num: Optional[int] = Field(0, alias="orderNum")
remark: Optional[str] = None
class CellProductPackageUpdateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
package_code: Optional[str] = Field(None, alias="packageCode")
package_name: Optional[str] = Field(None, alias="packageName")
order_num: Optional[int] = Field(None, alias="orderNum")
remark: Optional[str] = None
@router.get("/cell-product-packages")
async def list_cell_product_packages(
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 = ps_svc.list_cell_product_packages(
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no, user_info=user
)
tp = ps_svc.total_pages(total, pageSize)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}
)
@router.post("/cell-product-packages")
async def create_cell_product_package(
body: CellProductPackageCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
obj = ps_svc.create_cell_product_package(
db,
package_code=body.package_code,
package_name=body.package_name,
tenant_no=tenant_no,
order_num=body.order_num,
remark=body.remark,
user_id=user.get("user_id"),
)
return success_response(ps_svc._row_to_dict_cell_product_package(obj), message="创建成功")
except ValueError as e:
return error_response(400, str(e))
@router.put("/cell-product-packages/{row_id}")
async def update_cell_product_package(
row_id: str,
body: CellProductPackageUpdateBody,
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 = ps_svc.update_cell_product_package(
db,
row_id,
package_code=payload.get("package_code"),
package_name=payload.get("package_name"),
order_num=payload.get("order_num"),
remark=payload.get("remark"),
user_id=user.get("user_id"),
tenant_no=tenant_no,
)
return success_response(ps_svc._row_to_dict_cell_product_package(obj), message="更新成功")
except ValueError as e:
return error_response(400, str(e))
@router.delete("/cell-product-packages/{row_id}")
async def delete_cell_product_package(
row_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
ps_svc.soft_delete_cell_product_package(db, row_id, tenant_no=tenant_no)
return success_response(None, message="删除成功")
except ValueError as e:
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 3. 细胞产品价格
# ---------------------------------------------------------------------------
+13 -3
View File
@@ -5,7 +5,7 @@ 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_menu_code
from app.core.menu_guard import require_any_menu_code
from app.core.response import error_response, success_response
from app.crud import pre_transit_sample as pts_svc
from app.models.models import SysUser
@@ -44,7 +44,12 @@ async def list_in_transit_samples(
sampleCollectTimeStart: Optional[str] = Query(None, alias="sampleCollectTimeStart"),
sampleCollectTimeEnd: Optional[str] = Query(None, alias="sampleCollectTimeEnd"),
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:preTransitSample:list")),
user: dict = Depends(
require_any_menu_code(
"biz:preTransitSample:list",
"production:preTransitSample:list",
)
),
):
try:
tenant_no = _get_tenant_no(db, user)
@@ -79,7 +84,12 @@ async def list_pre_delivery_samples(
sampleCollectTimeStart: Optional[str] = Query(None, alias="sampleCollectTimeStart"),
sampleCollectTimeEnd: Optional[str] = Query(None, alias="sampleCollectTimeEnd"),
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:preTransitSample:list")),
user: dict = Depends(
require_any_menu_code(
"biz:preTransitSample:list",
"production:preTransitSample:list",
)
),
):
try:
tenant_no = _get_tenant_no(db, user)
+448
View File
@@ -0,0 +1,448 @@
"""预签约订单 / 意向金转账 / 转正式 API"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.data_scope import get_user_dept_id
from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import error_response, success_response
from app.crud import presign_order as po_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
def _get_current_user_id(user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失")
return str(user_id)
class PresignOrderBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
order_date: Optional[str] = Field(None, alias="orderDate")
protocol_no: Optional[str] = Field(None, alias="protocolNo")
mother_name: Optional[str] = Field(None, alias="motherName")
expected_delivery_date: Optional[str] = Field(None, alias="expectedDeliveryDate")
region_id: Optional[str] = Field(None, alias="regionId")
hospital_id: Optional[str] = Field(None, alias="hospitalId")
sample_collect_time: Optional[str] = Field(None, alias="sampleCollectTime")
pay_mode: Optional[str] = Field(None, alias="payMode")
contract_amount: Optional[float] = Field(None, alias="contractAmount")
collector_user_id: Optional[str] = Field(None, alias="collectorUserId")
sales_user_id: Optional[str] = Field(None, alias="salesUserId")
region_manager_user_id: Optional[str] = Field(None, alias="regionManagerUserId")
order_status: Optional[str] = Field(None, alias="orderStatus")
remark: Optional[str] = None
sample_type_ids: Optional[List[str]] = Field(None, alias="sampleTypeIds")
transfers: Optional[List[Dict[str, Any]]] = None
@field_validator("order_date", "expected_delivery_date")
@classmethod
def validate_ymd(cls, value: Optional[str]) -> Optional[str]:
if value in (None, ""):
return value
text = value.strip()
if len(text) != 8 or not text.isdigit():
raise ValueError("日期必须使用 YYYYMMDD 格式")
try:
datetime.strptime(text, "%Y%m%d")
except ValueError as exc:
raise ValueError("日期必须是有效的 YYYYMMDD") from exc
return text
class PresignTransferBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
transfer_amount: Optional[float] = Field(None, alias="transferAmount")
transfer_time: Optional[str] = Field(None, alias="transferTime")
transfer_type: Optional[str] = Field(None, alias="transferType")
transfer_attachments: Optional[str] = Field(None, alias="transferAttachments")
remark: Optional[str] = None
class FinanceConfirmBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
finance_confirm_attachments: Optional[str] = Field(
None, alias="financeConfirmAttachments"
)
finance_remark: Optional[str] = Field(None, alias="financeRemark")
@router.get("/presign-orders")
async def list_presign_orders(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
orderStatus: Optional[str] = Query(None, alias="orderStatus"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items, total = po_svc.list_presign_orders(
db,
tenant_no=tenant_no,
page=page,
page_size=pageSize,
keyword=keyword,
order_status=orderStatus,
user_info=user,
)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": pageSize}
)
except PermissionError as e:
return error_response(403, str(e))
@router.get("/presign-orders/{order_id}")
async def get_presign_order(
order_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
po_svc.assert_presign_data_access(db, order_id, tenant_no, user)
data = po_svc.get_presign_order(db, order_id=order_id, tenant_no=tenant_no)
if not data:
return error_response(404, "预签约订单不存在")
return success_response(data)
except PermissionError as e:
return error_response(403, str(e))
@router.post("/presign-orders")
async def create_presign_order(
body: PresignOrderBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
dumped = body.model_dump(by_alias=False)
obj = po_svc.create_presign_order(
db,
create_dept_id=get_user_dept_id(db, user_id) or "",
tenant_no=tenant_no,
user_id=user_id,
fields=dumped,
sample_type_ids=body.sample_type_ids,
transfers=body.transfers,
)
return success_response(
po_svc.order_to_dict(db, obj, tenant_no, with_children=True),
message="创建成功",
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.put("/presign-orders/{order_id}")
async def update_presign_order(
order_id: str,
body: PresignOrderBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
po_svc.assert_presign_data_access(db, order_id, tenant_no, user)
user_id = _get_current_user_id(user)
dumped = body.model_dump(by_alias=False, exclude_unset=True)
sample_type_ids = dumped.pop("sample_type_ids", None)
dumped.pop("transfers", None)
obj = po_svc.update_presign_order(
db,
create_dept_id=get_user_dept_id(db, user_id) or "",
order_id=order_id,
tenant_no=tenant_no,
user_id=user_id,
fields=dumped,
sample_type_ids=sample_type_ids,
)
return success_response(
po_svc.order_to_dict(db, obj, tenant_no, with_children=True),
message="保存成功",
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.delete("/presign-orders/{order_id}")
async def delete_presign_order(
order_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
po_svc.assert_presign_data_access(db, order_id, tenant_no, user)
user_id = _get_current_user_id(user)
po_svc.soft_delete_presign_order(
db, order_id=order_id, tenant_no=tenant_no, user_id=user_id
)
return success_response(None, message="删除成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/presign-orders/{order_id}/convert")
async def convert_presign_order(
order_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
po_svc.assert_presign_data_access(db, order_id, tenant_no, user)
user_id = _get_current_user_id(user)
data = po_svc.convert_to_protocol(
db, order_id=order_id, tenant_no=tenant_no, user_id=user_id,
create_dept_id=get_user_dept_id(db, user_id) or "",
)
return success_response(data, message="已转正式协议")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/presign-orders/{order_id}/transfers")
async def create_presign_transfer(
order_id: str,
body: PresignTransferBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
po_svc.assert_presign_data_access(db, order_id, tenant_no, user)
user_id = _get_current_user_id(user)
obj = po_svc.add_transfer(
db,
create_dept_id=get_user_dept_id(db, user_id) or "",
presign_order_id=order_id,
tenant_no=tenant_no,
user_id=user_id,
fields=body.model_dump(by_alias=False),
)
return success_response(
po_svc._transfer_to_dict(db, obj, tenant_no), message="添加成功"
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.put("/presign-transfers/{transfer_id}")
async def update_presign_transfer(
transfer_id: str,
body: PresignTransferBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
po_svc.assert_presign_data_access(
db, transfer_id, tenant_no, user, transfer=True
)
user_id = _get_current_user_id(user)
obj = po_svc.update_transfer_draft(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_id=user_id,
fields=body.model_dump(by_alias=False, exclude_unset=True),
)
return success_response(
po_svc._transfer_to_dict(db, obj, tenant_no), message="保存成功"
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/presign-transfers/{transfer_id}/submit-receipt-finance")
async def submit_presign_transfer_finance(
transfer_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
po_svc.assert_presign_data_access(
db, transfer_id, tenant_no, user, transfer=True
)
user_id = _get_current_user_id(user)
obj = po_svc.submit_receipt_finance(
db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id
)
return success_response(
po_svc._transfer_to_dict(db, obj, tenant_no), message="已提交财务确认"
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/presign-transfers/{transfer_id}/withdraw")
async def withdraw_presign_transfer(
transfer_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
po_svc.assert_presign_data_access(
db, transfer_id, tenant_no, user, transfer=True
)
user_id = _get_current_user_id(user)
obj = po_svc.withdraw_receipt(
db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id
)
return success_response(
po_svc._transfer_to_dict(db, obj, tenant_no), message="已撤回"
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.get("/presign-transfers/receipt-confirm")
async def list_presign_receipt_confirm(
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 = po_svc.list_presign_transfers_for_finance(
db,
pay_status="jfqrz",
tenant_no=tenant_no,
keyword=keyword,
page=page,
page_size=pageSize,
user_info=user,
)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": pageSize}
)
except PermissionError as e:
return error_response(403, str(e))
@router.get("/presign-transfers/receipt-confirmed")
async def list_presign_receipt_confirmed(
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 = po_svc.list_presign_transfers_for_finance(
db,
pay_status="yjf",
tenant_no=tenant_no,
keyword=keyword,
page=page,
page_size=pageSize,
user_info=user,
)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": pageSize}
)
except PermissionError as e:
return error_response(403, str(e))
@router.post("/presign-transfers/{transfer_id}/receipt-confirm")
async def confirm_presign_transfer(
transfer_id: str,
body: Optional[FinanceConfirmBody] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
po_svc.assert_presign_data_access(
db, transfer_id, tenant_no, user, transfer=True
)
user_id = _get_current_user_id(user)
obj = po_svc.confirm_receipt(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_id=user_id,
finance_confirm_attachments=body.finance_confirm_attachments if body else None,
finance_remark=body.finance_remark if body else None,
)
return success_response(
po_svc._transfer_to_dict(db, obj, tenant_no), message="确认收款成功"
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/presign-transfers/{transfer_id}/receipt-reject")
async def reject_presign_transfer(
transfer_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
po_svc.assert_presign_data_access(
db, transfer_id, tenant_no, user, transfer=True
)
user_id = _get_current_user_id(user)
obj = po_svc.reject_receipt(
db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id
)
return success_response(
po_svc._transfer_to_dict(db, obj, tenant_no), message="已退回"
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
+390 -15
View File
@@ -19,6 +19,7 @@ from app.crud import sop_document as sop_document_svc
from app.crud import process_category as process_category_svc
from app.crud import equipment as equipment_svc
from app.crud import output_type as output_type_svc
from app.crud import production_product_type as production_product_type_svc
from app.crud import consumable_pack as consumable_pack_svc
from app.crud import process as process_svc
from app.crud import process_route as route_svc
@@ -38,6 +39,8 @@ from app.schemas.process_route import (
from app.models.models import SysUser
from app.crud import production_work_order as production_work_order_svc
from app.crud import production_task as production_task_svc
from app.crud import production_task_set as production_task_set_svc
from app.crud import production_waste_output as production_waste_output_svc
from app.crud import cell_product as cell_product_svc
from app.crud import qc_work_order as qc_work_order_svc
from app.schemas.production_work_order import (
@@ -47,6 +50,12 @@ from app.schemas.production_work_order import (
ProductionWorkOrderConfigureBody,
ProductionWorkOrderUpdate,
)
from app.schemas.production_task_set import (
ProductionTaskSetCreateBody,
ProductionTaskSetFromWorkOrderBody,
ProductionTaskSetUpdateBody,
ProductionTasksFromSetBody,
)
from app.schemas.production_task import (
ProductionTaskCreate,
ProductionTaskUpdate,
@@ -1014,6 +1023,110 @@ async def delete_output_type(
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 5b. 生产产品类型(生产专用,解耦中试 biz_cell_product_type)
# ---------------------------------------------------------------------------
class ProductionProductTypeCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
product_type_code: str = Field(..., alias="productTypeCode")
product_type_name: str = Field(..., alias="productTypeName")
order_num: Optional[int] = Field(0, alias="orderNum")
remark: Optional[str] = None
class ProductionProductTypeUpdateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
product_type_code: Optional[str] = Field(None, alias="productTypeCode")
product_type_name: Optional[str] = Field(None, alias="productTypeName")
order_num: Optional[int] = Field(None, alias="orderNum")
remark: Optional[str] = None
@router.get("/production-product-types")
async def list_production_product_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 = production_product_type_svc.list_production_product_types(
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no, user_info=user
)
tp = production_product_type_svc.total_pages(total, pageSize)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}
)
@router.post("/production-product-types")
async def create_production_product_type(
body: ProductionProductTypeCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
obj = production_product_type_svc.create_production_product_type(
db,
product_type_code=body.product_type_code,
product_type_name=body.product_type_name,
tenant_no=tenant_no,
order_num=body.order_num,
remark=body.remark,
user_id=user.get("user_id"),
)
return success_response(production_product_type_svc._row_to_dict(obj), message="创建成功")
except ValueError as e:
return error_response(400, str(e))
@router.put("/production-product-types/{row_id}")
async def update_production_product_type(
row_id: str,
body: ProductionProductTypeUpdateBody,
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 = production_product_type_svc.update_production_product_type(
db,
row_id,
product_type_code=payload.get("product_type_code"),
product_type_name=payload.get("product_type_name"),
order_num=payload.get("order_num"),
remark=payload.get("remark"),
user_id=user.get("user_id"),
tenant_no=tenant_no,
)
return success_response(production_product_type_svc._row_to_dict(obj), message="更新成功")
except ValueError as e:
return error_response(400, str(e))
@router.delete("/production-product-types/{row_id}")
async def delete_production_product_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)
production_product_type_svc.soft_delete_production_product_type(
db, row_id, tenant_no=tenant_no
)
return success_response(None, message="删除成功")
except ValueError as e:
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 6. 耗材包(主 + 子项)
# ---------------------------------------------------------------------------
@@ -1164,6 +1277,7 @@ async def delete_consumable_pack(
class ProcessReagentInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
reagent_code: Optional[str] = Field(None, alias="reagentCode")
reagent_name: Optional[str] = Field(None, alias="reagentName")
spec: Optional[str] = None
@@ -1175,6 +1289,7 @@ class ProcessReagentInput(BaseModel):
class ProcessConsumableInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
consumable_code: Optional[str] = Field(None, alias="consumableCode")
consumable_name: Optional[str] = Field(None, alias="consumableName")
spec_model: Optional[str] = Field(None, alias="specModel")
@@ -1186,6 +1301,7 @@ class ProcessConsumableInput(BaseModel):
class ProcessOutputInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
output_type_id: Optional[str] = Field(None, alias="outputTypeId")
output_name: Optional[str] = Field(None, alias="outputName")
output_barcode: Optional[str] = Field(None, alias="outputBarcode")
@@ -1194,6 +1310,7 @@ class ProcessOutputInput(BaseModel):
class ProcessDataCollectionInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
collection_name: str = Field(..., alias="collectionName")
unit: Optional[str] = None
order_num: Optional[int] = Field(0, alias="orderNum")
@@ -1209,6 +1326,7 @@ class ProcessCreateBody(BaseModel):
equipment_ids: Optional[List[str]] = Field(None, alias="equipmentIds")
estimated_duration: Optional[str] = Field(None, alias="estimatedDuration")
sop_ids: Optional[str] = Field(None, alias="sopIds")
sop_content: Optional[str] = Field(None, alias="sopContent")
order_num: Optional[int] = Field(0, alias="orderNum")
remark: Optional[str] = None
reagents: List[ProcessReagentInput] = []
@@ -1226,6 +1344,7 @@ class ProcessUpdateBody(BaseModel):
equipment_ids: Optional[List[str]] = Field(None, alias="equipmentIds")
estimated_duration: Optional[str] = Field(None, alias="estimatedDuration")
sop_ids: Optional[str] = Field(None, alias="sopIds")
sop_content: Optional[str] = Field(None, alias="sopContent")
order_num: Optional[int] = Field(None, alias="orderNum")
remark: Optional[str] = None
reagents: Optional[List[ProcessReagentInput]] = None
@@ -1298,6 +1417,7 @@ async def create_process(
equipment_ids=body.equipment_ids,
estimated_duration=body.estimated_duration,
sop_ids=body.sop_ids,
sop_content=body.sop_content,
reagents=reagents_data,
consumables=consumables_data,
outputs=outputs_data,
@@ -1339,6 +1459,7 @@ async def update_process(
"description": payload.get("description"),
"estimated_duration": payload.get("estimated_duration"),
"sop_ids": payload.get("sop_ids"),
"sop_content": payload.get("sop_content"),
"reagents": reagents_data,
"consumables": consumables_data,
"outputs": outputs_data,
@@ -1437,16 +1558,19 @@ async def update_process_route(
db: Session = Depends(get_db),
):
try:
route_svc.assert_route_writable(db, route_id, user)
payload = body.model_dump(exclude_unset=True, by_alias=False)
obj = route_svc.update_route(
db, route_id,
user_id=user.get("user_id"),
route_code=payload.get("route_code"),
route_name=payload.get("route_name"),
product_type_id=payload.get("product_type_id"),
version=payload.get("version"),
remark=payload.get("remark"),
)
update_kwargs = {
"user_id": user.get("user_id"),
"route_code": payload.get("route_code"),
"route_name": payload.get("route_name"),
"product_type_id": payload.get("product_type_id"),
"version": payload.get("version"),
"remark": payload.get("remark"),
}
if "equipment_ids" in payload:
update_kwargs["equipment_ids"] = payload.get("equipment_ids")
obj = route_svc.update_route(db, route_id, **update_kwargs)
return success_response(route_svc._route_row_to_dict(obj), message="更新成功")
except ValueError as e:
return error_response(400, str(e))
@@ -1459,6 +1583,7 @@ async def set_current_process_route(
db: Session = Depends(get_db),
):
try:
route_svc.assert_route_writable(db, body.route_id, user)
tenant_no = _get_tenant_no(db, user)
obj = route_svc.set_current_route(db, body.route_id, tenant_no)
return success_response(route_svc._route_row_to_dict(obj), message="设置成功")
@@ -1469,10 +1594,11 @@ async def set_current_process_route(
@router.delete("/process-routes/{route_id}")
async def delete_process_route(
route_id: str,
_user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
route_svc.assert_route_writable(db, route_id, user)
route_svc.soft_delete_route(db, route_id)
return success_response(None, message="删除成功")
except ValueError as e:
@@ -1488,6 +1614,7 @@ async def add_route_step(
db: Session = Depends(get_db),
):
try:
route_svc.assert_route_writable(db, body.route_id, user)
tenant_no = _get_tenant_no(db, user)
step_data = route_svc.add_step(
db,
@@ -1509,6 +1636,7 @@ async def copy_route_step(
db: Session = Depends(get_db),
):
try:
route_svc.assert_step_route_writable(db, body.step_id, user)
tenant_no = _get_tenant_no(db, user)
step_data = route_svc.copy_step(
db,
@@ -1528,6 +1656,7 @@ async def update_route_step(
db: Session = Depends(get_db),
):
try:
route_svc.assert_step_route_writable(db, body.step_id, user)
payload = body.model_dump(exclude_unset=True, by_alias=False)
payload.pop("step_id", None)
obj = route_svc.update_step(db, body.step_id, user_id=user.get("user_id"), **payload)
@@ -1539,10 +1668,11 @@ async def update_route_step(
@router.put("/process-routes/step/reorder")
async def reorder_route_steps(
body: StepReorderBody,
_user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
route_svc.assert_route_writable(db, body.route_id, user)
route_svc.reorder_steps(db, body.route_id, body.step_ids)
return success_response(None, message="排序成功")
except ValueError as e:
@@ -1552,10 +1682,11 @@ async def reorder_route_steps(
@router.delete("/process-routes/step/{step_id}")
async def delete_route_step(
step_id: str,
_user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
route_svc.assert_step_route_writable(db, step_id, user)
route_svc.soft_delete_step(db, step_id)
return success_response(None, message="删除成功")
except ValueError as e:
@@ -1571,6 +1702,7 @@ async def update_step_reagents(
db: Session = Depends(get_db),
):
try:
route_svc.assert_step_route_writable(db, body.step_id, user)
tenant_no = _get_tenant_no(db, user)
items_data = [it.model_dump(by_alias=False) for it in body.items]
route_svc.update_step_reagents(db, body.step_id, items_data, tenant_no, user.get("user_id"))
@@ -1586,6 +1718,7 @@ async def update_step_consumables(
db: Session = Depends(get_db),
):
try:
route_svc.assert_step_route_writable(db, body.step_id, user)
tenant_no = _get_tenant_no(db, user)
items_data = [it.model_dump(by_alias=False) for it in body.items]
route_svc.update_step_consumables(db, body.step_id, items_data, tenant_no, user.get("user_id"))
@@ -1601,6 +1734,7 @@ async def update_step_outputs(
db: Session = Depends(get_db),
):
try:
route_svc.assert_step_route_writable(db, body.step_id, user)
tenant_no = _get_tenant_no(db, user)
items_data = [it.model_dump(by_alias=False) for it in body.items]
route_svc.update_step_outputs(db, body.step_id, items_data, tenant_no, user.get("user_id"))
@@ -1616,6 +1750,7 @@ async def update_step_data_collections(
db: Session = Depends(get_db),
):
try:
route_svc.assert_step_route_writable(db, body.step_id, user)
tenant_no = _get_tenant_no(db, user)
items_data = [it.model_dump(by_alias=False) for it in body.items]
route_svc.update_step_data_collections(db, body.step_id, items_data, tenant_no, user.get("user_id"))
@@ -1623,7 +1758,6 @@ async def update_step_data_collections(
except ValueError as e:
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 9. 入库生产工单
# ---------------------------------------------------------------------------
@@ -1810,6 +1944,25 @@ async def complete_production_work_order(
return error_response(400, str(e))
@router.put("/production-work-orders/{wo_id}/void")
async def void_production_work_order_api(
wo_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
wo = production_work_order_svc.void_production_work_order(
db, wo_id, tenant_no, user.get("user_id"),
)
return success_response(
production_work_order_svc._wo_to_response_dict(db, wo),
message="工单已作废",
)
except ValueError as e:
return error_response(400, str(e))
@router.delete("/production-work-orders/{wo_id}")
async def delete_production_work_order_api(
wo_id: str,
@@ -1916,6 +2069,155 @@ async def create_qc_work_order_from_production_api(
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 生产任务集合
# ---------------------------------------------------------------------------
@router.get("/production-task-sets")
async def list_production_task_sets(
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_menu_code("production:task-set:list")),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items, total = production_task_set_svc.list_sets(
db, tenant_no=tenant_no, page=page, page_size=pageSize, keyword=keyword, status=status,
)
tp = production_task_set_svc.total_pages(total, pageSize)
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
@router.get("/production-task-sets/active")
async def list_active_production_task_sets(
user: dict = Depends(require_any_menu_code("production:task-set:list", "production:work-order:list")),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items = production_task_set_svc.list_active_sets(db, tenant_no=tenant_no)
return success_response({"list": items})
@router.post("/production-task-sets/from-work-order")
async def create_production_task_set_from_work_order(
body: ProductionTaskSetFromWorkOrderBody,
user: dict = Depends(
require_any_menu_code("production:work-order:list", "production:task-set:add")
),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
detail = production_task_set_svc.create_set_from_work_order(
db,
tenant_no=tenant_no,
user_id=user.get("user_id"),
work_order_id=body.work_order_id,
set_name=body.set_name,
remark=body.remark,
items=(
[x.model_dump(by_alias=False) for x in body.items]
if body.items is not None
else None
),
)
db.commit()
return success_response(detail, message="保存成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@router.get("/production-task-sets/{set_id}")
async def get_production_task_set(
set_id: str,
user: dict = Depends(require_any_menu_code("production:task-set:list", "production:work-order:list")),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
detail = production_task_set_svc.get_set(db, set_id, tenant_no)
if not detail:
return error_response(404, "集合不存在")
return success_response(detail)
@router.post("/production-task-sets")
async def create_production_task_set(
body: ProductionTaskSetCreateBody,
user: dict = Depends(require_menu_code("production:task-set:add")),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
detail = production_task_set_svc.create_set(
db,
tenant_no=tenant_no,
user_id=user.get("user_id"),
set_name=body.set_name,
status=body.status or "1",
remark=body.remark,
items=[x.model_dump(by_alias=False) for x in body.items],
)
db.commit()
return success_response(detail, message="创建成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@router.put("/production-task-sets/{set_id}")
async def update_production_task_set(
set_id: str,
body: ProductionTaskSetUpdateBody,
user: dict = Depends(require_menu_code("production:task-set:edit")),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
production_task_set_svc.assert_set_writable(db, set_id, tenant_no, user)
data = body.model_dump(by_alias=False, exclude_unset=True)
items = None
if "items" in data:
items = [x.model_dump(by_alias=False) for x in (body.items or [])]
detail = production_task_set_svc.update_set(
db,
set_id,
tenant_no=tenant_no,
user_id=user.get("user_id"),
set_name=data.get("set_name"),
status=data.get("status"),
remark=data.get("remark"),
items=items,
)
db.commit()
return success_response(detail, message="更新成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@router.delete("/production-task-sets/{set_id}")
async def delete_production_task_set(
set_id: str,
user: dict = Depends(require_menu_code("production:task-set:remove")),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
production_task_set_svc.assert_set_writable(db, set_id, tenant_no, user)
production_task_set_svc.delete_set(
db, set_id, tenant_no=tenant_no, user_id=user.get("user_id")
)
db.commit()
return success_response(None, message="删除成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 10. 生产任务
# ---------------------------------------------------------------------------
@@ -1943,6 +2245,34 @@ async def create_production_task(
return error_response(400, str(e))
@router.post("/production-tasks/from-set")
async def create_production_tasks_from_set(
body: ProductionTasksFromSetBody,
user: dict = Depends(require_menu_code("production:work-order:list")),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
result = production_task_set_svc.create_tasks_from_set(
db,
tenant_no=tenant_no,
user_id=user.get("user_id"),
create_dept_id="",
work_order_id=body.work_order_id,
set_id=body.set_id,
items=(
[x.model_dump(by_alias=False) for x in body.items]
if body.items is not None
else None
),
)
db.commit()
return success_response(result, message="创建成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@router.get("/production-tasks")
async def list_production_tasks(
page: int = Query(1, ge=1),
@@ -1971,6 +2301,33 @@ async def list_production_tasks(
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
@router.get("/production-waste-outputs")
async def list_production_waste_outputs(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
createTimeStart: Optional[str] = Query(None, alias="createTimeStart"),
createTimeEnd: Optional[str] = Query(None, alias="createTimeEnd"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items, total = production_waste_output_svc.list_production_waste_outputs(
db,
tenant_no=tenant_no,
page=page,
page_size=pageSize,
keyword=keyword,
create_time_start=createTimeStart,
create_time_end=createTimeEnd,
user_info=user,
)
tp = production_waste_output_svc.total_pages(total, pageSize)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}
)
@router.post("/production-tasks/list-by-work-orders")
async def list_production_tasks_by_work_orders(
body: ProductionTaskListByWorkOrdersBody,
@@ -2381,6 +2738,7 @@ async def replace_task_step_reagents(
for it in body:
d = it.model_dump(by_alias=False)
items_data.append({
"id": d.get("id"),
"reagent_code": d.get("material_code"),
"reagent_name": d.get("material_name"),
"spec": d.get("spec"),
@@ -2414,6 +2772,7 @@ async def replace_task_step_consumables(
for it in body:
d = it.model_dump(by_alias=False)
items_data.append({
"id": d.get("id"),
"consumable_code": d.get("material_code"),
"consumable_name": d.get("material_name"),
"spec_model": d.get("spec_model"),
@@ -2447,6 +2806,7 @@ async def replace_task_step_configured_reagents(
for it in body:
d = it.model_dump(by_alias=False)
items_data.append({
"id": d.get("id"),
"configured_reagent_code": d.get("material_code"),
"configured_reagent_name": d.get("material_name"),
"spec": d.get("spec"),
@@ -2498,8 +2858,10 @@ async def replace_task_step_outputs(
try:
tenant_no = _get_tenant_no(db, user)
items_data = [it.model_dump(by_alias=False) for it in body]
production_task_svc.replace_step_outputs(db, step_id, tenant_no, user.get("user_id"), items_data)
return success_response(None, message="更新成功")
saved = production_task_svc.replace_step_outputs(
db, step_id, tenant_no, user.get("user_id"), items_data
)
return success_response(saved, message="更新成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
@@ -2693,6 +3055,19 @@ async def get_cell_product_chain(
return success_response(chain)
@router.get("/cell-products/{product_id}/lineage-qc")
async def get_cell_product_lineage_qc(
product_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
data = cell_product_svc.get_product_lineage_qc(db, product_id, tenant_no)
if not data:
raise HTTPException(status_code=404, detail="细胞产品不存在")
return success_response(data)
@router.post("/cell-products")
async def create_cell_product(
body: CellProductCreate,
+2
View File
@@ -739,6 +739,7 @@ class StockCheckCreateBody(BaseModel):
class StockCheckUnusedLineItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
usage_id: Optional[str] = Field(None, alias="usageId")
batch_id: Optional[str] = Field(None, alias="batchId")
material_code: Optional[str] = Field(None, alias="materialCode")
@@ -926,6 +927,7 @@ async def cancel_production_material_stock_check(
class FormulaLineBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
component_material_id: str = Field(..., alias="componentMaterialId")
component_material_type: str = Field(..., alias="componentMaterialType")
standard_qty: float = Field(..., gt=0, alias="standardQty")
+205
View File
@@ -0,0 +1,205 @@
"""协议级退费登记 API"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends
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 protocol_refund_record as rr_svc
from app.models.models import (
BizCollectionProtocol,
BizProtocolRefundRecord,
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)
def _protocol_accessible(db: Session, protocol_id: str, tenant_no: str, user: dict) -> bool:
from app.core.data_scope import apply_data_scope
q = db.query(BizCollectionProtocol).filter(
BizCollectionProtocol.id == protocol_id,
BizCollectionProtocol.del_flag == 0,
BizCollectionProtocol.tenant_no == tenant_no,
)
q = apply_data_scope(q, BizCollectionProtocol, user, db, tenant_no)
return q.first() is not None
class RefundRecordBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
refund_amount: float = Field(..., alias="refundAmount")
refund_time: Optional[str] = Field(None, alias="refundTime")
refund_remark: Optional[str] = Field(None, alias="refundRemark")
refund_attachments: Optional[str] = Field(None, alias="refundAttachments")
workflow_instance_id: Optional[str] = Field(None, alias="workflowInstanceId")
remark: Optional[str] = None
class RefundRecordUpdateBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
refund_amount: Optional[float] = Field(None, alias="refundAmount")
refund_time: Optional[str] = Field(None, alias="refundTime")
refund_remark: Optional[str] = Field(None, alias="refundRemark")
refund_attachments: Optional[str] = Field(None, alias="refundAttachments")
workflow_instance_id: Optional[str] = Field(None, alias="workflowInstanceId")
remark: Optional[str] = None
@router.get("/collection-protocols/{protocol_id}/refund-records")
async def list_protocol_refund_records(
protocol_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(404, "协议单不存在或无权查看")
data = rr_svc.list_refund_records(
db, collection_protocol_id=protocol_id, tenant_no=tenant_no
)
return success_response(data)
except PermissionError as e:
return error_response(403, str(e))
@router.post("/collection-protocols/{protocol_id}/refund-records")
async def create_protocol_refund_record(
protocol_id: str,
body: RefundRecordBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(404, "协议单不存在或无权操作")
obj = rr_svc.create_refund_record(
db,
collection_protocol_id=protocol_id,
tenant_no=tenant_no,
user_id=user_id,
refund_amount=body.refund_amount,
refund_time=body.refund_time,
refund_remark=body.refund_remark,
refund_attachments=body.refund_attachments,
workflow_instance_id=body.workflow_instance_id,
remark=body.remark,
)
return success_response(
rr_svc._record_to_dict(db, obj, tenant_no), message="登记成功"
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.put("/protocol-refund-records/{record_id}")
async def update_protocol_refund_record(
record_id: str,
body: RefundRecordUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
existing = (
db.query(BizProtocolRefundRecord)
.filter(
BizProtocolRefundRecord.id == record_id,
BizProtocolRefundRecord.tenant_no == tenant_no,
BizProtocolRefundRecord.del_flag == 0,
)
.first()
)
if not existing:
return error_response(404, "退费登记不存在")
if not _protocol_accessible(
db, existing.collection_protocol_id, tenant_no, user
):
return error_response(403, "无权操作该协议")
dumped = body.model_dump(by_alias=False, exclude_unset=True)
obj = rr_svc.update_refund_record(
db,
record_id=record_id,
tenant_no=tenant_no,
user_id=user_id,
refund_amount=dumped.get("refund_amount"),
refund_time=dumped.get("refund_time"),
refund_remark=dumped.get("refund_remark"),
refund_attachments=dumped.get("refund_attachments"),
workflow_instance_id=dumped.get("workflow_instance_id"),
remark=dumped.get("remark"),
fields_set=set(dumped.keys()),
)
return success_response(
rr_svc._record_to_dict(db, obj, tenant_no), message="保存成功"
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.delete("/protocol-refund-records/{record_id}")
async def delete_protocol_refund_record(
record_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
existing = (
db.query(BizProtocolRefundRecord)
.filter(
BizProtocolRefundRecord.id == record_id,
BizProtocolRefundRecord.tenant_no == tenant_no,
BizProtocolRefundRecord.del_flag == 0,
)
.first()
)
if not existing:
return error_response(404, "退费登记不存在")
if not _protocol_accessible(
db, existing.collection_protocol_id, tenant_no, user
):
return error_response(403, "无权操作该协议")
rr_svc.soft_delete_refund_record(
db, record_id=record_id, tenant_no=tenant_no, user_id=user_id
)
return success_response(None, message="删除成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
+340 -1
View File
@@ -1,15 +1,19 @@
"""采集协议样本采集信息 API"""
from typing import Optional
from typing import 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_any_menu_code
from app.core.response import error_response, success_response
from app.core.utils import parse_date, parse_datetime
from app.crud import protocol_sample_collection as svc
from app.crud import sample_info_quick_register as quick_svc
from app.crud.collection_protocol import total_pages
from app.models.models import SysUser
from app.services.sample_customer_service_register import has_any_customer_service_payload
router = APIRouter(prefix="/web/admin", tags=["采集协议样本采集信息"])
@@ -17,6 +21,11 @@ _SAMPLE_INFO_MENU = require_any_menu_code(
"cell-storage:sample-info:list",
)
_QUICK_REGISTER_MENU = require_any_menu_code(
"cell-storage:sample-info:list",
"research-donation:sample-info:list",
)
def _get_tenant_no(db: Session, user: dict) -> str:
user_id = user.get("user_id")
@@ -44,6 +53,12 @@ async def list_protocol_sample_collections(
pageSize: int = Query(10, ge=1, le=100),
keyword: Optional[str] = None,
sampleStatus: Optional[str] = Query(None, alias="sampleStatus"),
collectionFlag: Optional[str] = Query(None, alias="collectionFlag"),
receptionFlag: Optional[str] = Query(None, alias="receptionFlag"),
qcFlag: Optional[str] = Query(None, alias="qcFlag"),
storageFlag: Optional[str] = Query(None, alias="storageFlag"),
storageCertHandled: Optional[str] = Query(None, alias="storageCertHandled"),
insuranceHandled: Optional[str] = Query(None, alias="insuranceHandled"),
db: Session = Depends(get_db),
user: dict = Depends(_SAMPLE_INFO_MENU),
):
@@ -56,6 +71,12 @@ async def list_protocol_sample_collections(
tenant_no=tenant_no,
keyword=keyword,
sample_status=sampleStatus,
collection_flag=collectionFlag,
reception_flag=receptionFlag,
qc_flag=qcFlag,
storage_flag=storageFlag,
storage_cert_handled=storageCertHandled,
insurance_handled=insuranceHandled,
user_info=user,
)
return success_response(_page_payload(items, total, page, pageSize))
@@ -63,3 +84,321 @@ async def list_protocol_sample_collections(
return error_response(403, str(e))
except Exception as e:
return error_response(400, str(e))
def _get_current_user_id(user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失")
return str(user_id)
class QuickRegisterItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
sample_code: str = Field(..., alias="sampleCode")
protocol_id: str = Field(..., alias="protocolId")
class QuickRegisterBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
kind: str
reception_source: str = Field(..., alias="receptionSource")
items: List[QuickRegisterItem]
handle_time: Optional[str] = Field(None, alias="handleTime")
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
attachments: Optional[str] = None
remark: Optional[str] = None
mail_cert_time: Optional[str] = Field(None, alias="mailCertTime")
policy_send_time: Optional[str] = Field(None, alias="policySendTime")
storage_handle_time: Optional[str] = Field(None, alias="storageHandleTime")
storage_handler_user_id: Optional[str] = Field(None, alias="storageHandlerUserId")
storage_attachments: Optional[str] = Field(None, alias="storageAttachments")
storage_remark: Optional[str] = Field(None, alias="storageRemark")
cert_handle_time: Optional[str] = Field(None, alias="certHandleTime")
cert_handler_user_id: Optional[str] = Field(None, alias="certHandlerUserId")
cert_attachments: Optional[str] = Field(None, alias="certAttachments")
cert_remark: Optional[str] = Field(None, alias="certRemark")
insurance_handle_time: Optional[str] = Field(None, alias="insuranceHandleTime")
insurance_handler_user_id: Optional[str] = Field(None, alias="insuranceHandlerUserId")
insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
insurance_remark: Optional[str] = Field(None, alias="insuranceRemark")
sms_send_date: Optional[str] = Field(None, alias="smsSendDate")
sms_send_status: Optional[str] = Field(None, alias="smsSendStatus")
microbe_followup_date: Optional[str] = Field(None, alias="microbeFollowupDate")
microbe_followup_status: Optional[str] = Field(None, alias="microbeFollowupStatus")
fullmoon_followup_date: Optional[str] = Field(None, alias="fullmoonFollowupDate")
fullmoon_followup_status: Optional[str] = Field(None, alias="fullmoonFollowupStatus")
household_registered: Optional[str] = Field(None, alias="householdRegistered")
einvoice_send_time: Optional[str] = Field(None, alias="einvoiceSendTime")
effective_address: Optional[str] = Field(None, alias="effectiveAddress")
def _optional_dt(raw: Optional[str]):
if raw is None:
return None, False
text = str(raw).strip()
if not text:
return None, False
return parse_datetime(text), True
def _customer_service_incoming_from_body(body: QuickRegisterBody) -> dict:
return {
"sms_send_date": parse_date(body.sms_send_date) if body.sms_send_date else None,
"sms_send_status": body.sms_send_status,
"microbe_followup_date": parse_date(body.microbe_followup_date)
if body.microbe_followup_date
else None,
"microbe_followup_status": body.microbe_followup_status,
"fullmoon_followup_date": parse_date(body.fullmoon_followup_date)
if body.fullmoon_followup_date
else None,
"fullmoon_followup_status": body.fullmoon_followup_status,
"household_registered": body.household_registered,
"einvoice_send_time": parse_datetime(body.einvoice_send_time)
if body.einvoice_send_time
else None,
"effective_address": body.effective_address,
}
@router.post("/sample-info/quick-register")
async def quick_register_sample_info(
body: QuickRegisterBody,
db: Session = Depends(get_db),
user: dict = Depends(_QUICK_REGISTER_MENU),
):
kind = (body.kind or "").strip()
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
ht = parse_datetime(body.handle_time) if body.handle_time else None
items = [
{"sampleCode": it.sample_code, "protocolId": it.protocol_id} for it in (body.items or [])
]
source = (body.reception_source or "").strip()
attachments = body.attachments or ""
remark = body.remark or ""
mail_t, update_mail = _optional_dt(body.mail_cert_time)
policy_t, update_policy = _optional_dt(body.policy_send_time)
cs_incoming = _customer_service_incoming_from_body(body)
if kind == "storage":
if ht is None:
return error_response(400, "请填写办理时间")
result = quick_svc.batch_quick_register_storage(
db,
items=items,
reception_source=source,
tenant_no=tenant_no,
user_id=user_id,
handle_time=ht,
handler_user_id=body.handler_user_id,
attachments=attachments,
remark=remark,
)
elif kind == "storage_cert":
if source != "cell_storage":
return error_response(400, "储存证仅支持细胞存储样本")
if ht is None:
return error_response(400, "请填写办理时间")
result = quick_svc.batch_handle_storage_certs(
db,
items=items,
tenant_no=tenant_no,
user_id=user_id,
handle_time=ht,
handler_user_id=body.handler_user_id,
attachments=attachments,
remark=remark,
mail_cert_time=mail_t,
update_mail_cert_time=update_mail,
)
elif kind == "insurance":
if source != "cell_storage":
return error_response(400, "保险仅支持细胞存储样本")
if ht is None:
return error_response(400, "请填写办理时间")
result = quick_svc.batch_handle_insurance(
db,
items=items,
tenant_no=tenant_no,
user_id=user_id,
handle_time=ht,
handler_user_id=body.handler_user_id,
attachments=attachments,
remark=remark,
policy_send_time=policy_t,
update_policy_send_time=update_policy,
)
elif kind in ("sms", "followup", "fulfillment"):
if source != "cell_storage":
return error_response(400, "客户服务登记仅支持细胞存储样本")
result = quick_svc.batch_register_customer_service(
db,
items=items,
tenant_no=tenant_no,
user_id=user_id,
incoming=cs_incoming,
)
elif kind == "all":
if source != "cell_storage":
return error_response(400, "登记全部仅支持细胞存储样本")
split = any(
value is not None
for value in (
body.storage_handle_time,
body.storage_handler_user_id,
body.storage_attachments,
body.storage_remark,
body.cert_handle_time,
body.insurance_handle_time,
body.cert_attachments,
body.insurance_attachments,
body.cert_remark,
body.insurance_remark,
body.cert_handler_user_id,
body.insurance_handler_user_id,
)
)
if split:
storage_ht = (
parse_datetime(body.storage_handle_time)
if (body.storage_handle_time or "").strip()
else None
)
storage_handler = body.storage_handler_user_id
storage_atts = body.storage_attachments or ""
storage_remark = body.storage_remark or ""
cert_ht = (
parse_datetime(body.cert_handle_time)
if (body.cert_handle_time or "").strip()
else None
)
ins_ht = (
parse_datetime(body.insurance_handle_time)
if (body.insurance_handle_time or "").strip()
else None
)
cert_handler = body.cert_handler_user_id
ins_handler = body.insurance_handler_user_id
cert_atts = body.cert_attachments or ""
ins_atts = body.insurance_attachments or ""
cert_remark = body.cert_remark or ""
ins_remark = body.insurance_remark or ""
else:
storage_ht = None
storage_handler = None
storage_atts = ""
storage_remark = ""
cert_ht = ins_ht = ht
cert_handler = ins_handler = body.handler_user_id
cert_atts = ins_atts = attachments
cert_remark = ins_remark = remark
storage_side = bool(
storage_ht is not None
or (storage_atts or "").strip()
or (storage_remark or "").strip()
)
cert_side = bool(
cert_ht is not None
or update_mail
or (cert_atts or "").strip()
or (cert_remark or "").strip()
)
ins_side = bool(
ins_ht is not None
or update_policy
or (ins_atts or "").strip()
or (ins_remark or "").strip()
)
if storage_side and storage_ht is None:
return error_response(400, "请填写入库办理时间")
if cert_side and cert_ht is None:
return error_response(400, "请填写储存证办理时间")
if ins_side and ins_ht is None:
return error_response(400, "请填写保险办理时间")
wrote_any = False
success = 0
fail = 0
if storage_ht is not None:
storage_r = quick_svc.batch_quick_register_storage(
db,
items=items,
reception_source=source,
tenant_no=tenant_no,
user_id=user_id,
handle_time=storage_ht,
handler_user_id=storage_handler,
attachments=storage_atts,
remark=storage_remark,
do_commit=False,
)
success = max(success, storage_r.get("success", 0))
fail = max(fail, storage_r.get("fail", 0))
wrote_any = True
if cert_ht is not None:
cert_r = quick_svc.batch_handle_storage_certs(
db,
items=items,
tenant_no=tenant_no,
user_id=user_id,
handle_time=cert_ht,
handler_user_id=cert_handler,
attachments=cert_atts,
remark=cert_remark,
mail_cert_time=mail_t,
update_mail_cert_time=update_mail,
do_commit=False,
)
success = max(success, cert_r.get("success", 0))
fail = max(fail, cert_r.get("fail", 0))
wrote_any = True
if ins_ht is not None:
ins_r = quick_svc.batch_handle_insurance(
db,
items=items,
tenant_no=tenant_no,
user_id=user_id,
handle_time=ins_ht,
handler_user_id=ins_handler,
attachments=ins_atts,
remark=ins_remark,
policy_send_time=policy_t,
update_policy_send_time=update_policy,
do_commit=False,
)
success = max(success, ins_r.get("success", 0))
fail = max(fail, ins_r.get("fail", 0))
wrote_any = True
if has_any_customer_service_payload(cs_incoming):
cs_r = quick_svc.batch_register_customer_service(
db,
items=items,
tenant_no=tenant_no,
user_id=user_id,
incoming=cs_incoming,
do_commit=False,
)
success = max(success, cs_r.get("success", 0))
fail = max(fail, cs_r.get("fail", 0))
wrote_any = True
if not wrote_any:
return error_response(400, "请至少填写一项登记信息")
db.commit()
result = {"success": success, "fail": fail}
else:
return error_response(400, "登记类型无效")
return success_response(result, message="登记完成")
except PermissionError as e:
db.rollback()
return error_response(403, str(e))
except ValueError as e:
db.rollback()
return error_response(400, str(e))
except Exception as e:
db.rollback()
return error_response(400, str(e))
+646
View File
@@ -0,0 +1,646 @@
"""按样本缴费方案 / 转账笔财务确认 API(payment_model_version=2)"""
from __future__ import annotations
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 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 protocol_sample_payment as psp_svc
from app.models.models import BizCollectionProtocol, 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)
def _protocol_accessible(db: Session, protocol_id: str, tenant_no: str, user: dict) -> bool:
from app.core.data_scope import apply_data_scope
q = db.query(BizCollectionProtocol).filter(
BizCollectionProtocol.id == protocol_id,
BizCollectionProtocol.del_flag == 0,
BizCollectionProtocol.tenant_no == tenant_no,
)
q = apply_data_scope(q, BizCollectionProtocol, user, db, tenant_no)
return q.first() is not None
class ScheduleBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
id: Optional[str] = None
schedule_no: Optional[int] = Field(None, alias="scheduleNo")
due_amount: Optional[float] = Field(None, alias="dueAmount")
due_date: Optional[str] = Field(None, alias="dueDate")
schedule_status: Optional[str] = Field(None, alias="scheduleStatus")
remark: Optional[str] = None
transfers: Optional[List[Dict[str, Any]]] = None
class SamplePaymentBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
id: Optional[str] = None
sample_code: str = Field(..., alias="sampleCode")
protocol_sample_id: Optional[str] = Field(None, alias="protocolSampleId")
pay_mode: str = Field(..., alias="payMode")
contract_amount: Optional[float] = Field(None, alias="contractAmount")
installment_count: Optional[int] = Field(None, alias="installmentCount")
installment_interval_months: Optional[float] = Field(
None, alias="installmentIntervalMonths"
)
installment_start_date: Optional[str] = Field(None, alias="installmentStartDate")
installment_advance_days: Optional[int] = Field(None, alias="installmentAdvanceDays")
installment_ratios: Optional[Any] = Field(None, alias="installmentRatios")
lump_sum_deadline: Optional[str] = Field(None, alias="lumpSumDeadline")
remark: Optional[str] = None
schedules: Optional[List[ScheduleBody]] = None
class SamplePaymentsPutBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
sample_payments: List[SamplePaymentBody] = Field(
default_factory=list, alias="samplePayments"
)
class TransferCreateBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
schedule_id: str = Field(..., alias="scheduleId")
transfer_amount: Optional[float] = Field(None, alias="transferAmount")
transfer_time: Optional[str] = Field(None, alias="transferTime")
transfer_type: Optional[str] = Field(None, alias="transferType")
transfer_attachments: Optional[str] = Field(None, alias="transferAttachments")
remark: Optional[str] = None
class TransferDraftBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
transfer_amount: Optional[float] = Field(None, alias="transferAmount")
transfer_time: Optional[str] = Field(None, alias="transferTime")
transfer_type: Optional[str] = Field(None, alias="transferType")
transfer_attachments: Optional[str] = Field(None, alias="transferAttachments")
remark: Optional[str] = None
class FinanceConfirmBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
finance_confirm_attachments: Optional[str] = Field(
None, alias="financeConfirmAttachments"
)
finance_remark: Optional[str] = Field(None, alias="financeRemark")
def _ratios_to_str(ratios: Any) -> Optional[str]:
if ratios is None:
return None
if isinstance(ratios, str):
return ratios.strip() or None
import json
try:
return json.dumps(ratios, ensure_ascii=False)
except Exception:
return str(ratios)
def _save_sample_payments(
db: Session,
*,
protocol_id: str,
tenant_no: str,
user_id: str,
payments: List[SamplePaymentBody],
) -> List[Dict[str, Any]]:
for item in payments:
prev = psp_svc.find_active_sample_payment(
db,
tenant_no=tenant_no,
sample_payment_id=item.id,
collection_protocol_id=protocol_id,
sample_code=item.sample_code,
)
prev_mode = (prev.pay_mode or "").strip() if prev else ""
sp = psp_svc.upsert_sample_payment(
db,
collection_protocol_id=protocol_id,
tenant_no=tenant_no,
user_id=user_id,
sample_code=item.sample_code,
pay_mode=item.pay_mode,
contract_amount=item.contract_amount,
protocol_sample_id=item.protocol_sample_id,
installment_count=item.installment_count,
installment_interval_months=item.installment_interval_months,
installment_start_date=item.installment_start_date,
installment_advance_days=item.installment_advance_days,
installment_ratios=_ratios_to_str(item.installment_ratios),
lump_sum_deadline=item.lump_sum_deadline,
remark=item.remark,
sample_payment_id=item.id,
)
next_mode = (item.pay_mode or "").strip()
if (
prev_mode == "fq"
and next_mode in ("fgd", "dj")
and psp_svc.sample_payment_has_yjf(
db, sample_payment_id=sp.id, tenant_no=tenant_no
)
):
psp_svc.apply_fq_to_fgd_merge(
db,
sample_payment_id=sp.id,
tenant_no=tenant_no,
user_id=user_id,
target_pay_mode=next_mode,
)
continue
if item.schedules is not None:
schedule_payloads = [s.model_dump(by_alias=False) for s in item.schedules]
psp_svc.sync_schedules_and_transfers(
db,
sample_payment_id=sp.id,
tenant_no=tenant_no,
user_id=user_id,
schedules=schedule_payloads,
)
db.commit()
return psp_svc.get_sample_payments_bundle(db, protocol_id, tenant_no)
@router.get("/payment-chase")
async def list_payment_chase(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
chaseType: Optional[str] = Query(None, alias="chaseType"),
payMode: Optional[str] = Query(None, alias="payMode"),
salesUserId: Optional[str] = Query(None, alias="salesUserId"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
"""催缴信息:逾期未清 ∪ 未来 30 天内应缴未清(一行一期)。"""
try:
tenant_no = _get_tenant_no(db, user)
items, total = psp_svc.list_payment_chase_page(
db,
tenant_no=tenant_no,
keyword=keyword,
chase_type=chaseType,
pay_mode=payMode,
sales_user_id=salesUserId,
page=page,
page_size=page_size,
user_info=user,
)
return success_response({"list": items, "total": total, "page": page, "pageSize": page_size})
except Exception as e:
return error_response(500, str(e))
@router.get("/sample-payments")
async def list_sample_payments(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
payMode: Optional[str] = Query(None, alias="payMode"),
progressStatus: Optional[str] = Query(None, alias="progressStatus"),
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
"""按样本缴费方案分页列表(独立缴费信息菜单)。"""
try:
tenant_no = _get_tenant_no(db, user)
items, total = psp_svc.list_sample_payments_page(
db,
tenant_no=tenant_no,
keyword=keyword,
pay_mode=payMode,
progress_status=progressStatus,
due_date_start=installmentDueDateStart,
due_date_end=installmentDueDateEnd,
page=page,
page_size=page_size,
user_info=user,
)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": page_size}
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.get("/sample-payments/{sample_payment_id}")
async def get_sample_payment(
sample_payment_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
data = psp_svc.get_sample_payment_detail(
db,
sample_payment_id=sample_payment_id,
tenant_no=tenant_no,
user_info=user,
)
if not data:
return error_response(403, "无权查看或记录不存在")
return success_response(data)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.get("/collection-protocols/{protocol_id}/sample-payments")
async def get_sample_payments(
protocol_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(404, "协议单不存在或无权查看")
data = psp_svc.get_sample_payments_bundle(db, protocol_id, tenant_no)
return success_response(data)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.put("/collection-protocols/{protocol_id}/sample-payments")
async def put_sample_payments(
protocol_id: str,
body: SamplePaymentsPutBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
if not _protocol_accessible(db, protocol_id, tenant_no, user):
return error_response(404, "协议单不存在或无权操作")
# pydantic v2: may need model_validate with populate
payments = body.sample_payments
if hasattr(body, "model_dump"):
raw = body.model_dump(by_alias=True)
payments = [
SamplePaymentBody.model_validate(x)
for x in (raw.get("samplePayments") or raw.get("sample_payments") or [])
]
data = _save_sample_payments(
db,
protocol_id=protocol_id,
tenant_no=tenant_no,
user_id=user_id,
payments=payments or body.sample_payments,
)
return success_response(data, message="保存成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/payment-transfers")
async def create_payment_transfer(
body: TransferCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
sch = psp_svc._get_schedule_or_raise(
db, schedule_id=body.schedule_id, tenant_no=tenant_no
)
if not _protocol_accessible(db, sch.collection_protocol_id, tenant_no, user):
return error_response(403, "无权操作该协议")
obj = psp_svc.add_transfer(
db,
schedule_id=body.schedule_id,
tenant_no=tenant_no,
user_id=user_id,
transfer_amount=body.transfer_amount,
transfer_time=body.transfer_time,
transfer_type=body.transfer_type,
transfer_attachments=body.transfer_attachments,
remark=body.remark,
)
from app.crud.protocol_sample_payment import _transfer_to_dict
return success_response(
_transfer_to_dict(db, obj, tenant_no), message="创建成功"
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.put("/payment-transfers/{transfer_id}")
async def update_payment_transfer(
transfer_id: str,
body: TransferDraftBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
fields = body.model_dump(by_alias=False, exclude_unset=True)
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_info=user,
)
obj = psp_svc.update_transfer_draft(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_id=user_id,
fields=fields,
locked_transfer=locked_transfer,
)
from app.crud.protocol_sample_payment import _transfer_to_dict
return success_response(_transfer_to_dict(db, obj, tenant_no), message="保存成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/payment-transfers/{transfer_id}/submit-receipt-finance")
async def submit_payment_transfer_finance(
transfer_id: str,
body: Optional[TransferDraftBody] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
draft = body.model_dump(by_alias=True, exclude_unset=True) if body else None
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_info=user,
)
obj = psp_svc.submit_receipt_finance(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_id=user_id,
draft_fields=draft,
locked_transfer=locked_transfer,
)
from app.crud.protocol_sample_payment import _transfer_to_dict
return success_response(_transfer_to_dict(db, obj, tenant_no), message="已提交财务确认")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/payment-transfers/{transfer_id}/withdraw")
async def withdraw_payment_transfer(
transfer_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_info=user,
)
obj = psp_svc.withdraw_receipt(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_id=user_id,
locked_transfer=locked_transfer,
)
from app.crud.protocol_sample_payment import _transfer_to_dict
return success_response(_transfer_to_dict(db, obj, tenant_no), message="已撤回")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.delete("/payment-transfers/{transfer_id}")
async def delete_payment_transfer(
transfer_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
obj = psp_svc._get_transfer_or_raise(
db, transfer_id=transfer_id, tenant_no=tenant_no
)
if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user):
return error_response(403, "无权操作该协议")
psp_svc.soft_delete_payment_transfer(
db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id
)
return success_response(None, message="删除成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.get("/payment-transfers")
async def list_payment_transfers(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
payStatus: str = Query("dfj", alias="payStatus"),
keyword: Optional[str] = None,
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
"""按转账笔状态分页(缴费信息看板 / 财务共用)。"""
try:
tenant_no = _get_tenant_no(db, user)
items, total = psp_svc.list_transfers_for_finance(
db,
pay_status=payStatus,
tenant_no=tenant_no,
keyword=keyword,
page=page,
page_size=page_size,
user_info=user,
due_date_start=installmentDueDateStart,
due_date_end=installmentDueDateEnd,
)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": page_size}
)
except PermissionError as e:
return error_response(403, str(e))
@router.get("/payment-transfers/receipt-confirm")
async def list_receipt_confirm(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items, total = psp_svc.list_transfers_for_finance(
db,
pay_status="jfqrz",
tenant_no=tenant_no,
keyword=keyword,
page=page,
page_size=page_size,
user_info=user,
due_date_start=installmentDueDateStart,
due_date_end=installmentDueDateEnd,
)
return success_response({"list": items, "total": total, "page": page, "pageSize": page_size})
except PermissionError as e:
return error_response(403, str(e))
@router.get("/payment-transfers/receipt-confirmed")
async def list_receipt_confirmed(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
installmentDueDateStart: Optional[str] = Query(None, alias="installmentDueDateStart"),
installmentDueDateEnd: Optional[str] = Query(None, alias="installmentDueDateEnd"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items, total = psp_svc.list_transfers_for_finance(
db,
pay_status="yjf",
tenant_no=tenant_no,
keyword=keyword,
page=page,
page_size=page_size,
user_info=user,
due_date_start=installmentDueDateStart,
due_date_end=installmentDueDateEnd,
)
return success_response({"list": items, "total": total, "page": page, "pageSize": page_size})
except PermissionError as e:
return error_response(403, str(e))
@router.post("/payment-transfers/{transfer_id}/receipt-confirm")
async def confirm_payment_transfer(
transfer_id: str,
body: Optional[FinanceConfirmBody] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_info=user,
)
obj = psp_svc.confirm_receipt(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_id=user_id,
finance_confirm_attachments=body.finance_confirm_attachments if body else None,
finance_remark=body.finance_remark if body else None,
locked_transfer=locked_transfer,
)
from app.crud.protocol_sample_payment import _transfer_to_dict
return success_response(_transfer_to_dict(db, obj, tenant_no), message="确认收款成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/payment-transfers/{transfer_id}/receipt-reject")
async def reject_payment_transfer(
transfer_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_info=user,
)
obj = psp_svc.reject_receipt(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
user_id=user_id,
locked_transfer=locked_transfer,
)
from app.crud.protocol_sample_payment import _transfer_to_dict
return success_response(_transfer_to_dict(db, obj, tenant_no), message="已退回")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
+96 -39
View File
@@ -1,8 +1,10 @@
"""公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线)"""
import base64
import os
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session
@@ -37,38 +39,13 @@ from app.services.web_auth_roles import (
)
from app.schemas.auth import ProfileUpdateRequest
from app.crud.user import update_user, change_password
from app.services.issue_user_tokens import build_user_info_payload
from app.services import wx_qr_session
from app.services.wechat_mini_auth import get_unlimited_wxacode, WechatMiniAuthError
router = APIRouter(prefix="", tags=["公共接口"])
def _avatar_url_field(user: SysUser) -> Optional[str]:
"""返回库中原始头像路径,由前端按 VITE_MINIO_BASE_URL 拼展示 URL。"""
raw = getattr(user, "avatar", None)
if not raw:
return None
s = str(raw).strip()
return s if s else None
def _build_user_info_payload(user: SysUser, web_role: str, role_names: list = None) -> dict:
"""与 web-admin `userInfo` 字段约定一致。"""
phone = (getattr(user, "phone_no", None) or "").strip() or None
return {
"id": user.id,
"username": user.user_no,
"realName": user.nick_name,
"phone": phone,
"email": (getattr(user, "email", None) or "").strip() or None,
"sex": getattr(user, "sex", None) or "0",
"remark": (getattr(user, "remark", None) or "").strip() or None,
"role": web_role,
"roleNames": role_names or [],
"regionId": user.dept_id,
"regionName": None,
"avatarUrl": _avatar_url_field(user),
}
def _build_current_user_payload(current_user: dict) -> dict:
"""组装 current-user 接口响应。"""
au = current_user.get("avatar_url")
@@ -85,7 +62,9 @@ def _build_current_user_payload(current_user: dict) -> dict:
"roleNames": current_user.get("role_names") or current_user.get("roleNames") or [],
"isSuperAdmin": current_user.get("role") == "super_admin",
"regionId": current_user.get("region_id"),
"regionName": None,
"regionName": current_user.get("region_name") or current_user.get("dept_name"),
"deptName": current_user.get("dept_name") or current_user.get("region_name"),
"postNames": current_user.get("post_names") or [],
"regionIds": [],
"isActive": current_user.get("is_active"),
"avatarUrl": avatar_url if avatar_url else None,
@@ -141,11 +120,14 @@ async def web_login(
if user.status != "1":
return error_response(403, "账号已被停用,请联系管理员")
if not user_has_active_roles(db, user.id):
tenant_no = (getattr(user, "tenant_no", None) or "").strip() or None
if not user_has_active_roles(db, user.id, tenant_no=tenant_no):
return error_response(403, "账号未分配角色,请联系管理员")
web_role = resolve_web_role_for_user(db, user.id)
role_names = get_user_role_names(db, user.id)
web_role = resolve_web_role_for_user(
db, user.id, tenant_no=tenant_no
)
role_names = get_user_role_names(db, user.id, tenant_no=tenant_no)
session_id = generate_uuid()
access_jti = generate_uuid()
@@ -174,11 +156,54 @@ async def web_login(
"token": access_token,
"refreshToken": refresh_token,
"expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
"userInfo": _build_user_info_payload(user, web_role, role_names),
"userInfo": build_user_info_payload(user, web_role, role_names, db=db),
}
)
@router.post("/web/auth/wx-qr/create", response_model=dict)
async def wx_qr_create():
session_id, short = wx_qr_session.create_session()
page = (settings.WX_QR_PAGE or "pages/login/web-qr/index").strip()
try:
raw = get_unlimited_wxacode(
scene=short,
page=page,
env_version=settings.WX_QR_ENV_VERSION,
)
except WechatMiniAuthError as exc:
wx_qr_session.invalidate(session_id)
return error_response(400, str(exc))
b64 = base64.b64encode(raw).decode("ascii")
mime = "image/jpeg" if raw[:2] == b"\xff\xd8" else "image/png"
return success_response(
{
"sessionId": session_id,
"expiresIn": settings.WX_QR_SESSION_TTL_SEC,
"qrImageBase64": f"data:{mime};base64,{b64}",
}
)
@router.get("/web/auth/wx-qr/status", response_model=dict)
async def wx_qr_status(sessionId: str = ""):
sid = (sessionId or "").strip()
data = wx_qr_session.get_session(sid)
if not data:
return success_response({"status": "expired", "message": "二维码已过期,请刷新"})
status = data.get("status") or "expired"
if status == "success":
tokens = wx_qr_session.consume_success(sid)
if not tokens:
return success_response({"status": "expired", "message": "登录凭证已失效,请刷新二维码"})
return success_response({"status": "success", **tokens})
if status == "failed":
return success_response({"status": "failed", "message": data.get("message") or "登录失败"})
if status in ("expired", "consumed"):
return success_response({"status": "expired", "message": "二维码已过期,请刷新"})
return success_response({"status": status})
@router.post("/web/auth/refresh-token", response_model=dict)
async def refresh_token(body: dict, db: Session = Depends(get_db)):
"""刷新 Token"""
@@ -205,7 +230,10 @@ async def refresh_token(body: dict, db: Session = Depends(get_db)):
if not user or user.del_flag != 0 or user.status != "1":
return error_response(401, "用户不存在或已禁用")
web_role = resolve_web_role_for_user(db, user.id)
tenant_no = (getattr(user, "tenant_no", None) or "").strip() or None
web_role = resolve_web_role_for_user(
db, user.id, tenant_no=tenant_no
)
await revoke_refresh_token(refresh_jti=refresh_jti)
await extend_session(session_id=session_id)
@@ -298,9 +326,14 @@ async def update_current_user_profile(
user = user_crud.get(db, user_id)
if not user:
return error_response(404, "用户不存在")
web_role = resolve_web_role_for_user(db, user.id)
role_names = get_user_role_names(db, user.id)
return success_response(_build_user_info_payload(user, web_role, role_names))
tenant_no = (getattr(user, "tenant_no", None) or "").strip() or None
web_role = resolve_web_role_for_user(
db, user.id, tenant_no=tenant_no
)
role_names = get_user_role_names(
db, user.id, tenant_no=tenant_no
)
return success_response(build_user_info_payload(user, web_role, role_names, db=db))
except HTTPException:
return error_response(401, "Token无效或已过期")
except ValueError as exc:
@@ -397,7 +430,11 @@ async def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db
)
try:
codes = iter_user_menu_perm_codes(db, current_user.get("user_id") or "")
codes = iter_user_menu_perm_codes(
db,
current_user.get("user_id") or "",
tenant_no=current_user.get("tenant_no"),
)
menu_codes = sorted(codes)
except OperationalError:
menu_codes = []
@@ -411,3 +448,23 @@ async def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db
)
except HTTPException:
return error_response(401, "Token无效或已过期")
@router.get("/cn-holiday/range", response_model=dict)
def cn_holiday_range(
start: str = Query(..., description="YYYY-MM-DD"),
end: str = Query(..., description="YYYY-MM-DD"),
db: Session = Depends(get_db),
):
"""查询闭区间内每日节假日/休息日标志(只读本地缓存表)。"""
try:
start_d = datetime.strptime(start.strip(), "%Y-%m-%d").date()
end_d = datetime.strptime(end.strip(), "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="start/end 须为 YYYY-MM-DD")
if end_d < start_d:
raise HTTPException(status_code=400, detail="end 不能早于 start")
from app.services.cn_holiday import list_day_flags
days = list_day_flags(db, start_d, end_d)
return success_response({"days": days})
+397
View File
@@ -25,6 +25,7 @@ QC_THIRD_PARTY_FEE_FIELD_NAMES = ("feeAmount", "thirdPartyFeeTotal")
from app.core.response import error_response, success_response
from app.core.utils import parse_datetime
from app.crud import qc_lab_test_item as lab_svc
from app.crud import qc_inspection_item_template as iit_svc
from app.crud import qc_item_param as param_svc
from app.crud import qc_task_result as result_svc
from app.crud import qc_task as task_svc
@@ -35,6 +36,8 @@ from app.crud import qc_material_stock_batch as batch_svc
from app.crud import qc_material_stock_check as stock_check_svc
from app.crud import qc_material_usage as mu_svc
from app.crud import qc_material_amount_auth as amount_auth_svc
from app.crud import qc_material_month_template as month_tpl_svc
from app.crud.qc_material_stock_batch import _parse_date
from app.core.qc_material_amount_auth_constants import QC_MATERIAL_AMOUNT_AUTH_LIST_PERM
from app.crud import inbound_sample_special as inbound_sample_special_svc
from app.crud import qc_work_order as wo_svc
@@ -398,6 +401,31 @@ class LabTestItemUpdateBody(BaseModel):
remark: Optional[str] = None
# ---------- 检测项目模版(项目组合) ----------
class InspectionItemTemplateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
template_name: str = Field(..., alias="templateName")
lab_item_ids: List[str] = Field(default_factory=list, alias="labItemIds")
third_party_item_ids: List[str] = Field(default_factory=list, alias="thirdPartyItemIds")
status: Optional[str] = "1"
order_num: Optional[int] = Field(None, alias="orderNum")
remark: Optional[str] = None
class InspectionItemTemplateUpdateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
template_name: Optional[str] = Field(None, alias="templateName")
lab_item_ids: Optional[List[str]] = Field(None, alias="labItemIds")
third_party_item_ids: Optional[List[str]] = Field(None, alias="thirdPartyItemIds")
status: Optional[str] = None
order_num: Optional[int] = Field(None, alias="orderNum")
remark: Optional[str] = None
# ---------- 检测项目参数模板 ----------
@@ -533,6 +561,148 @@ async def delete_qc_lab_test_item(
return error_response(400, str(e))
@router.get("/qc-inspection-item-templates")
async def list_qc_inspection_item_templates(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100),
keyword: Optional[str] = None,
status: Optional[str] = None,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcInspectionItemTemplate:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
items, total = iit_svc.list_templates(
db,
page=page,
page_size=pageSize,
keyword=keyword,
status=status,
tenant_no=tenant_no,
user_info=user,
)
return success_response(_page_payload(items, total, page, pageSize))
except PermissionError as e:
return error_response(403, str(e))
except Exception as e:
return error_response(400, str(e))
@router.get("/qc-inspection-item-templates/active")
async def list_qc_inspection_item_templates_active(
db: Session = Depends(get_db),
user: dict = Depends(
require_any_menu_code(
"biz:qcInspectionItemTemplate:list", "biz:qcWorkOrder:list"
)
),
):
try:
tenant_no = _get_tenant_no(db, user)
return success_response(
iit_svc.list_active_templates(db, tenant_no, user_info=user)
)
except PermissionError as e:
return error_response(403, str(e))
except Exception as e:
return error_response(400, str(e))
@router.get("/qc-inspection-item-templates/{template_id}")
async def get_qc_inspection_item_template(
template_id: str,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcInspectionItemTemplate:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
obj = iit_svc.get_template(db, template_id, tenant_no=tenant_no)
if not obj:
return error_response(404, "模版不存在")
return success_response(iit_svc._row_to_dict(obj))
except PermissionError as e:
return error_response(403, str(e))
except Exception as e:
return error_response(400, str(e))
@router.post("/qc-inspection-item-templates")
async def create_qc_inspection_item_template(
body: InspectionItemTemplateBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcInspectionItemTemplate:add")),
):
try:
tenant_no = _get_tenant_no(db, user)
obj = iit_svc.create_template(
db,
template_name=body.template_name,
lab_item_ids=body.lab_item_ids,
third_party_item_ids=body.third_party_item_ids,
status=body.status or "1",
tenant_no=tenant_no,
order_num=body.order_num,
remark=body.remark,
user_id=_get_current_user_id(user),
create_dept_id=user.get("dept_id", "") or "",
)
return success_response(iit_svc._row_to_dict(obj), message="创建成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(400, str(e))
@router.put("/qc-inspection-item-templates/{template_id}")
async def update_qc_inspection_item_template(
template_id: str,
body: InspectionItemTemplateUpdateBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcInspectionItemTemplate:edit")),
):
try:
tenant_no = _get_tenant_no(db, user)
obj = iit_svc.update_template(
db,
template_id,
template_name=body.template_name,
lab_item_ids=body.lab_item_ids,
third_party_item_ids=body.third_party_item_ids,
status=body.status,
order_num=body.order_num,
remark=body.remark,
user_id=_get_current_user_id(user),
tenant_no=tenant_no,
)
return success_response(iit_svc._row_to_dict(obj), message="更新成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(400, str(e))
@router.delete("/qc-inspection-item-templates/{template_id}")
async def delete_qc_inspection_item_template(
template_id: str,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcInspectionItemTemplate:remove")),
):
try:
tenant_no = _get_tenant_no(db, user)
iit_svc.soft_delete_template(db, template_id, tenant_no=tenant_no)
return success_response(None, message="删除成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(400, str(e))
# ---------- 检测项目参数模板 ----------
@@ -617,6 +787,7 @@ async def create_qc_work_order(
user_id=user.get("user_id"),
create_dept_id=user.get("dept_id", ""),
cell_product_id=body.cell_product_id or "",
external_target_code=body.external_target_code or "",
remark=body.remark or "",
)
else:
@@ -655,6 +826,9 @@ async def list_qc_work_orders(
infusionTimeEnd: Optional[str] = Query(None, alias="infusionTimeEnd"),
triggerSource: Optional[str] = Query(None, alias="triggerSource"),
sampleTypeId: Optional[str] = Query(None, alias="sampleTypeId"),
protocolStatus: Optional[str] = Query(
None, alias="protocolStatus", description="协议/捐赠状态,多个用英文逗号分隔"
),
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcWorkOrder:list")),
):
@@ -671,6 +845,7 @@ async def list_qc_work_orders(
reception_source=receptionSource,
trigger_source=triggerSource,
sample_type_id=sampleTypeId,
protocol_status=protocolStatus,
create_time_start=createTimeStart,
create_time_end=createTimeEnd,
infusion_time_start=infusionTimeStart,
@@ -799,7 +974,9 @@ async def update_qc_work_order(
tenant_no=tenant_no,
user_id=_get_current_user_id(user),
cell_product_id=body.cell_product_id,
external_target_code=body.external_target_code,
work_order_status=body.work_order_status,
qc_conclusion=body.qc_conclusion,
remark=body.remark,
)
db.commit()
@@ -1394,6 +1571,55 @@ class BatchStockInBody(BaseModel):
stock_in_time: Optional[str] = Field(None, alias="stockInTime")
class QcMonthTemplateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
material_id: Optional[str] = Field(None, alias="materialId")
material_code: Optional[str] = Field(None, alias="materialCode")
material_name: Optional[str] = Field(None, alias="materialName")
spec_model: Optional[str] = Field(None, alias="specModel")
unit: Optional[str] = None
catalog_no: Optional[str] = Field(None, alias="catalogNo")
batch_no: Optional[str] = Field(None, alias="batchNo")
manufacturer: Optional[str] = None
unit_price: Optional[float] = Field(None, alias="unitPrice")
amount: Optional[float] = None
in_qty: Optional[float] = Field(None, alias="inQty")
expire_date: Optional[str] = Field(None, alias="expireDate")
order_num: Optional[int] = Field(None, alias="orderNum")
remark: Optional[str] = None
class QcMonthTemplatePreviewBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
stock_in_date: str = Field(..., alias="stockInDate")
class QcMonthTemplateConfirmItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
material_id: Optional[str] = Field(None, alias="materialId")
material_code: str = Field(..., alias="materialCode")
material_name: str = Field(..., alias="materialName")
spec_model: Optional[str] = Field(None, alias="specModel")
unit: Optional[str] = None
stock_in_no: str = Field(..., alias="stockInNo")
expire_date: str = Field(..., alias="expireDate")
catalog_no: str = Field(..., alias="catalogNo")
batch_no: str = Field(..., alias="batchNo")
manufacturer: str
unit_price: float = Field(..., alias="unitPrice")
amount: float
qty: float = Field(..., gt=0)
remark: Optional[str] = None
class QcMonthTemplateConfirmBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
stock_in_date: str = Field(..., alias="stockInDate")
items: List[QcMonthTemplateConfirmItem] = Field(..., min_length=1)
remark: Optional[str] = None
stock_in_time: Optional[str] = Field(None, alias="stockInTime")
class BatchStockOutItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
batch_id: str = Field(..., alias="batchId")
@@ -1455,6 +1681,7 @@ async def list_qc_material_batches(
keyword: Optional[str] = None,
materialId: Optional[str] = None,
status: Optional[str] = None,
stockStatus: Optional[str] = None,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
@@ -1467,6 +1694,7 @@ async def list_qc_material_batches(
keyword=keyword,
material_id=materialId,
status=status,
stock_status=stockStatus,
tenant_no=tenant_no,
user_info=user,
)
@@ -1887,6 +2115,174 @@ async def batch_discard_qc_material_batches(
return await batch_discard_qc_material(body, db, user)
# ---------- 月初入库模板 ----------
@router.get("/qc-material-month-templates")
async def list_qc_month_templates(
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
rows = month_tpl_svc.list_templates(db, tenant_no=tenant_no)
return success_response(rows)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(400, str(e))
@router.post("/qc-material-month-templates")
async def create_qc_month_template(
body: QcMonthTemplateBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
unit_price, amount = _resolve_stock_in_amounts(
db, user, body.unit_price, body.amount
)
data = body.model_dump(by_alias=False)
data["unit_price"] = unit_price if unit_price is not None else 0
data["amount"] = amount if amount is not None else 0
row = month_tpl_svc.create_template(
db, data=data, tenant_no=tenant_no, user_id=user_id
)
return success_response(row, message="新增成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(400, str(e))
@router.put("/qc-material-month-templates/{row_id}")
async def update_qc_month_template(
row_id: str,
body: QcMonthTemplateBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
unit_price, amount = _resolve_stock_in_amounts(
db, user, body.unit_price, body.amount
)
data = body.model_dump(by_alias=False)
data["unit_price"] = unit_price if unit_price is not None else 0
data["amount"] = amount if amount is not None else 0
row = month_tpl_svc.update_template(
db, row_id, data=data, tenant_no=tenant_no, user_id=user_id
)
return success_response(row, message="更新成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(400, str(e))
@router.delete("/qc-material-month-templates/{row_id}")
async def delete_qc_month_template(
row_id: str,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
month_tpl_svc.soft_delete_template(db, row_id, tenant_no=tenant_no)
return success_response(None, message="删除成功")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(400, str(e))
@router.post("/qc-material-month-templates/preview")
async def preview_qc_month_templates(
body: QcMonthTemplatePreviewBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
stock_in_date = _parse_date(body.stock_in_date)
rows = month_tpl_svc.preview_templates(
db, tenant_no=tenant_no, stock_in_date=stock_in_date
)
return success_response(rows)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(400, str(e))
@router.post("/qc-material-month-templates/confirm")
async def confirm_qc_month_templates(
body: QcMonthTemplateConfirmBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
operate_time = datetime.now()
if body.stock_in_time:
rt = parse_datetime(body.stock_in_time)
if not rt:
return error_response(400, "入库时间格式无效")
operate_time = rt
stock_in_date = _parse_date(body.stock_in_date)
items = []
for x in body.items:
unit_price, amount = _resolve_stock_in_amounts(
db, user, x.unit_price, x.amount
)
items.append(
{
"material_id": x.material_id,
"materialId": x.material_id,
"material_code": x.material_code,
"materialCode": x.material_code,
"material_name": x.material_name,
"materialName": x.material_name,
"spec_model": x.spec_model,
"specModel": x.spec_model,
"unit": x.unit,
"stock_in_no": x.stock_in_no,
"stockInNo": x.stock_in_no,
"expire_date": x.expire_date,
"expireDate": x.expire_date,
"catalog_no": x.catalog_no,
"catalogNo": x.catalog_no,
"batch_no": x.batch_no,
"batchNo": x.batch_no,
"manufacturer": x.manufacturer,
"unit_price": unit_price,
"unitPrice": unit_price,
"amount": amount,
"qty": x.qty,
"remark": x.remark,
}
)
count = month_tpl_svc.confirm_month_import(
db,
tenant_no=tenant_no,
user_id=user_id,
stock_in_date=stock_in_date,
items=items,
remark=body.remark,
operate_time=operate_time,
)
return success_response({"count": count}, message=f"月初导入成功,共 {count} 项")
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(400, str(e))
# ---------- 物料变动记录 ----------
@@ -2056,6 +2452,7 @@ class StockCheckCreateBody(BaseModel):
class StockCheckUnusedLineItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
usage_id: Optional[str] = Field(None, alias="usageId")
batch_id: Optional[str] = Field(None, alias="batchId")
material_code: Optional[str] = Field(None, alias="materialCode")
+71
View File
@@ -11,6 +11,12 @@ from app.core.response import error_response, success_response
from app.crud import research_donation as rd_svc
from app.models.models import SysUser
from app.models.research_donation import BizResearchDonation
from app.services.sample_collect_wx_notify import (
PROTOCOL_KIND_DONATION,
sample_codes_from_orm_rows,
sample_codes_from_payload,
schedule_sample_collect_notify,
)
router = APIRouter(prefix="/web/admin", tags=["科研捐赠管理"])
@@ -119,6 +125,17 @@ class ConvertFromStorageBody(BaseModel):
protocol_id: str = Field(..., alias="protocolId")
def assert_donation_sensitive_fields(
db: Session,
user: dict,
body: BaseModel,
*,
creating: bool,
) -> None:
"""保留钩子;不再校验菜单权限(与采集协议敏感字段策略对齐)。"""
_ = (db, user, body, creating)
# ---------------------------------------------------------------------------
# 1. 列表
# ---------------------------------------------------------------------------
@@ -229,6 +246,7 @@ async def create_research_donation(
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
assert_donation_sensitive_fields(db, user, body, creating=True)
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
try:
@@ -242,6 +260,17 @@ async def create_research_donation(
tenant_no=tenant_no,
user_id=user_id,
)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=obj.id,
protocol_kind=PROTOCOL_KIND_DONATION,
before_codes=[],
submitted_codes=(
sample_codes_from_payload(body.samples)
if body.samples is not None
else None
),
)
return success_response(
rd_svc._donation_to_dict(obj, db, tenant_no),
message="创建成功",
@@ -262,10 +291,15 @@ async def update_research_donation(
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
assert_donation_sensitive_fields(db, user, body, creating=False)
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
try:
data = body.model_dump(exclude_unset=True, by_alias=True)
before_codes = sample_codes_from_orm_rows(
rd_svc.list_samples(db, donation_id, tenant_no)
)
unset_fields = body.model_dump(exclude_unset=True)
obj = rd_svc.update_donation(
db,
donation_id,
@@ -273,6 +307,17 @@ async def update_research_donation(
tenant_no=tenant_no,
user_id=user_id,
)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=obj.id,
protocol_kind=PROTOCOL_KIND_DONATION,
before_codes=before_codes,
submitted_codes=(
sample_codes_from_payload(body.samples)
if "samples" in unset_fields
else None
),
)
return success_response(
rd_svc._donation_to_dict(obj, db, tenant_no),
message="更新成功",
@@ -397,6 +442,32 @@ async def change_donation_status(
return error_response(400, str(e))
@router.get("/research-donations/{donation_id}/status-changes")
async def list_research_donation_status_changes(
donation_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
obj = (
db.query(BizResearchDonation)
.filter(
BizResearchDonation.id == donation_id,
BizResearchDonation.tenant_no == tenant_no,
BizResearchDonation.del_flag == 0,
)
.first()
)
if not obj:
return error_response(404, "捐赠单不存在")
items = rd_svc.list_donation_status_changes(
db,
research_donation_id=donation_id,
tenant_no=tenant_no,
)
return success_response({"list": items})
# ---------------------------------------------------------------------------
# 10. 细胞存储 → 科研捐赠
# ---------------------------------------------------------------------------
@@ -44,6 +44,10 @@ async def list_donation_sample_collections(
pageSize: int = Query(10, ge=1, le=100),
keyword: Optional[str] = None,
sampleStatus: Optional[str] = Query(None, alias="sampleStatus"),
collectionFlag: Optional[str] = Query(None, alias="collectionFlag"),
receptionFlag: Optional[str] = Query(None, alias="receptionFlag"),
qcFlag: Optional[str] = Query(None, alias="qcFlag"),
storageFlag: Optional[str] = Query(None, alias="storageFlag"),
db: Session = Depends(get_db),
user: dict = Depends(_SAMPLE_INFO_MENU),
):
@@ -56,6 +60,10 @@ async def list_donation_sample_collections(
tenant_no=tenant_no,
keyword=keyword,
sample_status=sampleStatus,
collection_flag=collectionFlag,
reception_flag=receptionFlag,
qc_flag=qcFlag,
storage_flag=storageFlag,
user_info=user,
)
return success_response(_page_payload(items, total, page, pageSize))
+211
View File
@@ -0,0 +1,211 @@
"""样本级储存证 / 保险办理 API"""
from typing import Optional
from fastapi import APIRouter, Depends
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, require_any_menu_code
from app.core.response import error_response, success_response
from app.core.utils import parse_datetime
from app.crud import sample_insurance as insurance_crud
from app.crud import sample_storage_cert as cert_crud
from app.models.models import BizProtocolSample, SysUser
router = APIRouter(prefix="/web/admin", tags=["样本储存证保险"])
_MENU = require_any_menu_code("cell-storage:sample-info:list")
_WRITE_MENU = require_admin_or_super_admin
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)
def _resolve_protocol_id(db: Session, tenant_no: str, sample_code: str) -> Optional[str]:
row = (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.sample_code == sample_code,
BizProtocolSample.del_flag == 0,
)
.first()
)
return (row.collection_protocol_id or "").strip() or None if row else None
class SampleHandleBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
handle_time: Optional[str] = Field(None, alias="handleTime")
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
cert_image_urls: Optional[str] = Field(None, alias="certImageUrls")
insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
remark: Optional[str] = None
handle_remark: Optional[str] = Field(None, alias="handleRemark")
mail_cert_time: Optional[str] = Field(None, alias="mailCertTime")
policy_send_time: Optional[str] = Field(None, alias="policySendTime")
collection_protocol_id: Optional[str] = Field(None, alias="collectionProtocolId")
@router.get("/sample-storage-certs/{sample_code}")
async def get_sample_storage_cert(
sample_code: str,
db: Session = Depends(get_db),
user: dict = Depends(_MENU),
):
try:
tenant_no = _get_tenant_no(db, user)
code = (sample_code or "").strip()
row = cert_crud.get_by_sample_code(db, tenant_no, code)
if row is None:
return success_response(
{
"sampleCode": code,
"storageCertHandled": False,
"handleTime": "",
"handlerUserId": "",
"certImageUrls": "",
"mailCertTime": "",
"remark": "",
}
)
return success_response(cert_crud.row_to_dict(row))
except PermissionError as e:
return error_response(403, str(e))
except Exception as e:
return error_response(400, str(e))
@router.put("/sample-storage-certs/{sample_code}")
async def put_sample_storage_cert(
sample_code: str,
body: SampleHandleBody,
db: Session = Depends(get_db),
user: dict = Depends(_WRITE_MENU),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
code = (sample_code or "").strip()
pid = (body.collection_protocol_id or "").strip() or _resolve_protocol_id(
db, tenant_no, code
)
ht = parse_datetime(body.handle_time) if body.handle_time else None
mail_raw = body.mail_cert_time
update_mail = mail_raw is not None
mail_t = parse_datetime(mail_raw) if mail_raw and str(mail_raw).strip() else None
row = cert_crud.upsert_handle(
db,
tenant_no=tenant_no,
sample_code=code,
collection_protocol_id=pid,
user_id=user_id,
handle_time=ht,
handler_user_id=body.handler_user_id,
cert_image_urls=body.cert_image_urls or "",
remark=body.remark or "",
mail_cert_time=mail_t,
update_mail_cert_time=update_mail,
)
db.commit()
return success_response(cert_crud.row_to_dict(row), message="保存成功")
except PermissionError as e:
db.rollback()
return error_response(403, str(e))
except ValueError as e:
db.rollback()
return error_response(400, str(e))
except Exception as e:
db.rollback()
return error_response(400, str(e))
@router.get("/sample-insurances/{sample_code}")
async def get_sample_insurance(
sample_code: str,
db: Session = Depends(get_db),
user: dict = Depends(_MENU),
):
try:
tenant_no = _get_tenant_no(db, user)
code = (sample_code or "").strip()
row = insurance_crud.get_by_sample_code(db, tenant_no, code)
if row is None:
return success_response(
{
"sampleCode": code,
"insuranceHandled": False,
"handleTime": "",
"handlerUserId": "",
"insuranceAttachments": "",
"policySendTime": "",
"handleRemark": "",
}
)
return success_response(insurance_crud.row_to_dict(row))
except PermissionError as e:
return error_response(403, str(e))
except Exception as e:
return error_response(400, str(e))
@router.put("/sample-insurances/{sample_code}")
async def put_sample_insurance(
sample_code: str,
body: SampleHandleBody,
db: Session = Depends(get_db),
user: dict = Depends(_WRITE_MENU),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
code = (sample_code or "").strip()
pid = (body.collection_protocol_id or "").strip() or _resolve_protocol_id(
db, tenant_no, code
)
ht = parse_datetime(body.handle_time) if body.handle_time else None
policy_raw = body.policy_send_time
update_policy = policy_raw is not None
policy_t = (
parse_datetime(policy_raw) if policy_raw and str(policy_raw).strip() else None
)
row = insurance_crud.upsert_handle(
db,
tenant_no=tenant_no,
sample_code=code,
collection_protocol_id=pid,
user_id=user_id,
handle_time=ht,
handler_user_id=body.handler_user_id,
insurance_attachments=body.insurance_attachments or "",
handle_remark=body.handle_remark or body.remark or "",
policy_send_time=policy_t,
update_policy_send_time=update_policy,
)
db.commit()
return success_response(insurance_crud.row_to_dict(row), message="保存成功")
except PermissionError as e:
db.rollback()
return error_response(403, str(e))
except ValueError as e:
db.rollback()
return error_response(400, str(e))
except Exception as e:
db.rollback()
return error_response(400, str(e))
+98
View File
@@ -0,0 +1,98 @@
"""样本编码全链路纠错 API"""
from typing import Optional
from fastapi import APIRouter, Depends
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.models.models import SysUser
from app.services import sample_code_correction as svc
from app.services.sample_code_correction import SampleCodeCorrectionError
router = APIRouter(prefix="/web/admin", tags=["样本编码纠错"])
_MENU = require_admin_or_super_admin
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_user_id(user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失")
return str(user_id)
class SampleCodeCorrectionBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
reception_source: str = Field(..., alias="receptionSource")
old_sample_code: str = Field(..., alias="oldSampleCode")
new_sample_code: str = Field(..., alias="newSampleCode")
new_sample_code_id: Optional[str] = Field(None, alias="newSampleCodeId")
reason: Optional[str] = None
@router.post("/sample-code-corrections/preview")
async def preview_sample_code_correction(
body: SampleCodeCorrectionBody,
db: Session = Depends(get_db),
user: dict = Depends(_MENU),
):
try:
tenant_no = _get_tenant_no(db, user)
data = svc.preview_correction(
db,
tenant_no=tenant_no,
reception_source=body.reception_source,
old_sample_code=body.old_sample_code,
new_sample_code=body.new_sample_code,
new_sample_code_id=body.new_sample_code_id,
)
return success_response(data)
except PermissionError as e:
return error_response(str(e))
except Exception as e:
return error_response(f"预览失败: {e}")
@router.post("/sample-code-corrections/apply")
async def apply_sample_code_correction(
body: SampleCodeCorrectionBody,
db: Session = Depends(get_db),
user: dict = Depends(_MENU),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_user_id(user)
data = svc.apply_correction(
db,
tenant_no=tenant_no,
reception_source=body.reception_source,
old_sample_code=body.old_sample_code,
new_sample_code=body.new_sample_code,
new_sample_code_id=body.new_sample_code_id,
reason=body.reason,
user_id=user_id,
)
return success_response(data, message="样本编码已更正")
except SampleCodeCorrectionError as e:
db.rollback()
return error_response(str(e))
except PermissionError as e:
db.rollback()
return error_response(str(e))
except Exception as e:
db.rollback()
return error_response(f"更正失败: {e}")
+412
View File
@@ -0,0 +1,412 @@
"""样本运费批次 API"""
from __future__ import annotations
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 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 sample_freight as freight_svc
from app.crud import sf_express_settlement as sf_crud
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
def _get_current_user_id(user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失")
return str(user_id)
class SampleFreightItemBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
sample_source: str = Field(..., alias="sampleSource")
sample_id: str = Field(..., alias="sampleId")
protocol_id: Optional[str] = Field(None, alias="protocolId")
sample_code: Optional[str] = Field(None, alias="sampleCode")
class SampleFreightCreateBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
transport_mode: str = Field(..., alias="transportMode")
freight_amount: Optional[float] = Field(None, alias="freightAmount")
waybill_no: Optional[str] = Field(None, alias="waybillNo")
items: Optional[List[SampleFreightItemBody]] = None
class SampleFreightUpdateBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
transport_mode: Optional[str] = Field(None, alias="transportMode")
freight_amount: Optional[float] = Field(None, alias="freightAmount")
waybill_no: Optional[str] = Field(None, alias="waybillNo")
class SampleFreightItemsBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
items: List[SampleFreightItemBody] = Field(default_factory=list)
class SfSettlementSettleBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
ids: List[str] = Field(default_factory=list)
freight_amount: Optional[float] = Field(None, alias="freightAmount")
class SfSettlementFillBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
freight_amount: float = Field(..., alias="freightAmount")
@router.get("/sample-freights")
async def list_sample_freights(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
transportMode: Optional[str] = None,
sampleSource: Optional[str] = None,
createTimeFrom: Optional[str] = None,
createTimeTo: Optional[str] = None,
includeItems: bool = Query(False),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items, total = freight_svc.list_freights(
db,
tenant_no=tenant_no,
user_info=user,
page=page,
page_size=pageSize,
keyword=keyword,
transport_mode=transportMode,
sample_source=sampleSource,
create_time_from=createTimeFrom,
create_time_to=createTimeTo,
include_items=includeItems,
)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": pageSize}
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.get("/sample-freights/sample-options")
async def list_sample_freight_options(
page: int = Query(1, ge=1),
pageSize: int = Query(20, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
sampleSource: 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 = freight_svc.list_sample_options(
db,
tenant_no=tenant_no,
user_info=user,
page=page,
page_size=pageSize,
keyword=keyword,
sample_source=sampleSource,
)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": pageSize}
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.get("/sample-freights/sf-settlements")
async def list_sf_express_settlements(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
waybillNo: Optional[str] = None,
settleStatus: Optional[str] = None,
createTimeFrom: Optional[str] = None,
createTimeTo: Optional[str] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
rows, total = sf_crud.list_sf_settlements(
db,
tenant_no=tenant_no,
user_info=user,
page=page,
page_size=pageSize,
waybill_no=waybillNo,
settle_status=settleStatus,
create_time_from=createTimeFrom,
create_time_to=createTimeTo,
)
return success_response(
{"list": rows, "total": total, "page": page, "pageSize": pageSize}
)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.get("/sample-freights/sf-settlements/export")
async def export_sf_express_settlements(
waybillNo: Optional[str] = None,
settleStatus: Optional[str] = None,
createTimeFrom: Optional[str] = None,
createTimeTo: Optional[str] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
rows = sf_crud.list_sf_settlements_for_export(
db,
tenant_no=tenant_no,
user_info=user,
waybill_no=waybillNo,
settle_status=settleStatus,
create_time_from=createTimeFrom,
create_time_to=createTimeTo,
)
return success_response({"list": rows})
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/sample-freights/sf-settlements/settle")
async def settle_sf_express_settlements(
body: SfSettlementSettleBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
data = sf_crud.settle_sf_freights(
db,
tenant_no=tenant_no,
user_id=user_id,
user_info=user,
ids=body.ids,
freight_amount=body.freight_amount,
)
return success_response(data, message="结算成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/sample-freights/sf-settlements/{freight_id}/fill-amount")
async def fill_sf_express_settlement_amount(
freight_id: str,
body: SfSettlementFillBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
data = sf_crud.fill_sf_freight_amount(
db,
tenant_no=tenant_no,
user_id=user_id,
user_info=user,
freight_id=freight_id,
freight_amount=body.freight_amount,
)
return success_response(data, message="补填成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.get("/sample-freights/{freight_id}")
async def get_sample_freight(
freight_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
data = freight_svc.get_freight_detail(db, freight_id, tenant_no, user_info=user)
return success_response(data)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(404, str(e))
@router.post("/sample-freights")
async def create_sample_freight(
body: SampleFreightCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
items = [it.model_dump(by_alias=True) for it in (body.items or [])]
data = freight_svc.create_freight(
db,
tenant_no=tenant_no,
user_info=user,
user_id=user_id,
transport_mode_raw=body.transport_mode,
freight_amount=body.freight_amount,
waybill_no=body.waybill_no,
waybill_supplied=True,
amount_supplied=True,
items=items,
)
return success_response(data, message="创建成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.put("/sample-freights/{freight_id}")
async def update_sample_freight(
freight_id: str,
body: SampleFreightUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
data = freight_svc.update_freight(
db,
freight_id,
tenant_no=tenant_no,
user_info=user,
user_id=user_id,
transport_mode_raw=body.transport_mode,
freight_amount=body.freight_amount,
waybill_no=body.waybill_no,
waybill_supplied="waybill_no" in body.model_fields_set,
amount_supplied="freight_amount" in body.model_fields_set,
)
return success_response(data, message="更新成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
msg = str(e)
code = 404 if "不存在" in msg else 400
return error_response(code, msg)
@router.post("/sample-freights/{freight_id}/link")
async def link_sample_freight(
freight_id: str,
body: SampleFreightItemsBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
items = [it.model_dump(by_alias=True) for it in (body.items or [])]
data = freight_svc.link_samples(
db,
freight_id,
tenant_no=tenant_no,
user_info=user,
user_id=user_id,
items=items,
)
return success_response(data, message="关联成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
msg = str(e)
code = 404 if "不存在" in msg else 400
return error_response(code, msg)
@router.post("/sample-freights/{freight_id}/unlink")
async def unlink_sample_freight(
freight_id: str,
body: SampleFreightItemsBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
items = [it.model_dump(by_alias=True) for it in (body.items or [])]
data = freight_svc.unlink_samples(
db,
freight_id,
tenant_no=tenant_no,
user_info=user,
user_id=user_id,
items=items,
)
return success_response(data, message="已取消关联")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
msg = str(e)
code = 404 if "不存在" in msg else 400
return error_response(code, msg)
@router.delete("/sample-freights/{freight_id}")
async def delete_sample_freight(
freight_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
data = freight_svc.delete_freight(
db,
freight_id,
tenant_no=tenant_no,
user_info=user,
user_id=user_id,
)
return success_response(data, message="删除成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
msg = str(e)
code = 404 if "不存在" in msg else 400
return error_response(code, msg)
@@ -61,7 +61,7 @@ class ProductionReceptionBody(BaseModel):
infectious_four_test_result: Optional[str] = Field(None, alias="infectiousFourTestResult")
enriched_blood_weight_g: Optional[float] = Field(None, alias="enrichedBloodWeightG")
cryopreservation_volume_ml: Optional[float] = Field(None, alias="cryopreservationVolumeMl")
inoculation_amount: Optional[float] = Field(None, alias="inoculationAmount")
inoculation_amount: Optional[str] = Field(None, alias="inoculationAmount")
cryopreservation_time: Optional[str] = Field(None, alias="cryopreservationTime")
storage_total_tubes: Optional[int] = Field(None, alias="storageTotalTubes")
available_cell_count_1e7: Optional[float] = Field(None, alias="availableCellCount1e7")
+84
View File
@@ -0,0 +1,84 @@
"""样本储存证质检信息 API"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends
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_any_menu_code, require_strict_any_menu_code
from app.core.response import error_response, success_response
from app.crud import sample_storage_cert_qc as svc
from app.models.models import SysUser
router = APIRouter(prefix="/web/admin", tags=["储存证质检信息"])
# 市场 / 质检 / 生产三端共用读写(方案 A:完整表后写覆盖)
READ_MENUS = (
"biz:qcWorkOrder:list",
"biz:myQcTask:list",
"production:work-order:list",
"production:my-task:list",
"cell-storage:sample-info:list",
)
WRITE_MENUS = READ_MENUS
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)
class UpsertBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
payload: Dict[str, Any] = Field(default_factory=dict)
qc_work_order_id: Optional[str] = Field(None, alias="qcWorkOrderId")
@router.get("/sample-storage-cert-qc/{sample_code}")
async def get_sample_storage_cert_qc(
sample_code: str,
user: dict = Depends(require_any_menu_code(*READ_MENUS)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
detail = svc.get_detail(db, tenant_no, sample_code)
if detail is None:
return error_response(404, "样本不存在")
return success_response(detail)
@router.put("/sample-storage-cert-qc/{sample_code}")
async def put_sample_storage_cert_qc(
sample_code: str,
body: UpsertBody,
user: dict = Depends(require_strict_any_menu_code(*WRITE_MENUS)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
try:
detail = svc.upsert(
db,
tenant_no=tenant_no,
user_id=user_id,
sample_code=sample_code,
payload=body.payload,
qc_work_order_id=body.qc_work_order_id,
)
return success_response(detail, message="保存成功")
except ValueError as e:
return error_response(400, str(e))
+47 -3
View File
@@ -4,7 +4,11 @@ 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, require_menu_code
from app.core.menu_guard import (
require_admin_or_super_admin,
require_any_menu_code,
user_has_menu_perm,
)
from app.core.response import error_response, success_response
from app.crud import storage_release_review as srr_svc
from app.models.models import SysUser
@@ -34,6 +38,32 @@ def _get_current_user_id(user: dict) -> str:
return str(user_id)
STORAGE_RELEASE_READ_MENU_CODES = (
"biz:storageReleaseReview:list",
"production:storageReleaseReview:list",
"quality:storageReleaseReview:list",
)
STORAGE_RELEASE_WRITE_MENU_CODES = (
"production:storageReleaseReview:list",
"quality:storageReleaseReview:list",
)
def _ensure_review_side_write(db: Session, user: dict, review_side: str) -> str:
side = srr_svc.normalize_review_side(review_side)
if side not in ("production", "qc"):
raise ValueError("审核侧无效")
menu = srr_svc.review_side_required_menu(side)
if not menu or not user_has_menu_perm(db, user, menu):
raise PermissionError("无权提交该侧审核")
return side
def _ensure_storage_write(db: Session, user: dict) -> None:
if not any(user_has_menu_perm(db, user, code) for code in STORAGE_RELEASE_WRITE_MENU_CODES):
raise PermissionError("无权执行入库操作")
@router.get("/storage-release-reviews")
async def list_storage_release_reviews(
page: int = Query(1, ge=1),
@@ -44,8 +74,12 @@ async def list_storage_release_reviews(
storageStatus: Optional[str] = None,
reviewPhase: Optional[str] = None,
qcWorkOrderCompleted: Optional[bool] = Query(None),
readyForReviewOnly: Optional[bool] = Query(None),
reviewSide: Optional[str] = None,
prodReviewConclusion: Optional[str] = None,
qcReviewConclusion: Optional[str] = None,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:storageReleaseReview:list")),
user: dict = Depends(require_any_menu_code(*STORAGE_RELEASE_READ_MENU_CODES)),
):
try:
tenant_no = _get_tenant_no(db, user)
@@ -60,6 +94,10 @@ async def list_storage_release_reviews(
keyword=keyword,
review_phase=reviewPhase,
qc_work_order_completed=qcWorkOrderCompleted,
ready_for_review_only=readyForReviewOnly,
review_side=reviewSide,
prod_review_conclusion=prodReviewConclusion,
qc_review_conclusion=qcReviewConclusion,
user_info=user,
)
return success_response(result)
@@ -73,7 +111,7 @@ async def list_storage_release_reviews(
async def get_storage_release_review_detail(
review_id: str,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:storageReleaseReview:list")),
user: dict = Depends(require_any_menu_code(*STORAGE_RELEASE_READ_MENU_CODES)),
):
try:
tenant_no = _get_tenant_no(db, user)
@@ -97,9 +135,11 @@ async def submit_single_review(
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
side = _ensure_review_side_write(db, user, body.reviewSide)
rev = srr_svc.submit_review(
db,
review_id,
review_side=side,
review_conclusion=body.reviewConclusion,
review_remark=body.reviewRemark,
review_attachments=body.reviewAttachments,
@@ -125,9 +165,11 @@ async def batch_review(
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
side = _ensure_review_side_write(db, user, body.reviewSide)
result = srr_svc.batch_submit_review(
db,
body.ids,
review_side=side,
review_conclusion=body.reviewConclusion,
review_remark=body.reviewRemark,
review_attachments=body.reviewAttachments,
@@ -153,6 +195,7 @@ async def store_single_sample(
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
_ensure_storage_write(db, user)
rev = srr_svc.store_sample(
db,
review_id,
@@ -178,6 +221,7 @@ async def batch_store_samples(
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
_ensure_storage_write(db, user)
result = srr_svc.batch_store_samples(
db,
body.ids,
+8 -2
View File
@@ -467,8 +467,11 @@ async def approve_task(
):
try:
tenant_no = user.get("tenant_no", "zkxw")
actor_user_id = user.get("user_id") or user.get("id")
engine = WorkflowEngine(db)
instance = engine.approve_task(task_id, body.comment or "", tenant_no)
instance = engine.approve_task(
task_id, body.comment or "", tenant_no, actor_user_id=actor_user_id
)
return success_response({"instanceId": instance.id, "status": instance.status}, message="审批成功")
except ValueError as e:
return error_response(400, str(e))
@@ -483,8 +486,11 @@ async def reject_task(
):
try:
tenant_no = user.get("tenant_no", "zkxw")
actor_user_id = user.get("user_id") or user.get("id")
engine = WorkflowEngine(db)
instance = engine.reject_task(task_id, body.comment or "", tenant_no)
instance = engine.reject_task(
task_id, body.comment or "", tenant_no, actor_user_id=actor_user_id
)
return success_response({"instanceId": instance.id, "status": instance.status}, message="已驳回")
except ValueError as e:
return error_response(400, str(e))
+13 -2
View File
@@ -221,8 +221,11 @@ async def get_current_user_by_token(
detail="用户已被禁用",
)
web_role = resolve_web_role_for_user(db, user.id)
role_names = get_user_role_names(db, user.id)
tenant_no = (getattr(user, "tenant_no", None) or "").strip() or None
web_role = resolve_web_role_for_user(
db, user.id, tenant_no=tenant_no
)
role_names = get_user_role_names(db, user.id, tenant_no=tenant_no)
phone = (getattr(user, "phone_no", None) or "").strip() or None
email = (getattr(user, "email", None) or "").strip() or None
@@ -232,6 +235,10 @@ async def get_current_user_by_token(
s = str(avatar_raw).strip()
avatar_stored = s if s else None
from app.services.issue_user_tokens import resolve_user_org_labels
dept_name, post_names = resolve_user_org_labels(db, user)
current_user_dept_id.set(user.dept_id or "")
return {
@@ -240,7 +247,11 @@ async def get_current_user_by_token(
"real_name": user.nick_name,
"role": web_role,
"role_names": role_names,
"tenant_no": tenant_no,
"region_id": user.dept_id,
"region_name": dept_name,
"dept_name": dept_name,
"post_names": post_names,
"phone": phone,
"email": email,
"sex": getattr(user, "sex", None) or "0",
+98 -4
View File
@@ -1,8 +1,23 @@
from pydantic import field_validator
from pydantic_settings import BaseSettings
from typing import Optional
from pathlib import Path
import os
# 已知弱/占位 JWT 密钥:启动时一律拒绝(勿再当作默认值)
_INSECURE_JWT_SECRET_KEYS = frozenset(
{
"",
"secret",
"changeme",
"change-me",
"your-secret-key",
"your-secret-key-change-in-production",
"your-secret-key-change-in-production-please-use-random-string",
}
)
_MIN_JWT_SECRET_KEY_LEN = 32
class Settings(BaseSettings):
"""应用配置"""
@@ -15,10 +30,27 @@ class Settings(BaseSettings):
# 数据库配置
DATABASE_URL: str = "mysql+pymysql://root:password@localhost:3306/sanqiang"
# JWT配置
SECRET_KEY: str = "your-secret-key-change-in-production-please-use-random-string"
# JWT配置(必须通过环境变量配置;禁止弱默认值)
SECRET_KEY: str = ""
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 * 24 # 30天
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 * 24 * 60 # 30天
@field_validator("SECRET_KEY")
@classmethod
def secret_key_must_be_strong(cls, value: str) -> str:
key = (value or "").strip()
if key.lower() in _INSECURE_JWT_SECRET_KEYS or key in _INSECURE_JWT_SECRET_KEYS:
raise ValueError(
"SECRET_KEY 未配置或仍为弱占位值。"
"请在环境文件中设置至少 32 字符的随机密钥,例如:"
'python -c "import secrets; print(secrets.token_urlsafe(48))"'
)
if len(key) < _MIN_JWT_SECRET_KEY_LEN:
raise ValueError(
f"SECRET_KEY 长度不足(当前 {len(key)},至少 {_MIN_JWT_SECRET_KEY_LEN})。"
"请使用 secrets.token_urlsafe(48) 等方式生成。"
)
return key
# CORS配置
BACKEND_CORS_ORIGINS: list = [
@@ -100,6 +132,55 @@ class Settings(BaseSettings):
WX_TEMPLATE_REFUND_RESULT: str = "" # 退款结果通知模板ID
WX_TEMPLATE_DELIVERY_WARNING: str = "" # 配送时效预警模板ID
# 微信服务号(OA 审批/抄送通知)
# 总开关:false 时不发送审批/抄送/申请人进度/评论@提到/基础实验交付提醒/样本采集通知模板消息
WX_MP_NOTIFY_ENABLED: bool = False
WX_MP_APPID: str = ""
WX_MP_SECRET: str = ""
WX_MP_TEMPLATE_APPROVAL: str = ""
WX_MP_TEMPLATE_CC: str = ""
WX_MP_TEMPLATE_APPLICANT_PROGRESS: str = ""
# 基础实验交付日前一天提醒(出库提醒模板)
WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY: str = (
"oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM"
)
# 协议新增样本采集通知(工单类模板)
# 工单标题 thing2 / 发起人 thing8 / 发起时间 time3 / 任务类型 phrase10 / 工单类型 thing17(样本类型名称)
WX_MP_TEMPLATE_SAMPLE_COLLECT: str = (
"iOnQ9k2mavAOwFbp_6yH4t1SHqpEkaiwNWKueY0kpwE"
)
WX_MP_SAMPLE_COLLECT_ROLE_NAME: str = "样本采集通知人员"
# 审批模板:申请事项/操作人(申请人)/当前环节/操作时间/申请类型(流程名)
# 生产字段:thing12 / thing18 / thing7 / time17 / thing8
WX_MP_TPL_FIELD_TITLE: str = "thing12"
WX_MP_TPL_FIELD_APPLICANT: str = "thing18"
WX_MP_TPL_FIELD_NODE: str = "thing7"
WX_MP_TPL_FIELD_TIME: str = "time17"
WX_MP_TPL_FIELD_PROCESS: str = "thing8"
# 抄送模板「OA抄送通知」:业务名称/申请人/申请时间/流程名称(无当前环节)
WX_MP_TPL_CC_FIELD_TITLE: str = "thing5"
WX_MP_TPL_CC_FIELD_APPLICANT: str = "thing2"
WX_MP_TPL_CC_FIELD_TIME: str = "time3"
WX_MP_TPL_CC_FIELD_PROCESS: str = "thing1"
# 申请人进度:业务名称/流程名称/审批人/审批结果(const)/审批时间
WX_MP_TPL_PROGRESS_FIELD_BUSINESS: str = "thing5"
WX_MP_TPL_PROGRESS_FIELD_PROCESS: str = "thing1"
WX_MP_TPL_PROGRESS_FIELD_APPROVER: str = "thing6"
WX_MP_TPL_PROGRESS_FIELD_RESULT: str = "const4"
WX_MP_TPL_PROGRESS_FIELD_TIME: str = "time11"
# 基础实验交付提醒:客户名称/产品名称/件数/收货联系人/出库时间
WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER: str = "thing6"
WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT: str = "thing10"
WX_MP_TPL_BE_DELIVERY_FIELD_QTY: str = "character_string8"
WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT: str = "thing7"
WX_MP_TPL_BE_DELIVERY_FIELD_TIME: str = "time14"
# 样本采集通知:工单标题/发起人/发起时间/任务类型/工单类型(样本类型名称)
WX_MP_TPL_SC_FIELD_TITLE: str = "thing2"
WX_MP_TPL_SC_FIELD_APPLICANT: str = "thing8"
WX_MP_TPL_SC_FIELD_TIME: str = "time3"
WX_MP_TPL_SC_FIELD_TYPE: str = "phrase10"
WX_MP_TPL_SC_FIELD_SAMPLE_TYPE: str = "thing17"
# 与 docs/sql/zkxw.sql 基线一致:登录按租户 + sys_user.user_no
DEFAULT_TENANT_NO: str = "zkxw"
# 已废弃:无角色用户固定为 guest,不可通过环境变量提升为 super_admin
@@ -126,6 +207,9 @@ class Settings(BaseSettings):
API_ENCRYPT_FIXED_SECRET: str = ""
API_ENCRYPT_TS_SKEW_SEC: int = 300
API_ENCRYPT_NONCE_TTL_SEC: int = 300
WX_QR_SESSION_TTL_SEC: int = 180
WX_QR_ENV_VERSION: str = "release" # release | trial | develop
WX_QR_PAGE: str = "pages/login/web-qr/index"
API_ENCRYPT_EXCLUDE_PATHS: str = (
"/,"
"/health,"
@@ -134,14 +218,24 @@ class Settings(BaseSettings):
"/api/web/auth/login-rsa-public-key,"
"/api/web/auth/login,"
"/api/web/auth/refresh-token,"
"/api/mini/auth/wechat-login,"
"/api/web/admin/attachment/upload,"
"/api/web/admin/products/upload-image,"
"/api/web/admin/categories/upload-image,"
"/api/web/admin/users/upload-avatar,"
"/api/web/auth/upload-avatar,"
"/api/invoice/download"
"/api/invoice/download,"
"/api/web/auth/wx-qr/create,"
"/api/web/auth/wx-qr/status,"
"/api/mini/auth/web-qr-scanned,"
"/api/mini/auth/web-qr-confirm"
)
# 中国节假日日历(ailcc)同步
CN_HOLIDAY_API_BASE: str = "https://holiday.ailcc.com"
CN_HOLIDAY_HTTP_TIMEOUT_SEC: int = 15
CN_HOLIDAY_HTTP_RETRIES: int = 2
class Config:
# 支持通过 ENV_FILE 指定环境文件;未指定时回退到 backend/.env
_default_env_file = Path(__file__).resolve().parents[2] / ".env"
+8 -7
View File
@@ -20,7 +20,6 @@ from app.models.models import (
SysRole,
SysRoleDataScopeDetail,
SysUser,
SysUserRole,
)
@@ -62,17 +61,20 @@ def _get_child_dept_ids(db: Session, dept_id: str, tenant_no: str) -> List[str]:
def _resolve_user_role_scopes(
db: Session, user_id: str, tenant_no: str
) -> List[dict]:
"""获取用户所有角色的数据权限配置。
"""获取用户有效角色的数据权限配置(直接角色 ∪ 职位继承角色)。
返回: [{"data_scope": str, "details": [SysRoleDataScopeDetail, ...]}]
"""
from app.services.web_auth_roles import get_effective_role_ids
role_ids = get_effective_role_ids(db, user_id, tenant_no=tenant_no)
if not role_ids:
return []
roles = (
db.query(SysRole)
.join(SysUserRole, SysUserRole.role_id == SysRole.id)
.filter(
SysUserRole.user_id == user_id,
SysUserRole.del_flag == 0,
SysUserRole.tenant_no == tenant_no,
SysRole.id.in_(role_ids),
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant_no,
@@ -83,7 +85,6 @@ def _resolve_user_role_scopes(
return []
result: List[dict] = []
role_ids = [r.id for r in roles]
# 批量查所有自定义权限明细
all_details = (
+91
View File
@@ -5,6 +5,8 @@ from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.auth import get_current_user
from app.core.database import get_db
from app.models.models import SysUser
from app.services.web_auth_roles import iter_user_menu_perm_codes
ADMIN_ROLES = {"admin", "super_admin"}
@@ -57,3 +59,92 @@ def require_any_menu_code(*menu_codes: str) -> Callable:
"""接口菜单权限守卫(已统一放行 menuCode 校验,仅保留运营端登录)。"""
_ = menu_codes
return require_admin_or_super_admin
def _strict_user_identity(db: Session, user: dict) -> tuple[str, str]:
"""从用户表读取当前租户,拒绝信任 JWT/请求中的租户覆盖。"""
user_id = str((user or {}).get("user_id") or "").strip()
if not user_id:
return "", ""
row = (
db.query(SysUser.id, SysUser.tenant_no)
.filter(
SysUser.id == user_id,
SysUser.del_flag == 0,
SysUser.status == "1",
)
.first()
)
if not row:
return "", ""
return str(row[0] or "").strip(), str(row[1] or "").strip()
def strict_user_has_any_menu_perm(
db: Session,
user: dict,
menu_codes: Iterable[str],
) -> bool:
"""按用户真实租户严格判断任一菜单权限,含直接与职位继承角色。"""
required = {
str(code or "").strip() for code in menu_codes if str(code or "").strip()
}
if not required:
return False
user_id, tenant_no = _strict_user_identity(db, user)
if not user_id or not tenant_no:
return False
granted = iter_user_menu_perm_codes(db, user_id, tenant_no=tenant_no)
return bool(required.intersection(granted))
def strict_user_has_menu_perm(db: Session, user: dict, menu_code: str) -> bool:
"""按用户真实租户严格判断单个菜单权限。"""
return strict_user_has_any_menu_perm(db, user, (menu_code,))
def assert_strict_menu_code(
db: Session,
user: dict,
menu_code: str,
) -> None:
"""路由内部字段级权限断言。"""
if not strict_user_has_menu_perm(db, user, menu_code):
raise HTTPException(status_code=403, detail="无对应菜单权限")
def require_strict_menu_code(menu_code: str) -> Callable:
"""租户化的精确菜单权限依赖;与历史宽松守卫并存。"""
required = str(menu_code or "").strip()
async def dependency(
user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
assert_strict_menu_code(db, user, required)
return user
dependency.strict_menu_codes = (required,)
return dependency
def require_strict_any_menu_code(*menu_codes: str) -> Callable:
"""租户化的任一菜单权限依赖。"""
required = tuple(
dict.fromkeys(
str(code or "").strip()
for code in menu_codes
if str(code or "").strip()
)
)
async def dependency(
user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
if not strict_user_has_any_menu_perm(db, user, required):
raise HTTPException(status_code=403, detail="无对应菜单权限")
return user
dependency.strict_menu_codes = required
return dependency
+76
View File
@@ -0,0 +1,76 @@
"""移动市场接口的严格菜单权限守卫。"""
from typing import Callable, Iterable
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.auth import get_current_user
from app.core.database import get_db
from app.services.web_auth_roles import (
iter_user_menu_perm_codes,
resolve_web_role_for_user,
)
def require_market_menu_permission(
*,
user_id: str,
tenant_no: str,
required: str,
granted: Iterable[str],
) -> bool:
"""严格判断当前租户用户是否拥有指定移动市场权限码。"""
uid = (user_id or "").strip()
tenant = (tenant_no or "").strip()
code = (required or "").strip()
if not uid or not tenant or not code:
return False
return code in {(item or "").strip() for item in granted if item}
def user_has_tenant_menu_perm(
db: Session,
*,
user_id: str,
tenant_no: str,
perm_code: str,
) -> bool:
"""按显式租户读取用户权限,不回退默认租户。"""
tenant = (tenant_no or "").strip()
uid = (user_id or "").strip()
if not tenant or not uid:
return False
granted = iter_user_menu_perm_codes(db, uid, tenant_no=tenant)
return require_market_menu_permission(
user_id=uid,
tenant_no=tenant,
required=perm_code,
granted=granted,
)
def require_market_menu_code(menu_code: str) -> Callable:
"""为移动市场 API 提供租户化、精确匹配的菜单权限依赖。"""
required = (menu_code or "").strip()
async def dependency(
user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
tenant_no = str(user.get("tenant_no") or "").strip()
user_id = str(user.get("user_id") or "").strip()
if not tenant_no or not user_id:
raise HTTPException(status_code=403, detail="用户租户或身份信息缺失")
tenant_role = resolve_web_role_for_user(
db, user_id, tenant_no=tenant_no
)
if tenant_role != "super_admin" and not user_has_tenant_menu_perm(
db,
user_id=user_id,
tenant_no=tenant_no,
perm_code=required,
):
raise HTTPException(status_code=403, detail="无移动端菜单权限")
return user
return dependency
+49 -1
View File
@@ -1,7 +1,7 @@
"""协议状态展示(含作废类型后缀)。"""
from __future__ import annotations
from typing import Dict, Optional
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
@@ -12,6 +12,27 @@ VOID_TYPE_DICT = "xyzflx"
# 协议/捐赠单无效态(与样本接收、生产工单列表过滤口径一致)
INACTIVE_PROTOCOL_STATUSES = ("yzf", "sdfq")
# 科研捐赠质检列表/角标默认协议状态:待生效、生效中
DEFAULT_QC_DONATION_PROTOCOL_STATUSES = ("dsx", "sxz")
def resolve_research_donation_protocol_status_filter(
protocol_status: Optional[str],
) -> Tuple[str, List[str]]:
"""解析科研捐赠质检列表的协议状态筛选语义。
Returns:
("in", statuses) — 按所选状态 IN 过滤
("none", []) — 显式空选择,无匹配结果
("default", DEFAULT_QC_DONATION_PROTOCOL_STATUSES) — 未传参,与列表默认一致
"""
if protocol_status is None:
return ("default", list(DEFAULT_QC_DONATION_PROTOCOL_STATUSES))
statuses = [s.strip() for s in str(protocol_status).split(",") if s.strip()]
if not statuses:
return ("none", [])
return ("in", statuses)
_PROTOCOL_STATUS_LABELS = {
"dsx": "待生效",
"sxz": "生效中",
@@ -34,6 +55,16 @@ _VOID_TYPE_FALLBACK_LABELS = {
"jcbhg": "检测不合格",
}
# 非法来源状态视为终态,不允许再转出
_PROTOCOL_STATUS_TRANSITIONS = {
"dsx": ("sxz", "yzf", "sdfq"),
"sxz": ("ygq", "yzf", "zky", "sdfq"),
}
_DONATION_STATUS_TRANSITIONS = {
"dsx": ("sxz", "yzf", "sdfq"),
"sxz": ("ygq", "yzf", "zckc", "sdfq"),
}
def resolve_void_type_label(
db: Optional[Session],
@@ -59,6 +90,23 @@ def resolve_void_type_label(
return _VOID_TYPE_FALLBACK_LABELS.get(code, code)
def status_transition_denied_message(current: str, target: str, *, kind: str = "protocol") -> str:
"""状态流转被拒绝时的提示,状态码展示为中文名称。"""
current_label = format_protocol_status_label(current, kind=kind)
target_label = format_protocol_status_label(target, kind=kind)
return f"状态{current_label}不可转为{target_label}"
def assert_protocol_status_transition(current: str, target: str, *, kind: str = "protocol") -> None:
cur = (current or "").strip()
nxt = (target or "").strip()
if cur == nxt:
return
table = _DONATION_STATUS_TRANSITIONS if kind == "donation" else _PROTOCOL_STATUS_TRANSITIONS
if nxt not in table.get(cur, ()):
raise ValueError(status_transition_denied_message(cur, nxt, kind=kind))
def format_protocol_status_label(
status: str,
void_type: Optional[str] = None,
+26 -2
View File
@@ -21,6 +21,7 @@
import os
import uuid
import base64
import mimetypes
from io import BytesIO
from datetime import datetime, timedelta
from urllib.parse import quote
@@ -33,6 +34,25 @@ from app.core.config import settings
# HEIC 格式的 MIME 类型
HEIC_TYPES = {"image/heic", "image/heif"}
# 扩展名 → MIME(补齐部分系统 mimetypes 缺失,尤其 SVG)
_EXTRA_CONTENT_TYPES = {
".svg": "image/svg+xml",
".svgz": "image/svg+xml",
".webp": "image/webp",
".heic": "image/heic",
".heif": "image/heif",
}
def guess_content_type(filename: str) -> str:
"""按文件名推断 Content-Type,供对象存储写入元数据(SVG 回显依赖正确 MIME)。"""
name = (filename or "").strip()
ext = os.path.splitext(name)[1].lower()
if ext in _EXTRA_CONTENT_TYPES:
return _EXTRA_CONTENT_TYPES[ext]
guessed, _ = mimetypes.guess_type(name)
return guessed or "application/octet-stream"
def convert_heic_to_jpeg(content: bytes) -> bytes:
"""将 HEIC/HEIF 格式转换为 JPEG 格式"""
@@ -176,8 +196,9 @@ class QiniuStorage(BaseStorage):
key = self._generate_filename(filename)
token = self.qiniu.upload_token(self.bucket, key, 3600)
mime = guess_content_type(filename)
ret, info = put_data(token, key, file_content)
ret, info = put_data(token, key, file_content, mime_type=mime)
if ret is not None:
return self.get_url(ret["key"])
raise Exception(f"七牛云上传失败: {info}")
@@ -231,7 +252,8 @@ class OSSStorage(BaseStorage):
file_content, filename = process_image_content(file_content, filename)
key = self._generate_filename(filename)
result = self.bucket.put_object(key, file_content)
headers = {"Content-Type": guess_content_type(filename)}
result = self.bucket.put_object(key, file_content, headers=headers)
if result.status == 200:
return self.get_url(key)
raise Exception(f"OSS上传失败: {result.status}")
@@ -294,11 +316,13 @@ class MinIOStorage(BaseStorage):
if folder:
key = f"{folder.strip('/')}/{key}"
data = BytesIO(file_content)
# 必须写入正确 MIME:SVG 若为 octet-stream,浏览器 <img> 会拒绝回显
self.client.put_object(
bucket_name=self.bucket_name,
object_name=key,
data=data,
length=len(file_content),
content_type=guess_content_type(filename),
)
return self.get_url(key)
+62
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.protocol_donation_convert import assert_biz_no_available
from app.models.models import BizBizNoSequence
PREFIX_COLLECTION = "XBCC"
PREFIX_DONATION = "JZCC"
@@ -24,6 +25,46 @@ def _format_biz_no(prefix: str, biz_date: str, seq: int) -> str:
return f"{prefix}{biz_date}{seq:0{SEQ_DIGITS}d}"
def _allocate_sqlite_sequence(
db: Session,
*,
tenant_no: str,
prefix: str,
biz_date: str,
user_id: str,
now: datetime,
) -> int:
"""SQLite 测试环境使用 ORM 分配;生产 MySQL 仍走原子 SQL。"""
row = db.query(BizBizNoSequence).filter(
BizBizNoSequence.tenant_no == tenant_no,
BizBizNoSequence.prefix == prefix,
BizBizNoSequence.biz_date == biz_date,
BizBizNoSequence.del_flag == 0,
).first()
if row:
row.last_seq = int(row.last_seq or 0) + 1
row.update_by = user_id
row.update_time = now
else:
row = BizBizNoSequence(
id=generate_uuid(),
tenant_no=tenant_no,
prefix=prefix,
biz_date=biz_date,
last_seq=1,
order_num=0,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
db.flush()
return int(row.last_seq)
def allocate_next_biz_no(
db: Session,
*,
@@ -41,6 +82,27 @@ def allocate_next_biz_no(
date_str = now.strftime("%Y%m%d")
uid = user_id or ""
dialect_name = getattr(getattr(db.get_bind(), "dialect", None), "name", "")
if dialect_name == "sqlite":
seq_int = _allocate_sqlite_sequence(
db,
tenant_no=tenant_no,
prefix=prefix,
biz_date=date_str,
user_id=uid,
now=now,
)
if seq_int > MAX_SEQ:
raise ValueError(f"当日{prefix}协议号序号已用尽(最大 {MAX_SEQ})")
biz_no = _format_biz_no(prefix, date_str, seq_int)
assert_biz_no_available(
db,
tenant_no=tenant_no,
biz_no=biz_no,
for_donation=(kind == "donation"),
)
return biz_no
db.execute(
text(
"""
+279 -10
View File
@@ -16,11 +16,12 @@ from app.models.models import BizStorageSlotOccupancy
from app.models.models import (
BizCellProduct,
BizCellProductSpec,
BizCellProductType,
BizProductionProductType,
BizStorageFacility,
BizStorageFacilitySlot,
)
from app.services.storage_facility_code import build_full_slot_code
from app.crud.storage_facility import search_slot_ids_by_keyword
_CELL_PRODUCT_STATUSES = frozenset(
{"frozen", "in_production", "sold", "used", "voided", "thawed"}
@@ -148,10 +149,10 @@ def _enrich_product_dicts(
type_map: Dict[str, str] = {}
if type_ids:
rows = (
db.query(BizCellProductType)
db.query(BizProductionProductType)
.filter(
BizCellProductType.id.in_(type_ids),
BizCellProductType.del_flag == 0,
BizProductionProductType.id.in_(type_ids),
BizProductionProductType.del_flag == 0,
)
.all()
)
@@ -289,20 +290,27 @@ def list_cell_products(
source_type: Optional[str] = None,
user_info=None,
) -> Dict[str, Any]:
kw = (keyword or "").strip()
matched_slot_ids: List[str] = (
search_slot_ids_by_keyword(db, tenant_no, kw) if kw else []
)
def _filtered():
q0 = db.query(BizCellProduct).filter(
BizCellProduct.del_flag == 0,
BizCellProduct.tenant_no == tenant_no,
)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q0 = q0.filter(
or_(
BizCellProduct.product_code.ilike(like),
BizCellProduct.product_name.ilike(like),
keyword_conds = [
BizCellProduct.product_code.ilike(like),
BizCellProduct.product_name.ilike(like),
]
if matched_slot_ids:
keyword_conds.append(
BizCellProduct.storage_slot_id.in_(matched_slot_ids)
)
)
q0 = q0.filter(or_(*keyword_conds))
pc = (product_code or "").strip()
if pc:
q0 = q0.filter(BizCellProduct.product_code.like(f"%{pc}%"))
@@ -1410,6 +1418,62 @@ def _build_descendant_chain(
return result
def _lineage_node_key(node: Dict[str, Any]) -> str:
nt = (node.get("nodeType") or "").strip()
nid = (node.get("id") or "").strip()
if nt == "sample":
code = (node.get("sampleCode") or "").strip()
return f"sample:{(nid or code)}"
return f"{nt}:{nid}"
def _walk_ancestor_link(
link: Any,
out: List[Dict[str, Any]],
seen: set,
) -> None:
if not link:
return
if isinstance(link, list):
for item in link:
_walk_ancestor_link(item, out, seen)
return
if not isinstance(link, dict):
return
parent = link.get("parent")
_walk_ancestor_link(parent, out, seen)
node = link.get("node")
if not isinstance(node, dict):
return
key = _lineage_node_key(node)
if not key or key in seen:
return
seen.add(key)
out.append(dict(node))
def _flatten_lineage_nodes(chain: Dict[str, Any]) -> List[Dict[str, Any]]:
"""将 get_product_chain 结果展平为上游→下游节点列表(含 current,不含 descendants)。"""
out: List[Dict[str, Any]] = []
seen: set = set()
for link in chain.get("ancestors") or []:
_walk_ancestor_link(link, out, seen)
current = chain.get("current")
if isinstance(current, dict):
key = _lineage_node_key(current)
if key and key not in seen:
seen.add(key)
node = dict(current)
node["isCurrent"] = True
out.append(node)
elif key:
for n in out:
if _lineage_node_key(n) == key:
n["isCurrent"] = True
break
return out
def get_product_chain(db: Session, product_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
product = get_cell_product(db, product_id, tenant_no)
if not product:
@@ -1422,3 +1486,208 @@ def get_product_chain(db: Session, product_id: str, tenant_no: str) -> Optional[
db, product.id, product.product_code or "", 0, tenant_no
),
}
def _wo_status_label(status: str) -> str:
from app.crud.qc_work_order import _WORK_ORDER_STATUS_LABELS
return _WORK_ORDER_STATUS_LABELS.get(status or "", status or "")
def _slim_task_for_lineage(
task: Dict[str, Any], results: List[Dict[str, Any]]
) -> Dict[str, Any]:
return {
"id": task.get("id") or "",
"taskNo": task.get("taskNo") or "",
"itemName": task.get("itemName") or "",
"itemCode": task.get("itemCode") or "",
"taskType": task.get("taskType") or "",
"taskStatus": task.get("taskStatus") or "",
"taskStatusLabel": task.get("taskStatusLabel") or "",
"conclusion": task.get("conclusion") or "",
"conclusionLabel": task.get("conclusionLabel") or "",
"remark": task.get("remark") or "",
"completedTime": task.get("completedTime") or "",
"companyName": task.get("companyName") or "",
"results": [
{
"paramName": r.get("paramName") or "",
"paramValue": r.get("paramValue") or "",
"paramUnit": r.get("paramUnit") or "",
}
for r in (results or [])
],
"attachmentFiles": task.get("attachmentFiles") or [],
}
def _load_lineage_qc_for_nodes(
db: Session, nodes: List[Dict[str, Any]], tenant_no: str
) -> List[Dict[str, Any]]:
from app.models.models import BizQcWorkOrder
from app.crud import qc_task as task_svc
from app.crud import qc_task_result as result_svc
from app.crud.qc_work_order import list_work_orders_by_sample
product_ids = [
(n.get("id") or "").strip()
for n in nodes
if (n.get("nodeType") or "") == "cell_product" and (n.get("id") or "").strip()
]
wo_by_product: Dict[str, List[BizQcWorkOrder]] = {pid: [] for pid in product_ids}
if product_ids:
rows = (
db.query(BizQcWorkOrder)
.filter(
BizQcWorkOrder.cell_product_id.in_(product_ids),
BizQcWorkOrder.tenant_no == tenant_no,
BizQcWorkOrder.del_flag == 0,
)
.order_by(BizQcWorkOrder.create_time.desc())
.all()
)
for wo in rows:
pid = (wo.cell_product_id or "").strip()
if pid in wo_by_product:
wo_by_product[pid].append(wo)
sample_wo_cache: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
node_work_orders_orm: List[List[BizQcWorkOrder]] = []
for n in nodes:
if (n.get("nodeType") or "") == "cell_product":
node_work_orders_orm.append(wo_by_product.get((n.get("id") or "").strip(), []))
else:
node_work_orders_orm.append([])
sample_wo_dicts: Dict[int, List[Dict[str, Any]]] = {}
for idx, n in enumerate(nodes):
if (n.get("nodeType") or "") != "sample":
continue
code = (n.get("sampleCode") or "").strip()
rs = (n.get("receptionSource") or "").strip()
if not code or not rs:
sample_wo_dicts[idx] = []
continue
key = (code, rs)
if key not in sample_wo_cache:
sample_wo_cache[key] = list_work_orders_by_sample(
db,
sample_code=code,
reception_source=rs,
tenant_no=tenant_no,
user_info=None,
)
sample_wo_dicts[idx] = sample_wo_cache[key]
pending_wo_ids: List[str] = []
for wos in node_work_orders_orm:
for wo in wos:
pending_wo_ids.append(wo.id)
for wos in sample_wo_dicts.values():
for wo in wos:
wid = (wo.get("id") or "").strip()
if wid:
pending_wo_ids.append(wid)
unique_wo_ids = list({(x or "").strip() for x in pending_wo_ids if (x or "").strip()})
tasks_by_wo: Dict[str, List[Dict[str, Any]]] = {}
for i in range(0, len(unique_wo_ids), 100):
chunk = unique_wo_ids[i : i + 100]
chunk_tasks = task_svc.list_tasks_by_work_order_ids(db, chunk, tenant_no)
for wo_id, tasks in chunk_tasks.items():
tasks_by_wo.setdefault(wo_id, []).extend(tasks)
all_task_ids = [
(t.get("id") or "").strip()
for tasks in tasks_by_wo.values()
for t in tasks
if (t.get("id") or "").strip()
]
results_by_task = result_svc.list_saved_results_by_task_ids(db, all_task_ids, tenant_no)
def pack_orm_wos(wos: List[BizQcWorkOrder]) -> List[Dict[str, Any]]:
packed = []
for wo in wos:
tasks = tasks_by_wo.get(wo.id, [])
packed.append(
{
"id": wo.id,
"workOrderNo": wo.work_order_no or "",
"workOrderType": wo.work_order_type or "",
"workOrderStatus": wo.work_order_status or "",
"workOrderStatusLabel": _wo_status_label(wo.work_order_status or ""),
"tasks": [
_slim_task_for_lineage(
t, results_by_task.get(t.get("id") or "", [])
)
for t in tasks
],
}
)
return packed
def pack_dict_wos(wos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
packed = []
for wo in wos:
wid = (wo.get("id") or "").strip()
tasks = tasks_by_wo.get(wid, [])
packed.append(
{
"id": wid,
"workOrderNo": wo.get("workOrderNo") or "",
"workOrderType": wo.get("workOrderType") or "",
"workOrderStatus": wo.get("workOrderStatus") or "",
"workOrderStatusLabel": wo.get("workOrderStatusLabel")
or _wo_status_label(wo.get("workOrderStatus") or ""),
"tasks": [
_slim_task_for_lineage(
t, results_by_task.get(t.get("id") or "", [])
)
for t in tasks
],
}
)
return packed
out_nodes: List[Dict[str, Any]] = []
for idx, n in enumerate(nodes):
nt = n.get("nodeType") or ""
if nt == "sample":
label = (n.get("sampleCode") or n.get("id") or "").strip()
work_orders = pack_dict_wos(sample_wo_dicts.get(idx, []))
else:
label = (n.get("productCode") or n.get("id") or "").strip()
work_orders = pack_orm_wos(node_work_orders_orm[idx])
out_nodes.append(
{
"nodeType": nt,
"id": n.get("id") or "",
"label": label,
"productName": n.get("productName") or "",
"passage": n.get("passage") or None,
"receptionSource": n.get("receptionSource") or None,
"receptionSourceLabel": n.get("receptionSourceLabel") or None,
"isCurrent": bool(n.get("isCurrent")),
"workOrders": work_orders,
}
)
return out_nodes
def get_product_lineage_qc(
db: Session, product_id: str, tenant_no: str
) -> Optional[Dict[str, Any]]:
chain = get_product_chain(db, product_id, tenant_no)
if not chain:
return None
current = chain.get("current") or {}
flat = _flatten_lineage_nodes(chain)
nodes = _load_lineage_qc_for_nodes(db, flat, tenant_no)
return {
"productId": current.get("id") or product_id,
"productCode": current.get("productCode") or "",
"productName": current.get("productName") or "",
"nodes": nodes,
}
+257
View File
@@ -0,0 +1,257 @@
"""按行同步子表。
带 ID 的行原地修改,没有 ID 时按业务键对上尚未占用的原行,其余才新增。
本次提交里没有的活动行才软删除。不要整表软删后再插入,否则外键会指向已删除的行。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
def _item_id(item: Dict[str, Any]) -> str:
return str(item.get("id") or "").strip()
def _sort_key(row: Any):
order_num = getattr(row, "order_num", None)
create_time = getattr(row, "create_time", None)
return (
order_num if order_num is not None else 0,
create_time or datetime.min,
str(getattr(row, "id", "") or ""),
)
def sync_child_rows(
db: Session,
model,
*,
parent_attr: str,
parent_id: str,
items: List[Dict[str, Any]],
build_row: Callable[[str], Any],
apply_fields: Callable[[Any, Dict[str, Any], int, datetime], None],
tenant_no: Optional[str] = None,
tenant_attr: str = "tenant_no",
user_id: Optional[str] = None,
now: Optional[datetime] = None,
natural_key: Optional[Callable[[Dict[str, Any]], str]] = None,
row_natural_key: Optional[Callable[[Any], str]] = None,
duplicate_message: str = "编号重复",
foreign_message: str = "记录不属于当前数据",
extra_filters: Optional[Dict[str, Any]] = None,
reuse_deleted: bool = False,
) -> List[Any]:
now = now or datetime.now()
payload = list(items or [])
seen_ids = set()
for item in payload:
row_id = _item_id(item)
if not row_id:
continue
if row_id in seen_ids:
raise ValueError(duplicate_message)
seen_ids.add(row_id)
def _scoped(query):
query = query.filter(getattr(model, parent_attr) == parent_id)
if tenant_no is not None and hasattr(model, tenant_attr):
query = query.filter(getattr(model, tenant_attr) == tenant_no)
for key, value in (extra_filters or {}).items():
query = query.filter(getattr(model, key) == value)
return query
active_rows = _scoped(db.query(model).filter(model.del_flag == 0)).all()
active_by_id = {row.id: row for row in active_rows}
for row_id in seen_ids:
if row_id in active_by_id:
continue
other = db.query(model).filter(model.id == row_id).first()
if other is None:
continue
other_parent = getattr(other, parent_attr)
other_tenant = getattr(other, tenant_attr, None) if tenant_no is not None else tenant_no
if other_parent != parent_id or (
tenant_no is not None and (other_tenant or "") != tenant_no
):
raise ValueError(foreign_message)
queues: Dict[str, List[Any]] = {}
deleted_queues: Dict[str, List[Any]] = {}
if natural_key is not None and row_natural_key is not None:
for row in sorted(active_rows, key=_sort_key):
key = (row_natural_key(row) or "").strip()
if not key:
continue
queues.setdefault(key, []).append(row)
if reuse_deleted:
deleted_rows = _scoped(db.query(model).filter(model.del_flag == 1)).all()
for row in sorted(deleted_rows, key=_sort_key):
key = (row_natural_key(row) or "").strip()
if not key:
continue
deleted_queues.setdefault(key, []).append(row)
claimed = set()
assigned: List[Optional[Any]] = [None] * len(payload)
def _take(row: Any) -> Any:
if row.id in claimed:
raise ValueError(duplicate_message)
claimed.add(row.id)
return row
for idx, item in enumerate(payload):
row_id = _item_id(item)
if not row_id:
continue
if row_id in active_by_id:
assigned[idx] = _take(active_by_id[row_id])
continue
row = db.query(model).filter(model.id == row_id).first()
if row is None:
row = build_row(row_id)
db.add(row)
assigned[idx] = _take(row)
for bucket in queues.values():
bucket[:] = [row for row in bucket if row.id not in claimed]
for idx, item in enumerate(payload):
if assigned[idx] is not None:
continue
row = None
if natural_key is not None:
key = (natural_key(item) or "").strip()
bucket = queues.get(key) if key else None
while bucket:
candidate = bucket.pop(0)
if candidate.id not in claimed:
row = candidate
break
if row is None and reuse_deleted and natural_key is not None:
key = (natural_key(item) or "").strip()
bucket = deleted_queues.get(key) if key else None
while bucket:
candidate = bucket.pop(0)
if candidate.id not in claimed:
row = candidate
break
if row is None:
row = build_row(generate_uuid())
db.add(row)
assigned[idx] = _take(row)
result: List[Any] = []
for idx, item in enumerate(payload):
row = assigned[idx]
setattr(row, parent_attr, parent_id)
if tenant_no is not None and hasattr(row, tenant_attr):
setattr(row, tenant_attr, tenant_no)
apply_fields(row, item, idx, now)
row.del_flag = 0
if hasattr(row, "update_by"):
row.update_by = user_id
if hasattr(row, "update_time"):
row.update_time = now
db.add(row)
result.append(row)
if reuse_deleted and row_natural_key is not None:
keys_to_clear = set()
for row in result:
keys_to_clear.add((row_natural_key(row) or "").strip())
for row in active_rows:
if row.id not in claimed:
keys_to_clear.add((row_natural_key(row) or "").strip())
removed_tombstone = False
for key in keys_to_clear:
if not key:
continue
for tomb in deleted_queues.get(key, []):
if tomb.id in claimed:
continue
db.delete(tomb)
removed_tombstone = True
if removed_tombstone:
db.flush()
for row in active_rows:
if row.id in claimed:
continue
row.del_flag = 1
if hasattr(row, "update_by"):
row.update_by = user_id
if hasattr(row, "update_time"):
row.update_time = now
db.add(row)
return result
def sync_link_rows(
db: Session,
model,
*,
parent_attr: str,
parent_id: str,
link_attr: str,
link_ids: List[str],
tenant_no: Optional[str] = None,
user_id: Optional[str] = None,
duplicate_message: str = "关联重复",
foreign_message: str = "关联不属于当前数据",
) -> List[Any]:
"""按关联 id 更新关系行。重新加回已删除的关联时恢复原行,避免唯一索引冲突。"""
now = datetime.now()
ordered: List[Dict[str, Any]] = []
seen = set()
for raw in link_ids or []:
link_id = str(raw or "").strip()
if not link_id or link_id in seen:
continue
seen.add(link_id)
ordered.append({link_attr: link_id})
def build_row(row_id: str):
data = {
"id": row_id,
parent_attr: parent_id,
link_attr: "",
"create_by": user_id,
"create_dept_id": "",
"create_time": now,
}
if tenant_no is not None and hasattr(model, "tenant_no"):
data["tenant_no"] = tenant_no
return model(**data)
def apply_row(row: Any, item: Dict[str, Any], idx: int, _now: datetime) -> None:
setattr(row, link_attr, item[link_attr])
if tenant_no is not None and hasattr(row, "tenant_no"):
row.tenant_no = tenant_no
if hasattr(row, "order_num"):
row.order_num = idx
return sync_child_rows(
db,
model,
parent_attr=parent_attr,
parent_id=parent_id,
tenant_no=tenant_no,
items=ordered,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: item[link_attr],
row_natural_key=lambda row: str(getattr(row, link_attr) or "").strip(),
duplicate_message=duplicate_message,
foreign_message=foreign_message,
reuse_deleted=True,
)
+1 -1
View File
@@ -405,7 +405,7 @@ def submit_refund(
) -> BizPilotPayment:
row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no)
if row.pay_status != "yjf":
raise ValueError("仅已缴费状态可发起退费")
raise ValueError("仅付款完成状态可发起退费")
if refund_amount is None or Decimal(str(refund_amount)) <= 0:
raise ValueError("请填写有效的退费金额")
row.pay_status = "tfqrz"
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -24,7 +24,7 @@ def _resolve_issued_user_display_name(
uid = (user_id or "").strip()
if not uid:
return None
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
u = db.query(SysUser).filter(SysUser.id == uid).first()
if not u:
return None
return (u.nick_name or u.user_no or "").strip() or None
@@ -41,7 +41,7 @@ def enrich_issued_user_names(db: Session, rows: List[Dict[str, Any]]) -> None:
return
users = (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(missing_uids), SysUser.del_flag == 0)
.filter(SysUser.id.in_(missing_uids))
.all()
)
name_map = {(u.id or ""): ((u.nick_name or u.user_no or "").strip()) for u in users}
+327 -3
View File
@@ -9,14 +9,43 @@ 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
from app.models.models import (
BizCollectionHospital,
BizCollectionRegion,
BizCollectionRegionStaff,
SysDept,
SysUser,
)
def _region_to_dict(r: BizCollectionRegion, hospital_names: Optional[List[str]] = None) -> Dict[str, Any]:
def staff_display_name(nick_name: Optional[str], user_no: Optional[str]) -> str:
n = (nick_name or "").strip()
if n:
return n
return (user_no or "").strip()
def build_staff_leader_name_lists(rows: List[Dict[str, Any]]) -> Tuple[List[str], List[str]]:
"""rows: dicts with nickName/isLeader (already sorted leaders-first preferred)."""
leaders = [staff_display_name(r.get("nickName"), r.get("userNo")) for r in rows if r.get("isLeader")]
# Keep leader-first order if caller sorted; otherwise partition
non_leaders = [staff_display_name(r.get("nickName"), r.get("userNo")) for r in rows if not r.get("isLeader")]
staff_names = leaders + non_leaders
return staff_names, leaders
def _region_to_dict(
r: BizCollectionRegion,
hospital_names: Optional[List[str]] = None,
staff_names: Optional[List[str]] = None,
leader_names: Optional[List[str]] = None,
) -> Dict[str, Any]:
return {
"id": r.id,
"regionName": r.region_name,
"hospitalNames": hospital_names or [],
"staffNames": staff_names or [],
"leaderNames": leader_names or [],
"tenantNo": r.tenant_no,
"orderNum": r.order_num if r.order_num is not None else 0,
"remark": r.remark or "",
@@ -46,6 +75,43 @@ def _hospital_names_by_region_ids(db: Session, region_ids: List[str]) -> Dict[st
return hospital_map
def _staff_names_by_region_ids(
db: Session, region_ids: List[str]
) -> Dict[str, Tuple[List[str], List[str]]]:
"""Return region_id -> (staffNames, leaderNames). Leaders first in staffNames."""
out: Dict[str, Tuple[List[str], List[str]]] = {rid: ([], []) for rid in region_ids}
if not region_ids:
return out
rows = (
db.query(BizCollectionRegionStaff, SysUser)
.outerjoin(SysUser, SysUser.id == BizCollectionRegionStaff.user_id)
.filter(
and_(
BizCollectionRegionStaff.region_id.in_(region_ids),
BizCollectionRegionStaff.del_flag == 0,
)
)
.order_by(
BizCollectionRegionStaff.is_leader.desc(),
BizCollectionRegionStaff.order_num.asc(),
BizCollectionRegionStaff.create_time.desc(),
)
.all()
)
buckets: Dict[str, List[Dict[str, Any]]] = {rid: [] for rid in region_ids}
for staff, user in rows:
buckets.setdefault(staff.region_id, []).append(
{
"nickName": (user.nick_name if user else "") or "",
"userNo": (user.user_no if user else "") or "",
"isLeader": bool(staff.is_leader),
}
)
for rid, items in buckets.items():
out[rid] = build_staff_leader_name_lists(items)
return out
def _hospital_to_dict(h: BizCollectionHospital) -> Dict[str, Any]:
return {
"id": h.id,
@@ -59,6 +125,30 @@ def _hospital_to_dict(h: BizCollectionHospital) -> Dict[str, Any]:
}
def _staff_to_dict(
s: BizCollectionRegionStaff,
*,
nick_name: str = "",
user_no: str = "",
dept_name: str = "",
) -> Dict[str, Any]:
return {
"id": s.id,
"regionId": s.region_id,
"userId": s.user_id,
"userName": staff_display_name(nick_name, user_no),
"nickName": nick_name or "",
"userNo": user_no or "",
"deptName": dept_name or "",
"isLeader": bool(s.is_leader),
"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_collection_regions(
db: Session,
*,
@@ -90,7 +180,16 @@ def list_collection_regions(
region_ids = [r.id for r in rows]
hospital_map = _hospital_names_by_region_ids(db, region_ids)
return [_region_to_dict(x, hospital_names=hospital_map.get(x.id, [])) for x in rows], int(total)
staff_map = _staff_names_by_region_ids(db, region_ids)
return [
_region_to_dict(
x,
hospital_names=hospital_map.get(x.id, []),
staff_names=staff_map.get(x.id, ([], []))[0],
leader_names=staff_map.get(x.id, ([], []))[1],
)
for x in rows
], int(total)
def get_collection_region(db: Session, region_id: str, tenant_no: Optional[str] = None) -> Optional[BizCollectionRegion]:
@@ -183,6 +282,20 @@ def soft_delete_collection_region(db: Session, region_id: str, tenant_no: Option
h.del_flag = 1
h.update_time = now
db.add(h)
staff_subs = (
db.query(BizCollectionRegionStaff)
.filter(
and_(
BizCollectionRegionStaff.region_id == region_id,
BizCollectionRegionStaff.del_flag == 0,
)
)
.all()
)
for s in staff_subs:
s.del_flag = 1
s.update_time = now
db.add(s)
db.commit()
@@ -343,6 +456,217 @@ def soft_delete_hospital(db: Session, hospital_id: str, tenant_no: Optional[str]
db.commit()
def map_staff_region_rows_to_list(rows: List[Tuple[str, str]]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for rid, name in rows:
rid = (rid or "").strip()
if not rid:
continue
out.append({"id": rid, "regionName": (name or "").strip()})
return out
def list_regions_by_staff_user(
db: Session, user_id: str, tenant_no: Optional[str] = None
) -> List[Dict[str, Any]]:
uid = (user_id or "").strip()
if not uid:
return []
q = (
db.query(BizCollectionRegionStaff.region_id, BizCollectionRegion.region_name)
.join(
BizCollectionRegion,
BizCollectionRegion.id == BizCollectionRegionStaff.region_id,
)
.filter(
BizCollectionRegionStaff.user_id == uid,
BizCollectionRegionStaff.del_flag == 0,
BizCollectionRegion.del_flag == 0,
)
)
if tenant_no:
q = q.filter(
BizCollectionRegionStaff.tenant_no == tenant_no,
BizCollectionRegion.tenant_no == tenant_no,
)
q = q.order_by(
BizCollectionRegionStaff.order_num.asc(),
BizCollectionRegionStaff.create_time.desc(),
)
return map_staff_region_rows_to_list([(r[0], r[1]) for r in q.all()])
def list_staff(
db: Session,
*,
region_id: str,
page: int = 1,
page_size: int = 10,
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(BizCollectionRegionStaff).filter(
and_(BizCollectionRegionStaff.region_id == region_id, BizCollectionRegionStaff.del_flag == 0)
)
if tenant_no:
q0 = q0.filter(BizCollectionRegionStaff.tenant_no == tenant_no)
return q0
total = _filtered().order_by(None).with_entities(func.count(BizCollectionRegionStaff.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
_filtered()
.order_by(
BizCollectionRegionStaff.is_leader.desc(),
BizCollectionRegionStaff.order_num.asc(),
BizCollectionRegionStaff.create_time.desc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
user_ids = [r.user_id for r in rows]
user_map: Dict[str, SysUser] = {}
dept_map: Dict[str, str] = {}
if user_ids:
for u in db.query(SysUser).filter(SysUser.id.in_(user_ids)).all():
user_map[u.id] = u
dept_ids = [u.dept_id for u in user_map.values() if u.dept_id]
if dept_ids:
for d in db.query(SysDept).filter(SysDept.id.in_(dept_ids), SysDept.del_flag == 0).all():
dept_map[d.id] = d.dept_name or ""
items: List[Dict[str, Any]] = []
for s in rows:
u = user_map.get(s.user_id)
items.append(
_staff_to_dict(
s,
nick_name=(u.nick_name if u else "") or "",
user_no=(u.user_no if u else "") or "",
dept_name=dept_map.get((u.dept_id if u else "") or "", ""),
)
)
return items, int(total)
def get_staff(db: Session, staff_id: str, tenant_no: Optional[str] = None) -> Optional[BizCollectionRegionStaff]:
q = db.query(BizCollectionRegionStaff).filter(
and_(BizCollectionRegionStaff.id == staff_id, BizCollectionRegionStaff.del_flag == 0)
)
if tenant_no:
q = q.filter(BizCollectionRegionStaff.tenant_no == tenant_no)
return q.first()
def create_staff(
db: Session,
*,
region_id: str,
user_id: str,
is_leader: bool = False,
tenant_no: str = "zkxw",
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id_actor: Optional[str] = None,
) -> BizCollectionRegionStaff:
region = get_collection_region(db, region_id, tenant_no=tenant_no)
if not region:
raise ValueError("采集地区不存在")
uid = (user_id or "").strip()
if not uid:
raise ValueError("请选择系统人员")
user = (
db.query(SysUser)
.filter(
and_(
SysUser.id == uid,
SysUser.del_flag == 0,
SysUser.tenant_no == tenant_no,
)
)
.first()
)
if not user:
raise ValueError("用户不存在")
if (user.status or "") != "1":
raise ValueError("用户已停用,无法绑定")
exists = (
db.query(BizCollectionRegionStaff)
.filter(
and_(
BizCollectionRegionStaff.region_id == region_id,
BizCollectionRegionStaff.user_id == uid,
BizCollectionRegionStaff.del_flag == 0,
BizCollectionRegionStaff.tenant_no == tenant_no,
)
)
.first()
)
if exists:
raise ValueError("该人员已绑定到此地区")
obj = BizCollectionRegionStaff(
id=generate_uuid(),
region_id=region_id,
user_id=uid,
is_leader=1 if is_leader 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_actor,
create_dept_id="",
create_time=datetime.now(),
update_by=user_id_actor,
update_time=datetime.now(),
del_flag=0,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_staff(
db: Session,
staff_id: str,
*,
is_leader: Optional[bool] = None,
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
tenant_no: Optional[str] = None,
) -> BizCollectionRegionStaff:
obj = get_staff(db, staff_id, tenant_no=tenant_no)
if not obj:
raise ValueError("绑定记录不存在")
if is_leader is not None:
obj.is_leader = 1 if is_leader else 0
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_staff(db: Session, staff_id: str, tenant_no: Optional[str] = None) -> None:
obj = get_staff(db, staff_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
+32 -23
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import BizConsumablePack, BizConsumablePackItem
@@ -230,37 +231,45 @@ def update_consumable_pack(
db.add(obj)
if items is not None:
existing = (
db.query(BizConsumablePackItem)
.filter(and_(BizConsumablePackItem.pack_id == pack_id, BizConsumablePackItem.del_flag == 0))
.all()
)
now = datetime.now()
for ei in existing:
ei.del_flag = 1
ei.update_time = now
db.add(ei)
for it in items:
item_obj = BizConsumablePackItem(
id=generate_uuid(),
def build_item(row_id):
return BizConsumablePackItem(
id=row_id,
pack_id=pack_id,
consumable_code=(it.get("consumable_code") or "").strip(),
consumable_name=(it.get("consumable_name") or "").strip(),
spec_model=(it.get("spec_model") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
consumable_code="",
consumable_name="",
tenant_no=obj.tenant_no,
order_num=0,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(item_obj)
def apply_item(row, it, _idx, _now):
row.consumable_code = (it.get("consumable_code") or "").strip()
row.consumable_name = (it.get("consumable_name") or "").strip()
row.spec_model = (it.get("spec_model") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = 0
sync_child_rows(
db,
BizConsumablePackItem,
parent_attr="pack_id",
parent_id=pack_id,
tenant_no=obj.tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_item,
apply_fields=apply_item,
natural_key=lambda it: (it.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材包明细重复",
foreign_message="耗材包明细不属于当前耗材包",
)
db.commit()
db.refresh(obj)
+43 -5
View File
@@ -19,6 +19,10 @@ def _row_to_dict(c: BizCustomer) -> Dict[str, Any]:
"sex": (c.sex or "2").strip() if c.sex else "2",
"idCardNo": c.id_card_no or "",
"age": c.age,
"birthYm": c.birth_ym or "",
"ethnic": c.ethnic or "",
"email": c.email or "",
"address": c.address or "",
"phoneNo": c.phone_no or "",
"customerTags": c.customer_tags or "",
"tenantNo": c.tenant_no,
@@ -110,12 +114,18 @@ def create_customer(
sex: str = "2",
id_card_no: Optional[str] = None,
age: Optional[int] = None,
birth_ym: Optional[str] = None,
ethnic: Optional[str] = None,
email: Optional[str] = None,
address: Optional[str] = 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,
commit: bool = True,
create_dept_id: str = "",
) -> BizCustomer:
name = (customer_name or "").strip()
if not name:
@@ -129,21 +139,28 @@ def create_customer(
sex=(sex or "2")[:1] if sex else "2",
id_card_no=ic,
age=age,
birth_ym=(birth_ym or "").strip()[:6] or None,
ethnic=(ethnic or "").strip() or None,
email=(email or "").strip() or None,
address=(address or "").strip() or None,
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_dept_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)
if commit:
db.commit()
db.refresh(obj)
else:
db.flush()
return obj
@@ -155,6 +172,10 @@ def update_customer(
sex: Optional[str] = None,
id_card_no: Optional[str] = None,
age: Optional[int] = None,
birth_ym: Optional[str] = None,
ethnic: Optional[str] = None,
email: Optional[str] = None,
address: Optional[str] = None,
phone_no: Optional[str] = None,
customer_tags: Optional[str] = None,
order_num: Optional[int] = None,
@@ -179,6 +200,14 @@ def update_customer(
obj.id_card_no = ic
if age is not None:
obj.age = age
if birth_ym is not None:
obj.birth_ym = birth_ym.strip()[:6] or None
if ethnic is not None:
obj.ethnic = ethnic.strip() or None
if email is not None:
obj.email = email.strip() or None
if address is not None:
obj.address = address.strip() or None
if phone_no is not None:
obj.phone_no = phone_no.strip() or None
if customer_tags is not None:
@@ -195,14 +224,23 @@ def update_customer(
return obj
def soft_delete_customer(db: Session, customer_id: str, tenant_no: Optional[str] = None) -> None:
def soft_delete_customer(
db: Session,
customer_id: str,
tenant_no: Optional[str] = None,
*,
commit: bool = True,
) -> 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()
if commit:
db.commit()
else:
db.flush()
def total_pages(total: int, page_size: int) -> int:
+80
View File
@@ -0,0 +1,80 @@
"""客户服务字典 CRUD。"""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import BizCustomerServiceDict
from app.services.sample_customer_service_register import normalize_dict_value
def list_dict_values(db: Session, tenant_no: str, dict_type: str) -> List[str]:
dtype = (dict_type or "").strip()
if not dtype:
return []
rows = (
db.query(BizCustomerServiceDict)
.filter(
BizCustomerServiceDict.tenant_no == tenant_no,
BizCustomerServiceDict.dict_type == dtype,
BizCustomerServiceDict.del_flag == 0,
)
.order_by(
BizCustomerServiceDict.order_num.asc(),
BizCustomerServiceDict.dict_value.asc(),
)
.all()
)
return [row.dict_value for row in rows if row.dict_value]
def ensure_dict_value(
db: Session,
tenant_no: str,
dict_type: str,
value,
*,
user_id: Optional[str],
is_system: bool = False,
) -> Optional[str]:
normalized = normalize_dict_value(value)
if normalized is None:
return None
dtype = (dict_type or "").strip()
if not dtype:
return None
existing = (
db.query(BizCustomerServiceDict)
.filter(
BizCustomerServiceDict.tenant_no == tenant_no,
BizCustomerServiceDict.dict_type == dtype,
BizCustomerServiceDict.dict_value == normalized,
BizCustomerServiceDict.del_flag == 0,
)
.first()
)
if existing:
return existing.dict_value
now = datetime.now()
row = BizCustomerServiceDict(
id=generate_uuid(),
dict_type=dtype,
dict_value=normalized,
is_system="1" if is_system else "0",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
db.flush()
return normalized
+1 -1
View File
@@ -170,7 +170,7 @@ def list_env_detect_records(
page_size = min(max(1, page_size), 100)
rows = (
_filtered()
.order_by(BizEnvDetectRecord.create_time.desc())
.order_by(BizEnvDetectRecord.detect_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
+82 -54
View File
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
from app.models.models import SysUser
from app.models.material import (
@@ -442,6 +443,44 @@ def _resolve_unused_item_fields(
return out
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
usage = (usage_id or "").strip()
if usage:
return f"usage:{usage}"
batch = (batch_id or "").strip()
if batch:
return f"batch:{batch}"
return ""
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
return {
"id": str(it.get("id") or "").strip(),
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
"material_name": name,
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
"unit": (it.get("unit") or "").strip() or None,
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
"manufacturer": (it.get("manufacturer") or "").strip() or None,
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
"holder": (it.get("holder") or "").strip() or None,
"unused_qty": Decimal(str(qty)),
"remark": (it.get("remark") or "").strip() or None,
}
def save_unused_lines(
db: Session,
check_id: str,
@@ -458,72 +497,61 @@ def save_unused_lines(
raise ValueError("仅进行中的盘点单可登记未用完物料")
now = datetime.now()
old_rows = (
db.query(BizMaterialStockCheckUnusedLine)
.filter(
BizMaterialStockCheckUnusedLine.check_id == check_id,
BizMaterialStockCheckUnusedLine.del_flag == 0,
)
.all()
)
for r in old_rows:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
db.add(r)
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
count = 0
for it in items or []:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
qty_dec = Decimal(str(qty))
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
row = BizMaterialStockCheckUnusedLine(
id=generate_uuid(),
def build_row(row_id: str):
return BizMaterialStockCheckUnusedLine(
id=row_id,
check_id=check_id,
usage_id=usage_id,
batch_id=batch_id,
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
material_name=name,
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
stock_in_date=stock_in_date,
expire_date=expire_date,
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
manufacturer=(it.get("manufacturer") or "").strip() or None,
unit_price=(
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
else None
),
holder=(it.get("holder") or "").strip() or None,
unused_qty=qty_dec,
material_name="",
unused_qty=0,
tenant_no=tenant_no,
remark=(it.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
count += 1
def apply_row(row, it, idx, _now):
row.usage_id = it["usage_id"]
row.batch_id = it["batch_id"]
row.material_code = it["material_code"]
row.material_name = it["material_name"]
row.spec_model = it["spec_model"]
row.unit = it["unit"]
row.stock_in_no = it["stock_in_no"]
row.stock_in_date = it["stock_in_date"]
row.expire_date = it["expire_date"]
row.catalog_no = it["catalog_no"]
row.batch_no = it["batch_no"]
row.manufacturer = it["manufacturer"]
row.unit_price = it["unit_price"]
row.holder = it["holder"]
row.unused_qty = it["unused_qty"]
row.remark = it["remark"]
row.order_num = idx
saved = sync_child_rows(
db,
BizMaterialStockCheckUnusedLine,
parent_attr="check_id",
parent_id=check_id,
tenant_no=tenant_no,
items=prepared,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
duplicate_message="未用完明细重复",
foreign_message="未用完明细不属于当前盘点单",
)
check.update_by = user_id
check.update_time = now
db.add(check)
db.commit()
return count
return len(saved)
def complete_check(
+111 -338
View File
@@ -1,16 +1,15 @@
"""OA 审批 CRUD(工作流角色、分组、应用、草稿等)"""
"""OA 审批 CRUD(分组、应用、草稿等)"""
from __future__ import annotations
import copy
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.core.db_collation import eq_unicode
from app.core.utils import generate_uuid
from app.models.models import OaApp, OaAppGroup, OaAppVersion, OaRole, OaRoleUser, SysUser
from app.models.models import OaApp, OaAppGroup, OaAppVersion
DEFAULT_ADVANCED_SETTINGS: Dict[str, Any] = {
"supervisorFallbackToDeptLeader": True,
@@ -19,6 +18,7 @@ DEFAULT_ADVANCED_SETTINGS: Dict[str, Any] = {
"commentRequired": False,
"allowAddSign": True,
"allowReturn": True,
"autoPassWhenApproverIsInitiator": False,
}
@@ -40,322 +40,6 @@ def _loads(text: Optional[str], default: Any = None) -> Any:
except (TypeError, ValueError):
return default
PRESET_ROLES = (
("GM", "总经理"),
("CHAIRMAN", "董事长"),
)
def _role_to_dict(r: OaRole, *, member_count: int = 0) -> Dict[str, Any]:
return {
"id": r.id,
"roleName": r.role_name,
"roleCode": r.role_code,
"roleKind": r.role_kind,
"status": r.status,
"tenantNo": r.tenant_no,
"orderNum": r.order_num if r.order_num is not None else 0,
"remark": r.remark or "",
"memberCount": member_count,
"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 ensure_presets(db: Session, tenant_no: str, user_id: Optional[str] = None) -> List[OaRole]:
"""幂等写入预置角色 GM / CHAIRMAN。"""
now = datetime.now()
created: List[OaRole] = []
for code, name in PRESET_ROLES:
exists = (
db.query(OaRole)
.filter(
OaRole.tenant_no == tenant_no,
OaRole.role_code == code,
OaRole.del_flag == 0,
)
.first()
)
if exists:
continue
obj = OaRole(
id=generate_uuid(),
role_name=name,
role_code=code,
role_kind="preset",
status="1",
tenant_no=tenant_no,
order_num=0 if code == "GM" else 1,
remark="系统预置工作流角色",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
created.append(obj)
if created:
db.commit()
for obj in created:
db.refresh(obj)
return created
def _member_count_map(db: Session, role_ids: List[str], tenant_no: str) -> Dict[str, int]:
if not role_ids:
return {}
rows = (
db.query(OaRoleUser.oa_role_id, OaRoleUser.id)
.filter(
OaRoleUser.oa_role_id.in_(role_ids),
OaRoleUser.tenant_no == tenant_no,
OaRoleUser.del_flag == 0,
)
.all()
)
counts: Dict[str, int] = {rid: 0 for rid in role_ids}
for rid, _ in rows:
counts[rid] = counts.get(rid, 0) + 1
return counts
def list_roles(
db: Session,
*,
tenant_no: str,
keyword: Optional[str] = None,
status: Optional[str] = None,
) -> List[Dict[str, Any]]:
ensure_presets(db, tenant_no)
q = db.query(OaRole).filter(OaRole.tenant_no == tenant_no, OaRole.del_flag == 0)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q = q.filter((OaRole.role_name.like(like)) | (OaRole.role_code.like(like)))
if status:
q = q.filter(OaRole.status == status)
rows = q.order_by(OaRole.role_kind.desc(), OaRole.order_num.asc(), OaRole.create_time.desc()).all()
counts = _member_count_map(db, [r.id for r in rows], tenant_no)
return [_role_to_dict(r, member_count=counts.get(r.id, 0)) for r in rows]
def get_role(db: Session, role_id: str, tenant_no: str) -> Optional[OaRole]:
return (
db.query(OaRole)
.filter(OaRole.id == role_id, OaRole.tenant_no == tenant_no, OaRole.del_flag == 0)
.first()
)
def create_role(
db: Session,
*,
tenant_no: str,
role_name: str,
role_code: str,
status: str = "1",
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
) -> OaRole:
name = (role_name or "").strip()
code = (role_code or "").strip().upper()
if not name:
raise ValueError("角色名称不能为空")
if not code:
raise ValueError("角色编码不能为空")
if code in {c for c, _ in PRESET_ROLES}:
raise ValueError("该编码为系统预置,请勿占用")
exists = (
db.query(OaRole)
.filter(OaRole.tenant_no == tenant_no, OaRole.role_code == code, OaRole.del_flag == 0)
.first()
)
if exists:
raise ValueError("角色编码已存在")
now = datetime.now()
obj = OaRole(
id=generate_uuid(),
role_name=name,
role_code=code,
role_kind="custom",
status=status or "1",
tenant_no=tenant_no,
order_num=order_num if order_num is not None else 10,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_role(
db: Session,
role_id: str,
*,
tenant_no: str,
role_name: Optional[str] = None,
status: Optional[str] = None,
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
) -> OaRole:
obj = get_role(db, role_id, tenant_no)
if not obj:
raise ValueError("角色不存在")
if role_name is not None:
name = (role_name or "").strip()
if not name:
raise ValueError("角色名称不能为空")
obj.role_name = name
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 delete_role(db: Session, role_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None:
obj = get_role(db, role_id, tenant_no)
if not obj:
raise ValueError("角色不存在")
if obj.role_kind == "preset":
raise ValueError("预置角色不可删除")
obj.del_flag = 1
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.commit()
def list_role_users(db: Session, role_id: str, tenant_no: str) -> List[Dict[str, Any]]:
rows = (
db.query(OaRoleUser, SysUser)
.outerjoin(
SysUser,
and_(eq_unicode(SysUser.id, OaRoleUser.user_id), SysUser.del_flag == 0),
)
.filter(
OaRoleUser.oa_role_id == role_id,
OaRoleUser.tenant_no == tenant_no,
OaRoleUser.del_flag == 0,
)
.all()
)
out: List[Dict[str, Any]] = []
for ru, u in rows:
out.append(
{
"id": ru.id,
"userId": ru.user_id,
"nickName": (u.nick_name if u else "") or "",
"userNo": (u.user_no if u else "") or "",
}
)
return out
def replace_role_users(
db: Session,
role_id: str,
*,
tenant_no: str,
user_ids: List[str],
operator_id: Optional[str] = None,
) -> None:
obj = get_role(db, role_id, tenant_no)
if not obj:
raise ValueError("角色不存在")
now = datetime.now()
existing = (
db.query(OaRoleUser)
.filter(
OaRoleUser.oa_role_id == role_id,
OaRoleUser.tenant_no == tenant_no,
OaRoleUser.del_flag == 0,
)
.all()
)
for row in existing:
row.del_flag = 1
row.update_by = operator_id
row.update_time = now
db.add(row)
seen = set()
for uid in user_ids or []:
uid = (uid or "").strip()
if not uid or uid in seen:
continue
seen.add(uid)
db.add(
OaRoleUser(
id=generate_uuid(),
oa_role_id=role_id,
user_id=uid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=operator_id,
create_dept_id="",
create_time=now,
update_by=operator_id,
update_time=now,
del_flag=0,
)
)
db.commit()
def list_oa_role_user_ids(db: Session, role_ids: List[str], tenant_no: str) -> List[str]:
if not role_ids:
return []
enabled = {
r.id
for r in db.query(OaRole)
.filter(
OaRole.id.in_(role_ids),
OaRole.tenant_no == tenant_no,
OaRole.del_flag == 0,
OaRole.status == "1",
)
.all()
}
if not enabled:
return []
rows = (
db.query(OaRoleUser.user_id, OaRoleUser.oa_role_id)
.filter(
OaRoleUser.oa_role_id.in_(list(enabled)),
OaRoleUser.tenant_no == tenant_no,
OaRoleUser.del_flag == 0,
)
.all()
)
out: List[str] = []
seen = set()
for uid, rid in rows:
if rid in enabled and uid and uid not in seen:
seen.add(uid)
out.append(uid)
return out
# ---------------------------------------------------------------------------
# 分组 oa_app_group
# ---------------------------------------------------------------------------
@@ -525,6 +209,9 @@ def list_apps(
like = f"%{kw}%"
q = q.filter(OaApp.app_name.like(like))
rows = q.order_by(OaApp.order_num.asc(), OaApp.create_time.desc()).all()
if not rows:
return []
group_ids = list({r.group_id for r in rows if r.group_id})
group_names: Dict[str, str] = {}
if group_ids:
@@ -534,37 +221,46 @@ def list_apps(
.all()
):
group_names[g.id] = g.group_name
out: List[Dict[str, Any]] = []
for r in rows:
draft = (
db.query(OaAppVersion.id)
app_ids = [r.id for r in rows]
# 批量取草稿/最新发布版本号,避免 N+1,且不拉取 form/flow 大 JSON
draft_app_ids = {
app_id
for (app_id,) in (
db.query(OaAppVersion.app_id)
.filter(
OaAppVersion.app_id == r.id,
OaAppVersion.app_id.in_(app_ids),
OaAppVersion.tenant_no == tenant_no,
OaAppVersion.status == "draft",
OaAppVersion.del_flag == 0,
)
.first()
.distinct()
.all()
)
pub = (
db.query(OaAppVersion)
}
latest_published: Dict[str, int] = {
app_id: version_no
for app_id, version_no in (
db.query(OaAppVersion.app_id, OaAppVersion.version_no)
.filter(
OaAppVersion.app_id == r.id,
OaAppVersion.app_id.in_(app_ids),
OaAppVersion.tenant_no == tenant_no,
OaAppVersion.is_latest_published == "1",
OaAppVersion.del_flag == 0,
)
.first()
.all()
)
out.append(
_app_to_dict(
r,
group_name=group_names.get(r.group_id or "", ""),
has_draft=bool(draft),
latest_published=pub.version_no if pub else None,
)
}
return [
_app_to_dict(
r,
group_name=group_names.get(r.group_id or "", ""),
has_draft=r.id in draft_app_ids,
latest_published=latest_published.get(r.id),
)
return out
for r in rows
]
def _create_version(
@@ -668,6 +364,83 @@ def create_app(
return obj
def _copied_app_name(source_name: str) -> str:
"""原名称 +「复制」;超 50 字时截断前缀并保留后缀。"""
suffix = "复制"
base = (source_name or "").strip()
candidate = f"{base}{suffix}"
if len(candidate) <= 50:
return candidate
max_base = max(0, 50 - len(suffix))
return f"{base[:max_base]}{suffix}"
def copy_app(
db: Session,
source_app_id: str,
*,
tenant_no: str,
user_id: Optional[str] = None,
) -> OaApp:
"""复制应用:基础信息 + 草稿/最新发布版的表单·流程·高级设置,结果为未发布草稿。"""
src = get_app(db, source_app_id, tenant_no)
if not src:
raise ValueError("应用不存在")
source_ver = _find_draft(db, source_app_id, tenant_no) or _find_latest_published(
db, source_app_id, tenant_no
)
if source_ver:
form_schema = copy.deepcopy(_loads(source_ver.form_schema_json, {}) or {})
flow_tree = copy.deepcopy(_loads(source_ver.flow_tree_json, {}) or {})
advanced = copy.deepcopy(
_loads(source_ver.advanced_settings_json, DEFAULT_ADVANCED_SETTINGS)
or DEFAULT_ADVANCED_SETTINGS
)
else:
form_schema = {}
flow_tree = {}
advanced = dict(DEFAULT_ADVANCED_SETTINGS)
now = datetime.now()
obj = OaApp(
id=generate_uuid(),
group_id=src.group_id,
app_name=_copied_app_name(src.app_name),
icon_url=src.icon_url,
description=src.description,
initiator_scope=src.initiator_scope or "all",
initiator_user_ids=src.initiator_user_ids,
admin_scope=src.admin_scope or "all_oa_admin",
admin_user_ids=src.admin_user_ids,
status=src.status or "1",
tenant_no=tenant_no,
order_num=src.order_num if src.order_num is not None else 0,
remark=src.remark,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
_create_version(
db,
app_id=obj.id,
tenant_no=tenant_no,
version_no=1,
status="draft",
form_schema=form_schema,
flow_tree=flow_tree,
advanced=advanced,
user_id=user_id,
)
db.commit()
db.refresh(obj)
return obj
def update_app(
db: Session,
app_id: str,
+381
View File
@@ -0,0 +1,381 @@
"""OA 发起审批草稿 CRUD(oa_instance_draft)"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud import oa_approval as oa_crud
from app.models.models import OaApp, OaAppGroup, OaAppVersion, OaInstanceDraft
from app.services.oa_related_approval import flatten_form_fields_for_summary
def _loads(raw: Any, default: Any) -> Any:
if raw is None or raw == "":
return default
if isinstance(raw, (dict, list)):
return raw
try:
return json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
return default
def _dumps(obj: Any) -> Optional[str]:
if obj is None:
return None
return json.dumps(obj, ensure_ascii=False)
def row_to_dict(
d: OaInstanceDraft,
*,
app_name: str = "",
version_no: Optional[int] = None,
group_id: str = "",
group_name: str = "",
include_payload: bool = False,
) -> Dict[str, Any]:
out: Dict[str, Any] = {
"id": d.id,
"appId": d.app_id,
"appVersionId": d.app_version_id,
"appName": app_name or "",
"versionNo": version_no,
"groupId": group_id or "",
"groupName": group_name or "",
"title": d.title or "",
"ownerUserId": d.owner_user_id,
"initiatorDeptId": d.initiator_dept_id or "",
"status": "draft",
"createTime": d.create_time.strftime("%Y-%m-%d %H:%M:%S") if d.create_time else "",
"updateTime": d.update_time.strftime("%Y-%m-%d %H:%M:%S") if d.update_time else "",
}
if include_payload:
out["formData"] = _loads(d.form_data_json, {}) or {}
out["initiatorSelectByNode"] = _loads(d.initiator_select_json, {}) or {}
return out
class DraftAccessError(ValueError):
"""草稿不存在或无权访问"""
def get_owned_draft(
db: Session,
draft_id: str,
*,
tenant_no: str,
owner_user_id: str,
) -> OaInstanceDraft:
row = (
db.query(OaInstanceDraft)
.filter(
OaInstanceDraft.id == draft_id,
OaInstanceDraft.tenant_no == tenant_no,
OaInstanceDraft.owner_user_id == owner_user_id,
OaInstanceDraft.del_flag == 0,
)
.first()
)
if not row:
raise DraftAccessError("草稿不存在或无权访问")
return row
def _enrich(db: Session, d: OaInstanceDraft, *, include_payload: bool = False) -> Dict[str, Any]:
app = (
db.query(OaApp)
.filter(OaApp.id == d.app_id, OaApp.tenant_no == d.tenant_no, OaApp.del_flag == 0)
.first()
)
ver = (
db.query(OaAppVersion)
.filter(OaAppVersion.id == d.app_version_id, OaAppVersion.del_flag == 0)
.first()
)
group_name = ""
group_id = ""
if app and app.group_id:
group_id = app.group_id
g = (
db.query(OaAppGroup)
.filter(OaAppGroup.id == app.group_id, OaAppGroup.del_flag == 0)
.first()
)
if g:
group_name = g.group_name or ""
out = row_to_dict(
d,
app_name=(app.app_name if app else "") or "",
version_no=ver.version_no if ver else None,
group_id=group_id,
group_name=group_name,
include_payload=include_payload,
)
if include_payload and "formData" in out:
from app.services.oa_datetime_normalize import normalize_datetime_form_values
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
out["formData"] = normalize_datetime_form_values(schema, out.get("formData") or {})
return out
def count_drafts(db: Session, *, tenant_no: str, owner_user_id: str) -> int:
return (
db.query(OaInstanceDraft)
.filter(
OaInstanceDraft.tenant_no == tenant_no,
OaInstanceDraft.owner_user_id == owner_user_id,
OaInstanceDraft.del_flag == 0,
)
.count()
)
def list_drafts(
db: Session,
*,
tenant_no: str,
owner_user_id: str,
keyword: Optional[str] = None,
group_id: Optional[str] = None,
update_time_from: Optional[datetime] = None,
update_time_to: Optional[datetime] = None,
page: int = 1,
page_size: int = 20,
) -> Tuple[List[Dict[str, Any]], int]:
q = (
db.query(OaInstanceDraft, OaApp, OaAppVersion, OaAppGroup)
.outerjoin(
OaApp,
(OaApp.id == OaInstanceDraft.app_id)
& (OaApp.tenant_no == OaInstanceDraft.tenant_no)
& (OaApp.del_flag == 0),
)
.outerjoin(
OaAppVersion,
(OaAppVersion.id == OaInstanceDraft.app_version_id) & (OaAppVersion.del_flag == 0),
)
.outerjoin(
OaAppGroup,
(OaAppGroup.id == OaApp.group_id) & (OaAppGroup.del_flag == 0),
)
.filter(
OaInstanceDraft.tenant_no == tenant_no,
OaInstanceDraft.owner_user_id == owner_user_id,
OaInstanceDraft.del_flag == 0,
)
)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q = q.filter(or_(OaApp.app_name.like(like), OaInstanceDraft.title.like(like)))
gid = (group_id or "").strip()
if gid:
q = q.filter(OaApp.group_id == gid)
if update_time_from is not None:
q = q.filter(OaInstanceDraft.update_time >= update_time_from)
if update_time_to is not None:
q = q.filter(OaInstanceDraft.update_time <= update_time_to)
total = q.order_by(None).with_entities(func.count(OaInstanceDraft.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
q.order_by(OaInstanceDraft.update_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
items: List[Dict[str, Any]] = []
for d, app, ver, group in rows:
item = row_to_dict(
d,
app_name=(app.app_name if app else "") or "",
version_no=ver.version_no if ver else None,
group_id=(app.group_id if app else "") or "",
group_name=(group.group_name if group else "") or "",
)
schema = _loads(getattr(ver, "form_schema_json", None), {}) if ver else {}
form_data = _loads(d.form_data_json, {}) or {}
preview = [
{"label": f.get("label") or "", "value": f.get("value") or ""}
for f in flatten_form_fields_for_summary(schema, form_data)[:3]
]
item["previewFields"] = preview
items.append(item)
return items, int(total)
def get_draft_detail(
db: Session,
draft_id: str,
*,
tenant_no: str,
owner_user_id: str,
) -> Dict[str, Any]:
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
return _enrich(db, d, include_payload=True)
def create_draft(
db: Session,
*,
tenant_no: str,
owner_user_id: str,
app_id: str,
form_data: Optional[Dict[str, Any]] = None,
initiator_select_by_node: Optional[Dict[str, Any]] = None,
initiator_dept_id: Optional[str] = None,
title: Optional[str] = None,
create_dept_id: str = "",
) -> Dict[str, Any]:
app = oa_crud.get_app(db, app_id, tenant_no)
if not app or app.status != "1":
raise ValueError("应用不存在或已停用")
ver = oa_crud._find_latest_published(db, app_id, tenant_no)
if not ver:
raise ValueError("应用尚未发布,无法暂存草稿")
now = datetime.now()
row = OaInstanceDraft(
id=generate_uuid(),
app_id=app_id,
app_version_id=ver.id,
title=(title or app.app_name or "")[:200] or None,
form_data_json=_dumps(form_data or {}),
initiator_select_json=_dumps(initiator_select_by_node or {}),
initiator_dept_id=(initiator_dept_id or "").strip() or None,
owner_user_id=owner_user_id,
tenant_no=tenant_no,
create_by=owner_user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=owner_user_id,
update_time=now,
del_flag=0,
)
db.add(row)
db.commit()
db.refresh(row)
return _enrich(db, row, include_payload=True)
def update_draft(
db: Session,
draft_id: str,
*,
tenant_no: str,
owner_user_id: str,
form_data: Optional[Dict[str, Any]] = None,
initiator_select_by_node: Optional[Dict[str, Any]] = None,
initiator_dept_id: Optional[str] = None,
title: Optional[str] = None,
) -> Dict[str, Any]:
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
ver = oa_crud._find_latest_published(db, d.app_id, tenant_no)
if ver:
d.app_version_id = ver.id
if form_data is not None:
d.form_data_json = _dumps(form_data)
if initiator_select_by_node is not None:
d.initiator_select_json = _dumps(initiator_select_by_node)
if initiator_dept_id is not None:
d.initiator_dept_id = (initiator_dept_id or "").strip() or None
if title is not None:
d.title = (title or "")[:200] or None
d.update_by = owner_user_id
d.update_time = datetime.now()
db.commit()
db.refresh(d)
return _enrich(db, d, include_payload=True)
def soft_delete_draft(
db: Session,
draft_id: str,
*,
tenant_no: str,
owner_user_id: str,
) -> None:
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
d.del_flag = 1
d.update_by = owner_user_id
d.update_time = datetime.now()
db.commit()
def batch_soft_delete_drafts(
db: Session,
ids: List[str],
*,
tenant_no: str,
owner_user_id: str,
) -> int:
cleaned = [str(x).strip() for x in (ids or []) if str(x).strip()]
if not cleaned:
raise ValueError("请选择要删除的草稿")
rows = (
db.query(OaInstanceDraft)
.filter(
OaInstanceDraft.id.in_(cleaned),
OaInstanceDraft.tenant_no == tenant_no,
OaInstanceDraft.owner_user_id == owner_user_id,
OaInstanceDraft.del_flag == 0,
)
.all()
)
if len(rows) != len(set(cleaned)):
raise DraftAccessError("草稿不存在或无权访问")
now = datetime.now()
for d in rows:
d.del_flag = 1
d.update_by = owner_user_id
d.update_time = now
db.commit()
return len(rows)
def submit_draft(
db: Session,
draft_id: str,
*,
tenant_no: str,
owner_user_id: str,
form_data: Optional[Dict[str, Any]] = None,
initiator_select_by_node: Optional[Dict[str, Any]] = None,
initiator_dept_id: Optional[str] = None,
title: Optional[str] = None,
) -> Dict[str, Any]:
"""提交草稿:走 start_instance,成功后软删草稿。"""
from app.services.oa_engine import start_instance
d = get_owned_draft(db, draft_id, tenant_no=tenant_no, owner_user_id=owner_user_id)
payload_form = form_data if form_data is not None else (_loads(d.form_data_json, {}) or {})
payload_select = (
initiator_select_by_node
if initiator_select_by_node is not None
else (_loads(d.initiator_select_json, {}) or {})
)
dept = initiator_dept_id if initiator_dept_id is not None else d.initiator_dept_id
use_title = title if title is not None else d.title
data = start_instance(
db,
tenant_no=tenant_no,
app_id=d.app_id,
initiator_user_id=owner_user_id,
initiator_dept_id=dept,
form_data=payload_form if isinstance(payload_form, dict) else {},
title=use_title,
initiator_select_by_node=payload_select if isinstance(payload_select, dict) else {},
)
d.del_flag = 1
d.update_by = owner_user_id
d.update_time = datetime.now()
db.commit()
return data
+227
View File
@@ -0,0 +1,227 @@
"""OA 收款账户 CRUD(oa_receive_account)"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import OaReceiveAccount, SysUser
def row_to_dict(r: OaReceiveAccount) -> Dict[str, Any]:
return {
"id": r.id,
"accountType": r.account_type,
"accountName": r.account_name,
"accountNo": r.account_no,
"bankName": r.bank_name or "",
"bankProvince": r.bank_province or "",
"bankCity": r.bank_city or "",
"bankBranch": r.bank_branch 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 "",
"ownerUserId": (r.create_by or "").strip(),
}
def _owner_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
ids = [item for item in {uid.strip() for uid in user_ids if uid and uid.strip()}]
if not ids:
return {}
rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
return {row.id: (row.nick_name or "").strip() for row in rows}
def _attach_owner_names(db: Session, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
names = _owner_name_map(db, [item.get("ownerUserId") or "" for item in items])
for item in items:
uid = item.get("ownerUserId") or ""
item["ownerName"] = names.get(uid) or "—"
return items
def _filtered_query(
db: Session,
*,
tenant_no: str,
keyword: Optional[str] = None,
account_type: Optional[str] = None,
):
q = db.query(OaReceiveAccount).filter(
OaReceiveAccount.tenant_no == tenant_no,
OaReceiveAccount.del_flag == 0,
)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q = q.filter(
or_(
OaReceiveAccount.account_name.like(like),
OaReceiveAccount.account_no.like(like),
OaReceiveAccount.bank_name.like(like),
)
)
if account_type:
q = q.filter(OaReceiveAccount.account_type == account_type.strip())
return q
def list_accounts(
db: Session,
*,
tenant_no: str,
keyword: Optional[str] = None,
account_type: Optional[str] = None,
page: int = 1,
page_size: int = 10,
) -> Tuple[List[Dict[str, Any]], int]:
base = _filtered_query(db, tenant_no=tenant_no, keyword=keyword, account_type=account_type)
total = base.order_by(None).with_entities(func.count(OaReceiveAccount.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
base.order_by(OaReceiveAccount.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return _attach_owner_names(db, [row_to_dict(x) for x in rows]), int(total)
def list_options(db: Session, *, tenant_no: str, user_id: str) -> List[Dict[str, Any]]:
owner_id = (user_id or "").strip()
if not owner_id:
return []
rows = (
db.query(OaReceiveAccount)
.filter(
OaReceiveAccount.tenant_no == tenant_no,
OaReceiveAccount.del_flag == 0,
func.trim(OaReceiveAccount.create_by) == owner_id,
)
.order_by(OaReceiveAccount.create_time.desc())
.all()
)
return _attach_owner_names(db, [row_to_dict(x) for x in rows])
def get_account(db: Session, account_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
row = (
db.query(OaReceiveAccount)
.filter(
OaReceiveAccount.id == account_id,
OaReceiveAccount.tenant_no == tenant_no,
OaReceiveAccount.del_flag == 0,
)
.first()
)
return _attach_owner_names(db, [row_to_dict(row)])[0] if row else None
def create_account(
db: Session,
*,
tenant_no: str,
user_id: Optional[str],
dept_id: str,
fields: Dict[str, Any],
) -> Dict[str, Any]:
now = datetime.now()
obj = OaReceiveAccount(
id=generate_uuid(),
account_type=fields["account_type"],
account_name=fields["account_name"],
account_no=fields["account_no"],
bank_name=fields.get("bank_name") or None,
bank_province=fields.get("bank_province") or None,
bank_city=fields.get("bank_city") or None,
bank_branch=fields.get("bank_branch") or None,
tenant_no=tenant_no,
order_num=fields.get("order_num") if fields.get("order_num") is not None else 0,
remark=(fields.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
db.commit()
db.refresh(obj)
return _attach_owner_names(db, [row_to_dict(obj)])[0]
def update_account(
db: Session,
account_id: str,
tenant_no: str,
user_id: Optional[str],
fields: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
obj = (
db.query(OaReceiveAccount)
.filter(
OaReceiveAccount.id == account_id,
OaReceiveAccount.tenant_no == tenant_no,
OaReceiveAccount.del_flag == 0,
)
.first()
)
if not obj:
return None
obj.account_type = fields["account_type"]
obj.account_name = fields["account_name"]
obj.account_no = fields["account_no"]
obj.bank_name = fields.get("bank_name") or None
obj.bank_province = fields.get("bank_province") or None
obj.bank_city = fields.get("bank_city") or None
obj.bank_branch = fields.get("bank_branch") or None
if fields.get("order_num") is not None:
obj.order_num = fields["order_num"]
if fields.get("remark") is not None:
obj.remark = (fields.get("remark") or "").strip() or None
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.commit()
db.refresh(obj)
return _attach_owner_names(db, [row_to_dict(obj)])[0]
def soft_delete_account(
db: Session, account_id: str, tenant_no: str, user_id: Optional[str]
) -> bool:
obj = (
db.query(OaReceiveAccount)
.filter(
OaReceiveAccount.id == account_id,
OaReceiveAccount.tenant_no == tenant_no,
OaReceiveAccount.del_flag == 0,
)
.first()
)
if not obj:
return False
obj.del_flag = 1
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.commit()
return True
def soft_delete_accounts(
db: Session, ids: List[str], tenant_no: str, user_id: Optional[str]
) -> int:
count = 0
for account_id in ids:
if soft_delete_account(db, account_id, tenant_no, user_id):
count += 1
return count
+49 -27
View File
@@ -9,6 +9,7 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud import cell_product as cell_product_svc
from app.models.models import (
BizCellProduct,
@@ -396,13 +397,6 @@ def save_outbound_material(
db, schedule_id, tenant_no, incoming_qc_ids, user_id, now
)
db.query(BizOutboundReleasePaper).filter(
BizOutboundReleasePaper.infusion_schedule_id == schedule_id,
BizOutboundReleasePaper.tenant_no == tenant_no,
BizOutboundReleasePaper.del_flag == 0,
).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
db.flush()
for idx, item in enumerate(cells or []):
cp_id = (item.get("cellProductId") or item.get("cell_product_id") or "").strip()
if not cp_id:
@@ -532,7 +526,8 @@ def save_outbound_material(
)
)
for idx, item in enumerate(paper_materials or []):
paper_payload = []
for item in paper_materials or []:
paper_name = (item.get("paperName") or item.get("paper_name") or "").strip()
attachment_url = (item.get("attachmentUrl") or item.get("attachment_url") or "").strip()
copies_raw = item.get("copies")
@@ -546,26 +541,53 @@ def save_outbound_material(
raise HTTPException(status_code=400, detail="纸质材料份数格式无效")
if copies <= 0:
raise HTTPException(status_code=400, detail="纸质材料份数必须大于0")
db.add(
BizOutboundReleasePaper(
id=generate_uuid(),
material_id=header.id,
infusion_schedule_id=schedule_id,
paper_name=paper_name,
copies=copies,
attachment_url=attachment_url,
tenant_no=tenant_no,
order_num=idx + 1,
remark=(item.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
paper_payload.append(
{
"paper_name": paper_name,
"copies": copies,
"attachment_url": attachment_url,
"remark": (item.get("remark") or "").strip() or None,
}
)
def build_paper(row_id):
return BizOutboundReleasePaper(
id=row_id,
material_id=header.id,
infusion_schedule_id=schedule_id,
paper_name="",
copies=1,
attachment_url="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
)
def apply_paper(row, item, idx, _now):
row.material_id = header.id
row.paper_name = item["paper_name"]
row.copies = item["copies"]
row.attachment_url = item["attachment_url"]
row.order_num = idx + 1
row.remark = item["remark"]
sync_child_rows(
db,
BizOutboundReleasePaper,
parent_attr="infusion_schedule_id",
parent_id=schedule_id,
tenant_no=tenant_no,
items=paper_payload,
user_id=user_id,
now=now,
build_row=build_paper,
apply_fields=apply_paper,
natural_key=lambda item: item["attachment_url"],
row_natural_key=lambda row: row.attachment_url or "",
duplicate_message="纸质材料重复",
foreign_message="纸质材料不属于当前输注计划",
)
db.flush()
return get_outbound_material(db, schedule_id, tenant_no)
+167 -9
View File
@@ -6,9 +6,10 @@ from collections import defaultdict
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, func, or_
from sqlalchemy import and_, exists, func, or_
from sqlalchemy.orm import Session
from app.core.db_collation import eq_unicode
from app.core.utils import generate_uuid
from app.models.models import (
BizOutboundReleaseReview,
@@ -68,6 +69,124 @@ def _total_pages(total: int, page_size: int) -> int:
return int(math.ceil(total / float(page_size)))
def normalize_outbound_review_phase(review_phase: Optional[str]) -> Optional[str]:
p = (review_phase or "").strip().lower()
if p in ("pending", "reviewed"):
return p
return None
def normalize_outbound_ready_for_review_only(raw) -> Optional[bool]:
"""Return True when ready-only filter should apply; None means no filter."""
if raw is None:
return None
if isinstance(raw, bool):
return True if raw else None
s = str(raw).strip().lower()
if s in ("true", "1", "yes"):
return True
return None
def outbound_work_orders_all_void(statuses) -> bool:
"""True when ≥1 non-empty status and every one equals yzf."""
cleaned = [(s or "").strip() for s in (statuses or [])]
if not cleaned or not any(s for s in cleaned):
return False
return all(s.lower() == "yzf" for s in cleaned)
def _outbound_qc_unfinished_exists_for_schedule(tenant_no: str):
"""Any unfinished outbound schedule QC (dpz/ypz) for this infusion schedule."""
return (
exists()
.where(
and_(
BizQcWorkOrder.tenant_no == tenant_no,
BizQcWorkOrder.del_flag == 0,
BizQcWorkOrder.qc_biz_type == "outbound",
BizQcWorkOrder.trigger_source == "schedule",
# 跨表 id 排序规则可能不同(general_ci vs unicode_ci)
eq_unicode(
BizQcWorkOrder.infusion_schedule_id, BizPilotInfusionSchedule.id
),
BizQcWorkOrder.work_order_status.in_(("dpz", "ypz")),
)
)
.correlate(BizPilotInfusionSchedule)
)
def _outbound_production_unfinished_exists_for_schedule(tenant_no: str):
return (
exists()
.where(
and_(
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.del_flag == 0,
BizProductionWorkOrder.work_order_type == "outbound",
eq_unicode(
BizProductionWorkOrder.infusion_schedule_id,
BizPilotInfusionSchedule.id,
),
BizProductionWorkOrder.work_order_status.in_(("dpz", "jxz")),
)
)
.correlate(BizPilotInfusionSchedule)
)
def _outbound_qc_all_void_for_schedule(tenant_no: str):
base = [
BizQcWorkOrder.tenant_no == tenant_no,
BizQcWorkOrder.del_flag == 0,
BizQcWorkOrder.qc_biz_type == "outbound",
BizQcWorkOrder.trigger_source == "schedule",
eq_unicode(BizQcWorkOrder.infusion_schedule_id, BizPilotInfusionSchedule.id),
]
any_wo = exists().where(and_(*base)).correlate(BizPilotInfusionSchedule)
any_non_void = (
exists()
.where(
and_(
*base,
or_(
BizQcWorkOrder.work_order_status.is_(None),
BizQcWorkOrder.work_order_status != "yzf",
),
)
)
.correlate(BizPilotInfusionSchedule)
)
return and_(any_wo, ~any_non_void)
def _outbound_production_all_void_for_schedule(tenant_no: str):
base = [
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.del_flag == 0,
BizProductionWorkOrder.work_order_type == "outbound",
eq_unicode(
BizProductionWorkOrder.infusion_schedule_id, BizPilotInfusionSchedule.id
),
]
any_wo = exists().where(and_(*base)).correlate(BizPilotInfusionSchedule)
any_non_void = (
exists()
.where(
and_(
*base,
or_(
BizProductionWorkOrder.work_order_status.is_(None),
BizProductionWorkOrder.work_order_status != "yzf",
),
)
)
.correlate(BizPilotInfusionSchedule)
)
return and_(any_wo, ~any_non_void)
def _get_user_name(db: Session, user_id: str) -> str:
u = db.query(SysUser).filter(SysUser.id == user_id).first()
return (u.nick_name or "") if u else ""
@@ -132,6 +251,8 @@ def list_reviews_paginated(
biz_type: Optional[str] = None,
infusion_time_start: Optional[str] = None,
infusion_time_end: Optional[str] = None,
review_phase: Optional[str] = None,
ready_for_review_only=None,
page: int = 1,
page_size: int = 10,
user_info=None,
@@ -246,24 +367,45 @@ def list_reviews_paginated(
if time_bounds:
q0 = q0.filter(or_(and_(*time_bounds), BizPilotInfusionSchedule.infusion_time.is_(None)))
# ── review_conclusion 过滤 ──
# ── reviewPhase / review_conclusion 过滤 ──
phase = normalize_outbound_review_phase(review_phase)
rc = (review_conclusion or "").strip()
if rc == "pending":
# 待审核:不存在审核记录
if phase == "pending" or (phase is None and rc == "pending"):
reviewed_schedule_ids = (
db.query(BizOutboundReleaseReview.infusion_schedule_id)
.filter(
BizOutboundReleaseReview.tenant_no == tenant_no,
BizOutboundReleaseReview.del_flag == 0,
BizOutboundReleaseReview.review_conclusion.isnot(None),
BizOutboundReleaseReview.review_conclusion != "",
)
.subquery()
)
q0 = q0.filter(
~BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)
q0 = q0.filter(~BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids))
elif phase == "reviewed":
reviewed_schedule_ids = (
db.query(BizOutboundReleaseReview.infusion_schedule_id)
.filter(
BizOutboundReleaseReview.tenant_no == tenant_no,
BizOutboundReleaseReview.del_flag == 0,
BizOutboundReleaseReview.review_conclusion.isnot(None),
BizOutboundReleaseReview.review_conclusion != "",
)
.subquery()
)
elif rc:
# 有具体结论
q0 = q0.filter(BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids))
if rc in ("tg", "btg"):
conclusion_ids = (
db.query(BizOutboundReleaseReview.infusion_schedule_id)
.filter(
BizOutboundReleaseReview.tenant_no == tenant_no,
BizOutboundReleaseReview.del_flag == 0,
BizOutboundReleaseReview.review_conclusion == rc,
)
.subquery()
)
q0 = q0.filter(BizPilotInfusionSchedule.id.in_(conclusion_ids))
elif rc in ("tg", "btg"):
reviewed_schedule_ids = (
db.query(BizOutboundReleaseReview.infusion_schedule_id)
.filter(
@@ -273,10 +415,22 @@ def list_reviews_paginated(
)
.subquery()
)
q0 = q0.filter(BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids))
if normalize_outbound_ready_for_review_only(ready_for_review_only) is True:
q0 = q0.filter(
BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)
~_outbound_qc_unfinished_exists_for_schedule(tenant_no),
~_outbound_production_unfinished_exists_for_schedule(tenant_no),
)
# 出库质检/生产「有工单且全部 yzf」→ 列表隐藏(两 Tab 常驻)
q0 = q0.filter(
~or_(
_outbound_qc_all_void_for_schedule(tenant_no),
_outbound_production_all_void_for_schedule(tenant_no),
)
)
# ── 数据权限 ──
if user_info:
from app.core.data_scope import apply_data_scope
@@ -370,6 +524,8 @@ def list_reviews_paginated(
"projectName": product.project_name or "",
"cellTypeName": product.cell_type_name or "",
"specText": product.spec_text or "",
"packageId": getattr(product, "package_id", None) or "",
"packageText": getattr(product, "package_text", None) or "",
"agreementPrice": _dec(product.agreement_price),
"actualPrice": _dec(product.actual_price),
"quantity": product.quantity,
@@ -450,6 +606,8 @@ def list_reviews_paginated(
"patientName": (patient.patient_name or "") if patient else "",
"cellType": s.cell_type or (plan.cell_type or "") if plan else (s.cell_type or ""),
"specText": s.spec_text or (plan.spec_text or "") if plan else (s.spec_text or ""),
"packageId": getattr(s, "package_id", None) or "",
"packageText": getattr(s, "package_text", None) or "",
"infusionTime": _fmt_dt(s.infusion_time),
"scheduleStatus": s.schedule_status or "",
"scheduleStatusLabel": _SCHEDULE_STATUS_LABELS.get(
+20
View File
@@ -272,6 +272,8 @@ def _row_to_dict_schedule_product(row) -> Dict[str, Any]:
"projectName": row.project_name or "",
"cellTypeName": row.cell_type_name or "",
"specText": row.spec_text or "",
"packageId": getattr(row, "package_id", None) or "",
"packageText": getattr(row, "package_text", None) or "",
"agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None,
"actualPrice": float(row.actual_price) if row.actual_price is not None else None,
"quantity": row.quantity if row.quantity is not None else 1,
@@ -289,6 +291,8 @@ def _row_to_dict_infusion_schedule(row, cell_products: Optional[List[Dict[str, A
"infusionPlanId": row.infusion_plan_id or "",
"cellType": row.cell_type or "",
"specText": row.spec_text or "",
"packageId": getattr(row, "package_id", None) or "",
"packageText": getattr(row, "package_text", None) or "",
"infusionTime": infusion_time,
"infusionLocation": row.infusion_location or "",
"infusionMethod": row.infusion_method or "",
@@ -1218,6 +1222,8 @@ def _create_schedule_products(
project_name=item.get("projectName") or "",
cell_type_name=item.get("cellTypeName") or "",
spec_text=item.get("specText") or "",
package_id=item.get("packageId") or "",
package_text=item.get("packageText") or "",
agreement_price=item.get("agreementPrice"),
actual_price=item.get("actualPrice"),
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
@@ -1273,6 +1279,8 @@ def create_schedule(
infusion_plan_id=plan_id,
cell_type=data.get("cellType") or first_product.get("cellTypeName") or "",
spec_text=data.get("specText") or first_product.get("specText") or "",
package_id=data.get("packageId") or first_product.get("packageId") or "",
package_text=data.get("packageText") or first_product.get("packageText") or "",
infusion_time=_parse_infusion_datetime(data.get("infusionTime")),
infusion_location=data.get("infusionLocation") or "",
infusion_method=data.get("infusionMethod") or "",
@@ -1364,6 +1372,10 @@ def _update_schedule_editable(
if cell_products:
row.cell_type = data.get("cellType") or first_product.get("cellTypeName") or row.cell_type
row.spec_text = data.get("specText") or first_product.get("specText") or row.spec_text
row.package_id = data.get("packageId") or first_product.get("packageId") or row.package_id
row.package_text = (
data.get("packageText") or first_product.get("packageText") or row.package_text
)
if data.get("remark") is not None:
row.remark = data.get("remark") or ""
row.update_by = user_id
@@ -1407,6 +1419,8 @@ def _upsert_schedule_products(
prow.project_name = item.get("projectName") or ""
prow.cell_type_name = item.get("cellTypeName") or ""
prow.spec_text = item.get("specText") or ""
prow.package_id = item.get("packageId") or ""
prow.package_text = item.get("packageText") or ""
if item.get("agreementPrice") is not None:
prow.agreement_price = item.get("agreementPrice")
if item.get("actualPrice") is not None:
@@ -1429,6 +1443,8 @@ def _upsert_schedule_products(
project_name=item.get("projectName") or "",
cell_type_name=item.get("cellTypeName") or "",
spec_text=item.get("specText") or "",
package_id=item.get("packageId") or "",
package_text=item.get("packageText") or "",
agreement_price=item.get("agreementPrice"),
actual_price=item.get("actualPrice"),
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
@@ -1645,6 +1661,8 @@ def create_basic_experiment_sale_schedules(
schedule_data = {
"cellType": line.get("cellTypeName") or "",
"specText": line.get("specText") or "",
"packageId": line.get("packageId") or "",
"packageText": line.get("packageText") or "",
"infusionTime": line.get("pickupTime"),
"amount": line.get("amount"),
"sortNo": idx + 1,
@@ -1655,6 +1673,8 @@ def create_basic_experiment_sale_schedules(
"projectName": line.get("projectName"),
"cellTypeName": line.get("cellTypeName"),
"specText": line.get("specText"),
"packageId": line.get("packageId"),
"packageText": line.get("packageText"),
"agreementPrice": line.get("agreementPrice"),
"actualPrice": line.get("actualPrice"),
"quantity": line.get("quantity") or 1,
+67 -58
View File
@@ -8,6 +8,9 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizCheckupInstitution,
BizPilotApplication,
@@ -429,6 +432,52 @@ def list_reserve_items(
return [_row_to_dict_reserve_item(r) for r in rows]
def _checkup_project_name(item: Dict[str, Any]) -> str:
return (item.get("projectName") or item.get("project_name") or "").strip()
def _checkup_project_fee(item: Dict[str, Any]):
if item.get("projectFee") is not None:
return item.get("projectFee")
return item.get("project_fee")
def _sync_checkup_items(db, model, checkup_id, items_json, tenant_no, user_id, now):
def build_row(row_id):
return model(
id=row_id,
pilot_checkup_id=checkup_id,
project_name="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row, item, idx, _now):
row.project_name = _checkup_project_name(item)
row.project_fee = _checkup_project_fee(item)
row.order_num = idx + 1
row.remark = item.get("remark") or ""
sync_child_rows(
db,
model,
parent_attr="pilot_checkup_id",
parent_id=checkup_id,
tenant_no=tenant_no,
items=list(items_json or []),
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_checkup_project_name,
row_natural_key=lambda row: (row.project_name or "").strip(),
duplicate_message="体检项目重复",
foreign_message="体检项目不属于当前体检单",
)
def save_reserve_items(
db: Session,
checkup_id: str,
@@ -437,35 +486,15 @@ def save_reserve_items(
user_id: str,
) -> List[Dict[str, Any]]:
now = datetime.now()
# Soft-delete existing items
db.query(BizPilotCheckupReserveItem).filter(
BizPilotCheckupReserveItem.pilot_checkup_id == checkup_id,
BizPilotCheckupReserveItem.tenant_no == tenant_no,
BizPilotCheckupReserveItem.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
# Insert new items
for idx, item in enumerate(items_json):
row = BizPilotCheckupReserveItem(
id=uuid.uuid4().hex,
pilot_checkup_id=checkup_id,
project_name=item.get("projectName") or item.get("project_name") or "",
project_fee=item.get("projectFee") if item.get("projectFee") is not None else (
item.get("project_fee") if item.get("project_fee") is not None else None
),
tenant_no=tenant_no,
order_num=idx + 1,
remark=item.get("remark") or "",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
_sync_checkup_items(
db,
BizPilotCheckupReserveItem,
checkup_id,
items_json,
tenant_no,
user_id,
now,
)
db.commit()
return list_reserve_items(db, checkup_id, tenant_no)
@@ -520,35 +549,15 @@ def save_actual_items(
user_id: str,
) -> List[Dict[str, Any]]:
now = datetime.now()
# Soft-delete existing items
db.query(BizPilotCheckupActualItem).filter(
BizPilotCheckupActualItem.pilot_checkup_id == checkup_id,
BizPilotCheckupActualItem.tenant_no == tenant_no,
BizPilotCheckupActualItem.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
# Insert new items
for idx, item in enumerate(items_json):
row = BizPilotCheckupActualItem(
id=uuid.uuid4().hex,
pilot_checkup_id=checkup_id,
project_name=item.get("projectName") or item.get("project_name") or "",
project_fee=item.get("projectFee") if item.get("projectFee") is not None else (
item.get("project_fee") if item.get("project_fee") is not None else None
),
tenant_no=tenant_no,
order_num=idx + 1,
remark=item.get("remark") or "",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
_sync_checkup_items(
db,
BizPilotCheckupActualItem,
checkup_id,
items_json,
tenant_no,
user_id,
now,
)
db.commit()
return list_actual_items(db, checkup_id, tenant_no)
+30 -24
View File
@@ -8,6 +8,8 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizChannelAccount,
BizChannelAgreementProductPrice,
@@ -539,7 +541,7 @@ def save_delivery_materials(
tenant_no: str,
user_id: str,
) -> List[Dict[str, Any]]:
"""Bulk save: soft-delete existing + insert new."""
"""按物料名称更新已有行,只插入新物料,只软删去掉的物料。"""
now = datetime.now()
# Validate delivery exists
@@ -555,36 +557,40 @@ def save_delivery_materials(
if not delivery:
raise ValueError("交付信息不存在")
# Soft-delete existing materials
db.query(BizPilotDeliveryMaterial).filter(
BizPilotDeliveryMaterial.delivery_id == delivery_id,
BizPilotDeliveryMaterial.tenant_no == tenant_no,
BizPilotDeliveryMaterial.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
db.flush()
# Insert new
created = []
for idx, item in enumerate(items_json):
material = BizPilotDeliveryMaterial(
id=str(uuid.uuid4()),
def build_row(row_id):
return BizPilotDeliveryMaterial(
id=row_id,
delivery_id=delivery_id,
material_name=item.get("material_name") or "",
qty=item.get("qty"),
unit_id=item.get("unit_id") or "",
material_name="",
tenant_no=tenant_no,
order_num=idx + 1,
remark=item.get("remark") or "",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(material)
created.append(material)
def apply_row(row, item, idx, _now):
row.material_name = item.get("material_name") or ""
row.qty = item.get("qty")
row.unit_id = item.get("unit_id") or ""
row.order_num = idx + 1
row.remark = item.get("remark") or ""
created = sync_child_rows(
db,
BizPilotDeliveryMaterial,
parent_attr="delivery_id",
parent_id=delivery_id,
tenant_no=tenant_no,
items=list(items_json or []),
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("material_name") or "").strip(),
row_natural_key=lambda row: (row.material_name or "").strip(),
duplicate_message="交付物料重复",
foreign_message="交付物料不属于当前交付单",
)
db.commit()
for m in created:
db.refresh(m)
+1 -1
View File
@@ -129,7 +129,7 @@ def get_confirm_by_schedule(
if uid:
user = (
db.query(SysUser)
.filter(SysUser.id == uid, SysUser.del_flag == 0)
.filter(SysUser.id == uid)
.first()
)
data["confirmUserName"] = (user.nick_name or user.user_no or "") if user else ""
@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -10,6 +9,7 @@ from sqlalchemy import and_, desc, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizChannelAgreementProductPrice,
BizChannelServiceOrder,
@@ -69,6 +69,8 @@ def _row_to_dict_schedule(
"totalCourseCount": course.get("totalCourseCount"),
"cellType": row.cell_type or "",
"specText": row.spec_text or "",
"packageId": getattr(row, "package_id", None) or "",
"packageText": getattr(row, "package_text", None) or "",
"infusionTime": row.infusion_time.strftime("%Y-%m-%d %H:%M:%S") if row.infusion_time else "",
"infusionLocation": row.infusion_location or "",
"infusionMethod": row.infusion_method or "",
@@ -142,6 +144,8 @@ def _row_to_dict_product(row) -> Dict[str, Any]:
"projectName": row.project_name or "",
"cellTypeName": row.cell_type_name or "",
"specText": row.spec_text or "",
"packageId": getattr(row, "package_id", None) or "",
"packageText": getattr(row, "package_text", None) or "",
"agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None,
"actualPrice": float(row.actual_price) if row.actual_price is not None else None,
"quantity": row.quantity if row.quantity is not None else 1,
@@ -516,6 +520,8 @@ def get_infusion_schedule_detail(
sched["totalCourseCount"] = flat.get("totalCourseCount")
sched["cellType"] = flat.get("cellType") or sched.get("cellType") or ""
sched["specText"] = flat.get("specText") or sched.get("specText") or ""
sched["packageId"] = flat.get("packageId") or sched.get("packageId") or ""
sched["packageText"] = flat.get("packageText") or sched.get("packageText") or ""
sched["infusionTime"] = flat.get("infusionTime") or sched.get("infusionTime") or ""
sched["infusionLocation"] = flat.get("infusionLocation") or sched.get("infusionLocation") or ""
sched["infusionMethod"] = flat.get("infusionMethod") or sched.get("infusionMethod") or ""
@@ -1114,6 +1120,8 @@ def update_infusion_schedule_mgmt(
fields = {
"cellType": "cell_type",
"specText": "spec_text",
"packageId": "package_id",
"packageText": "package_text",
"infusionLocation": "infusion_location",
"infusionMethod": "infusion_method",
}
@@ -1134,44 +1142,68 @@ def update_infusion_schedule_mgmt(
row.update_by = user_id
row.update_time = now
# Update products if provided
products_data = data.get("products")
if products_data is not None:
# Soft-delete existing products
db.query(BizPilotInfusionScheduleProduct).filter(
BizPilotInfusionScheduleProduct.infusion_schedule_id == schedule_id,
BizPilotInfusionScheduleProduct.tenant_no == tenant_no,
BizPilotInfusionScheduleProduct.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
def _price_id(item):
return (item.get("priceId") or item.get("price_id") or "").strip()
for idx, item in enumerate(products_data):
p_row = BizPilotInfusionScheduleProduct(
id=str(uuid.uuid4()),
def _product_key(item):
price_id = _price_id(item)
if price_id:
return "price:" + price_id
project_code = (item.get("projectCode") or item.get("project_code") or "").strip()
spec_text = (item.get("specText") or item.get("spec_text") or "").strip()
return f"proj:{project_code}|{spec_text}"
def build_product(row_id):
return BizPilotInfusionScheduleProduct(
id=row_id,
infusion_schedule_id=schedule_id,
price_id=item.get("priceId") or item.get("price_id") or "",
project_code=item.get("projectCode") or item.get("project_code") or "",
project_name=item.get("projectName") or item.get("project_name") or "",
cell_type_name=item.get("cellTypeName") or item.get("cell_type_name") or "",
spec_text=item.get("specText") or item.get("spec_text") or "",
agreement_price=item.get("agreementPrice") if item.get("agreementPrice") is not None else (
item.get("agreement_price") if item.get("agreement_price") is not None else None
),
actual_price=item.get("actualPrice") if item.get("actualPrice") is not None else (
item.get("actual_price") if item.get("actual_price") is not None else None
),
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
product_status=status,
tenant_no=tenant_no,
order_num=idx + 1,
remark=item.get("remark") or "",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(p_row)
def apply_product(row, item, idx, _now):
row.price_id = _price_id(item)
row.project_code = item.get("projectCode") or item.get("project_code") or ""
row.project_name = item.get("projectName") or item.get("project_name") or ""
row.cell_type_name = item.get("cellTypeName") or item.get("cell_type_name") or ""
row.spec_text = item.get("specText") or item.get("spec_text") or ""
row.package_id = item.get("packageId") or item.get("package_id") or ""
row.package_text = item.get("packageText") or item.get("package_text") or ""
row.agreement_price = item.get("agreementPrice") if item.get("agreementPrice") is not None else (
item.get("agreement_price") if item.get("agreement_price") is not None else None
)
row.actual_price = item.get("actualPrice") if item.get("actualPrice") is not None else (
item.get("actual_price") if item.get("actual_price") is not None else None
)
row.quantity = item.get("quantity") if item.get("quantity") is not None else 1
row.product_status = status
row.order_num = idx + 1
row.remark = item.get("remark") or ""
sync_child_rows(
db,
BizPilotInfusionScheduleProduct,
parent_attr="infusion_schedule_id",
parent_id=schedule_id,
tenant_no=tenant_no,
items=list(products_data or []),
user_id=user_id,
now=now,
build_row=build_product,
apply_fields=apply_product,
natural_key=_product_key,
row_natural_key=lambda row: (
"price:" + (row.price_id or "").strip()
if (row.price_id or "").strip()
else f"proj:{(row.project_code or '').strip()}|{(row.spec_text or '').strip()}"
),
duplicate_message="输注产品重复",
foreign_message="输注产品不属于当前计划",
)
db.commit()
db.refresh(row)
+227 -58
View File
@@ -10,10 +10,12 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.collection_protocol import urls_to_attachment_list
from app.models.models import (
BizCellProductType,
BizCellProductSpec,
BizCellProductPackage,
BizCellProductPrice,
BizCellProductProjectSystem,
BizCellProductProjectSystemItem,
@@ -281,6 +283,141 @@ def soft_delete_cell_product_spec(db, spec_id, tenant_no=None):
db.commit()
# ────────────────────────────────────────────
# Entity 2b: BizCellProductPackage 细胞产品包装
# ────────────────────────────────────────────
def _row_to_dict_cell_product_package(row) -> Dict[str, Any]:
return {
"id": row.id,
"packageCode": row.package_code or "",
"packageName": row.package_name or "",
"tenantNo": row.tenant_no or "",
"orderNum": row.order_num if row.order_num is not None else 0,
"remark": row.remark or "",
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
}
def list_cell_product_packages(db, *, page=1, page_size=10, keyword=None, tenant_no=None, user_info=None):
def _filtered():
q0 = db.query(BizCellProductPackage).filter(BizCellProductPackage.del_flag == 0)
if tenant_no:
q0 = q0.filter(BizCellProductPackage.tenant_no == tenant_no)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q0 = q0.filter(
or_(
BizCellProductPackage.package_code.like(like),
BizCellProductPackage.package_name.like(like),
)
)
if user_info:
from app.core.data_scope import apply_data_scope
q0 = apply_data_scope(q0, BizCellProductPackage, user_info, db, tenant_no)
return q0
total = _filtered().order_by(None).with_entities(func.count(BizCellProductPackage.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
_filtered()
.order_by(BizCellProductPackage.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return [_row_to_dict_cell_product_package(x) for x in rows], int(total)
def get_cell_product_package(db, package_id, tenant_no=None):
q = db.query(BizCellProductPackage).filter(
and_(BizCellProductPackage.id == package_id, BizCellProductPackage.del_flag == 0)
)
if tenant_no:
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
return q.first()
def cell_product_package_code_exists(db, code, tenant_no, exclude_id=None):
q = db.query(BizCellProductPackage).filter(
and_(BizCellProductPackage.package_code == code, BizCellProductPackage.del_flag == 0)
)
if tenant_no:
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
if exclude_id:
q = q.filter(BizCellProductPackage.id != exclude_id)
return db.query(q.exists()).scalar()
def create_cell_product_package(
db, *, package_code, package_name, tenant_no="zkxw", order_num=None, remark=None, user_id=None
):
if cell_product_package_code_exists(db, package_code, tenant_no):
raise ValueError(f"包装编码 {package_code} 已存在")
obj = BizCellProductPackage(
id=generate_uuid(),
package_code=package_code,
package_name=package_name,
tenant_no=tenant_no,
order_num=order_num or 0,
remark=remark or "",
create_by=user_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_product_package(
db,
package_id,
*,
package_code=None,
package_name=None,
order_num=None,
remark=None,
user_id=None,
tenant_no=None,
):
obj = get_cell_product_package(db, package_id, tenant_no)
if not obj:
raise ValueError("记录不存在")
if package_code is not None and package_code != obj.package_code:
if cell_product_package_code_exists(db, package_code, tenant_no, exclude_id=package_id):
raise ValueError(f"包装编码 {package_code} 已存在")
obj.package_code = package_code
if package_name is not None:
obj.package_name = package_name
if order_num is not None:
obj.order_num = order_num
if remark is not None:
obj.remark = remark
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def soft_delete_cell_product_package(db, package_id, tenant_no=None):
obj = get_cell_product_package(db, package_id, tenant_no)
if not obj:
raise ValueError("记录不存在")
obj.del_flag = 1
obj.update_time = datetime.now()
db.add(obj)
db.commit()
# ────────────────────────────────────────────
# Entity 3: BizCellProductPrice 细胞产品价格
# ────────────────────────────────────────────
@@ -2151,7 +2288,9 @@ def _insert_package_items(
tenant_no,
user_id=None,
):
for idx, it in enumerate(items):
now = datetime.now()
prepared = []
for idx, it in enumerate(items or []):
pid = it.get("checkup_institution_project_id") or it.get("checkupInstitutionProjectId")
proj = _get_checkup_project_for_package(db, pid, institution_id, tenant_no)
name = (it.get("project_name") or it.get("projectName") or "").strip()
@@ -2160,22 +2299,51 @@ def _insert_package_items(
fee = it.get("project_fee")
if fee is None:
fee = it.get("projectFee")
obj = BizCheckupInstitutionPackageItem(
id=generate_uuid(),
package_id=package_id,
checkup_institution_project_id=pid,
project_name=name,
project_fee=fee,
tenant_no=tenant_no,
order_num=it.get("order_num") if it.get("order_num") is not None else it.get("orderNum") or (idx + 1),
remark=it.get("remark") or "",
create_by=user_id,
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
prepared.append(
{
"project_id": pid,
"project_name": name,
"project_fee": fee,
"order_num": it.get("order_num") if it.get("order_num") is not None else it.get("orderNum") or (idx + 1),
"remark": it.get("remark") or "",
}
)
db.add(obj)
def build_row(row_id):
return BizCheckupInstitutionPackageItem(
id=row_id,
package_id=package_id,
checkup_institution_project_id="",
project_name="",
tenant_no=tenant_no,
create_by=user_id,
create_time=now,
)
def apply_row(row, item, _idx, _now):
row.checkup_institution_project_id = item["project_id"]
row.project_name = item["project_name"]
row.project_fee = item["project_fee"]
row.order_num = item["order_num"]
row.remark = item["remark"]
sync_child_rows(
db,
BizCheckupInstitutionPackageItem,
parent_attr="package_id",
parent_id=package_id,
tenant_no=tenant_no,
items=prepared,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: str(item.get("project_id") or "").strip(),
row_natural_key=lambda row: (row.checkup_institution_project_id or "").strip(),
duplicate_message="套餐项目重复",
foreign_message="套餐项目不属于当前套餐",
reuse_deleted=True,
)
def list_checkup_institution_packages(db, checkup_institution_id, tenant_no=None, user_info=None):
@@ -2319,15 +2487,6 @@ def update_checkup_institution_package(
tenant_no,
existing_item_project_ids=existing_pids,
)
# 全量替换:物理删除旧明细,避免 uk(package_id, project_id) 与软删行冲突
q_items = db.query(BizCheckupInstitutionPackageItem).filter(
BizCheckupInstitutionPackageItem.package_id == package_id
)
if tenant_no:
q_items = q_items.filter(BizCheckupInstitutionPackageItem.tenant_no == tenant_no)
for row in q_items.all():
db.delete(row)
db.flush()
_insert_package_items(
db,
package_id=package_id,
@@ -3076,50 +3235,60 @@ def _save_gmcp_product_rows(
user_id,
now=None,
):
"""仅保存购买订单产品明细,不调整份数库存"""
"""仅保存购买订单产品明细,不调整份数库存。已有项目按编码、规格、细胞类型更新。"""
now = now or datetime.now()
for p in products:
project_code = p.get("projectCode") or p.get("project_code") or ""
project_name = p.get("projectName") or p.get("project_name") or ""
cell_type = p.get("cellType") or p.get("cell_type") or ""
spec_text = p.get("specText") or p.get("spec_text") or ""
agreement_unit_price = float(p.get("agreementUnitPrice") or p.get("agreement_unit_price") or 0)
buy_qty = float(p.get("buyQty") or p.get("buy_qty") or 0)
buy_amount = float(p.get("buyAmount") or p.get("buy_amount") or agreement_unit_price * buy_qty)
tenant = tenant_no or "zkxw"
prod_row = BizChannelServiceOrderProduct(
id=generate_uuid(),
def _product_key(item):
project_code = (item.get("projectCode") or item.get("project_code") or "").strip()
spec_text = (item.get("specText") or item.get("spec_text") or "").strip()
cell_type = (item.get("cellType") or item.get("cell_type") or "").strip()
return f"{project_code}|{spec_text}|{cell_type}"
def build_row(row_id):
return BizChannelServiceOrderProduct(
id=row_id,
service_order_id=order_id,
project_code=project_code,
project_name=project_name,
cell_type=cell_type,
spec_text=spec_text,
agreement_unit_price=agreement_unit_price,
buy_qty=buy_qty,
buy_amount=buy_amount,
tenant_no=tenant_no or "zkxw",
order_num=0,
remark="",
tenant_no=tenant,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(prod_row)
def apply_row(row, p, _idx, _now):
agreement_unit_price = float(p.get("agreementUnitPrice") or p.get("agreement_unit_price") or 0)
buy_qty = float(p.get("buyQty") or p.get("buy_qty") or 0)
row.project_code = p.get("projectCode") or p.get("project_code") or ""
row.project_name = p.get("projectName") or p.get("project_name") or ""
row.cell_type = p.get("cellType") or p.get("cell_type") or ""
row.spec_text = p.get("specText") or p.get("spec_text") or ""
row.agreement_unit_price = agreement_unit_price
row.buy_qty = buy_qty
row.buy_amount = float(p.get("buyAmount") or p.get("buy_amount") or agreement_unit_price * buy_qty)
row.order_num = 0
row.remark = ""
sync_child_rows(
db,
BizChannelServiceOrderProduct,
parent_attr="service_order_id",
parent_id=order_id,
tenant_no=tenant,
items=list(products or []),
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_product_key,
row_natural_key=lambda row: f"{(row.project_code or '').strip()}|{(row.spec_text or '').strip()}|{(row.cell_type or '').strip()}",
duplicate_message="订单产品重复",
foreign_message="订单产品不属于当前订单",
)
db.flush()
def _replace_gmcp_product_rows(db, order_id, products, tenant_no, user_id):
now = datetime.now()
db.query(BizChannelServiceOrderProduct).filter(
and_(
BizChannelServiceOrderProduct.service_order_id == order_id,
BizChannelServiceOrderProduct.del_flag == 0,
)
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
_save_gmcp_product_rows(db, order_id, products, tenant_no, user_id, now=now)
_save_gmcp_product_rows(db, order_id, products, tenant_no, user_id)
def _apply_gmcp_order_quotas(db, order, tenant_no, user_id):
+109 -4
View File
@@ -9,10 +9,53 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import SysPost
from app.crud.child_row_sync import sync_link_rows
from app.models.models import SysPost, SysPostRole, SysRole, SysUser, SysUserPost
def _row_to_dict(p: SysPost) -> Dict[str, Any]:
def _user_display_name(nick_name: Optional[str], user_no: Optional[str]) -> str:
n = (nick_name or "").strip()
if n:
return n
return (user_no or "").strip()
def _bound_user_names_by_post_ids(
db: Session, post_ids: List[str], tenant_no: str
) -> Dict[str, List[str]]:
"""批量查询职位关联用户显示名(nick_name 优先,否则 user_no)。"""
out: Dict[str, List[str]] = {}
ids = [pid for pid in (post_ids or []) if pid]
tenant = (tenant_no or "").strip()
if not ids or not tenant:
return out
rows = (
db.query(SysUserPost.post_id, SysUser.nick_name, SysUser.user_no)
.join(
SysUser,
and_(
SysUser.id == SysUserPost.user_id,
SysUser.del_flag == 0,
),
)
.filter(
and_(
SysUserPost.post_id.in_(ids),
SysUserPost.tenant_no == tenant,
SysUserPost.del_flag == 0,
)
)
.all()
)
for post_id, nick_name, user_no in rows:
name = _user_display_name(nick_name, user_no)
if not name:
continue
out.setdefault(post_id, []).append(name)
return out
def _row_to_dict(p: SysPost, bound_user_names: Optional[List[str]] = None) -> Dict[str, Any]:
return {
"id": p.id,
"postName": p.post_name,
@@ -21,6 +64,7 @@ def _row_to_dict(p: SysPost) -> Dict[str, Any]:
"tenantNo": p.tenant_no,
"orderNum": p.order_num if p.order_num is not None else 0,
"remark": p.remark or "",
"boundUserNames": list(bound_user_names or []),
"createTime": p.create_time.strftime("%Y-%m-%d %H:%M:%S") if p.create_time else "",
"updateTime": p.update_time.strftime("%Y-%m-%d %H:%M:%S") if p.update_time else "",
}
@@ -58,7 +102,10 @@ def list_posts(
.limit(page_size)
.all()
)
return [_row_to_dict(x) for x in rows], int(total)
name_map = _bound_user_names_by_post_ids(db, [x.id for x in rows], tenant_no or "")
return [
_row_to_dict(x, bound_user_names=name_map.get(x.id, [])) for x in rows
], int(total)
def get_post(db: Session, post_id: str) -> Optional[SysPost]:
@@ -154,9 +201,67 @@ def soft_delete_post(db: Session, post_id: str) -> None:
obj = get_post(db, post_id)
if not obj:
raise ValueError("职位不存在")
now = datetime.now()
obj.del_flag = 1
obj.update_time = datetime.now()
obj.update_time = now
db.add(obj)
db.query(SysPostRole).filter(
and_(SysPostRole.post_id == post_id, SysPostRole.del_flag == 0)
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
db.commit()
def list_role_ids_for_post(db: Session, post_id: str, tenant_no: str) -> List[str]:
rows = (
db.query(SysPostRole.role_id)
.filter(
and_(
SysPostRole.post_id == post_id,
SysPostRole.tenant_no == tenant_no,
SysPostRole.del_flag == 0,
)
)
.all()
)
return [r[0] for r in rows]
def sync_post_role_bindings(
db: Session,
post_id: str,
tenant_no: str,
role_ids: List[str],
updater_id: Optional[str],
) -> None:
"""按角色更新本职位的角色关联。"""
ordered = list(dict.fromkeys([rid for rid in (role_ids or []) if rid]))
for rid in ordered:
role = (
db.query(SysRole)
.filter(
and_(
SysRole.id == rid,
SysRole.del_flag == 0,
SysRole.tenant_no == tenant_no,
)
)
.first()
)
if not role:
raise ValueError("存在无效角色或不属于当前租户")
sync_link_rows(
db,
SysPostRole,
parent_attr="post_id",
parent_id=post_id,
link_attr="role_id",
link_ids=ordered,
tenant_no=tenant_no,
user_id=updater_id,
duplicate_message="角色重复",
foreign_message="角色关联不属于当前职位",
)
db.commit()
File diff suppressed because it is too large. Load diff
+138 -12
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizProcess,
BizProcessCategory,
@@ -82,6 +83,7 @@ def _row_to_dict(p: BizProcess) -> Dict[str, Any]:
"description": p.description or "",
"estimatedDuration": p.estimated_duration or "",
"sopIds": p.sop_ids or "",
"sopContent": p.sop_content or "",
"tenantNo": p.tenant_no,
"orderNum": p.order_num if p.order_num is not None else 0,
"remark": p.remark or "",
@@ -299,6 +301,132 @@ def _create_sub_data_collection(db: Session, process_id: str, tenant_no: str, us
db.add(obj)
def _new_process_child(model, row_id: str, process_id: str, tenant_no: str, user_id: Optional[str], now: datetime):
return model(
id=row_id,
process_id=process_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def _sync_process_reagents(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
item = _sub_item_with_order(data, idx)
row.reagent_code = (item.get("reagent_code") or "").strip()
row.reagent_name = (item.get("reagent_name") or "").strip()
row.spec = (item.get("spec") or "").strip() or None
row.standard_usage = (item.get("standard_usage") or "").strip() or None
row.unit = (item.get("unit") or "").strip() or None
row.is_key_material = item.get("is_key_material", 0)
row.remark = (item.get("remark") or "").strip() or None
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
sync_child_rows(
db,
BizProcessReagent,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(BizProcessReagent, row_id, process_id, tenant_no, user_id, now),
apply_fields=apply_row,
natural_key=lambda item: (item.get("reagent_code") or "").strip(),
row_natural_key=lambda row: (row.reagent_code or "").strip(),
duplicate_message="试剂编号重复",
foreign_message="试剂不属于当前工艺",
)
def _sync_process_consumables(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
item = _sub_item_with_order(data, idx)
row.consumable_code = (item.get("consumable_code") or "").strip()
row.consumable_name = (item.get("consumable_name") or "").strip()
row.spec_model = (item.get("spec_model") or "").strip() or None
row.standard_usage = (item.get("standard_usage") or "").strip() or None
row.unit = (item.get("unit") or "").strip() or None
row.is_required = item.get("is_required", 0)
row.remark = (item.get("remark") or "").strip() or None
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
sync_child_rows(
db,
BizProcessConsumable,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(BizProcessConsumable, row_id, process_id, tenant_no, user_id, now),
apply_fields=apply_row,
natural_key=lambda item: (item.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材编号重复",
foreign_message="耗材不属于当前工艺",
)
def _sync_process_outputs(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
item = _sub_item_with_order(data, idx)
row.output_type_id = (item.get("output_type_id") or "").strip() or None
row.output_name = (item.get("output_name") or "").strip() or None
row.output_barcode = (item.get("output_barcode") or "").strip() or None
row.remark = (item.get("remark") or "").strip() or None
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
sync_child_rows(
db,
BizProcessOutput,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(BizProcessOutput, row_id, process_id, tenant_no, user_id, now),
apply_fields=apply_row,
natural_key=lambda item: (item.get("output_barcode") or item.get("output_name") or "").strip(),
row_natural_key=lambda row: (row.output_barcode or row.output_name or "").strip(),
duplicate_message="产出物编号重复",
foreign_message="产出物不属于当前工艺",
)
def _sync_process_data_collections(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
row.collection_name = (data.get("collection_name") or "").strip()
row.unit = (data.get("unit") or "").strip() or None
order_num = data.get("order_num")
row.order_num = idx if order_num is None else order_num
row.remark = (data.get("remark") or "").strip() or None
sync_child_rows(
db,
BizProcessDataCollection,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(
BizProcessDataCollection, row_id, process_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("collection_name") or "").strip(),
row_natural_key=lambda row: (row.collection_name or "").strip(),
duplicate_message="数据采集编号重复",
foreign_message="数据采集不属于当前工艺",
)
def create_process(
db: Session,
*,
@@ -309,6 +437,7 @@ def create_process(
equipment_ids: Optional[Union[str, List[str]]] = None,
estimated_duration: Optional[str] = None,
sop_ids: Optional[str] = None,
sop_content: Optional[str] = None,
reagents: Optional[List[Dict[str, Any]]] = None,
consumables: Optional[List[Dict[str, Any]]] = None,
outputs: Optional[List[Dict[str, Any]]] = None,
@@ -334,6 +463,7 @@ def create_process(
equipment_name=None,
estimated_duration=(estimated_duration or "").strip() or None,
sop_ids=(sop_ids or "").strip() or None,
sop_content=(sop_content 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,
@@ -388,6 +518,7 @@ def update_process(
equipment_ids: Any = _UNSET,
estimated_duration: Optional[str] = None,
sop_ids: Optional[str] = None,
sop_content: Optional[str] = None,
reagents: Optional[List[Dict[str, Any]]] = None,
consumables: Optional[List[Dict[str, Any]]] = None,
outputs: Optional[List[Dict[str, Any]]] = None,
@@ -417,6 +548,8 @@ def update_process(
obj.estimated_duration = (estimated_duration or "").strip() or None
if sop_ids is not None:
obj.sop_ids = (sop_ids or "").strip() or None
if sop_content is not None:
obj.sop_content = (sop_content or "").strip() or None
if order_num is not None:
obj.order_num = order_num
if remark is not None:
@@ -428,22 +561,15 @@ def update_process(
sub_updated = reagents is not None or consumables is not None or outputs is not None or data_collections is not None
if sub_updated:
now = datetime.now()
tenant_no = obj.tenant_no
if reagents is not None:
_soft_delete_sub_records(db, BizProcessReagent, process_id, now)
for idx, r in enumerate(reagents):
_create_sub_reagent(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(r, idx))
_sync_process_reagents(db, process_id, tenant_no, user_id, now, reagents)
if consumables is not None:
_soft_delete_sub_records(db, BizProcessConsumable, process_id, now)
for idx, c in enumerate(consumables):
_create_sub_consumable(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(c, idx))
_sync_process_consumables(db, process_id, tenant_no, user_id, now, consumables)
if outputs is not None:
_soft_delete_sub_records(db, BizProcessOutput, process_id, now)
for idx, o in enumerate(outputs):
_create_sub_output(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(o, idx))
_sync_process_outputs(db, process_id, tenant_no, user_id, now, outputs)
if data_collections is not None:
_soft_delete_sub_records(db, BizProcessDataCollection, process_id, now)
for d in data_collections:
_create_sub_data_collection(db, process_id, obj.tenant_no, user_id, now, d)
_sync_process_data_collections(db, process_id, tenant_no, user_id, now, data_collections)
db.commit()
db.refresh(obj)
+180 -21
View File
@@ -9,8 +9,10 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
from app.models.models import (
BizCellProductType,
BizProductionProductType,
BizProcess,
BizProcessReagent,
BizProcessConsumable,
@@ -37,9 +39,12 @@ def _route_row_to_dict(r: BizProcessRoute) -> Dict[str, Any]:
"productTypeId": r.product_type_id or "",
"version": r.version or "",
"isCurrent": r.is_current if r.is_current is not None else "0",
"equipmentIds": parse_equipment_ids(getattr(r, "equipment_ids", None)),
"equipmentName": getattr(r, "equipment_name", None) or "",
"tenantNo": r.tenant_no,
"orderNum": r.order_num if r.order_num is not None else 0,
"remark": r.remark or "",
"isBuiltin": getattr(r, "is_builtin", None) or "0",
"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 "",
}
@@ -57,6 +62,7 @@ def _step_row_to_dict(s: BizProcessRouteStep) -> Dict[str, Any]:
"description": s.description or "",
"estimatedDuration": s.estimated_duration or "",
"sopIds": s.sop_ids or "",
"sopContent": s.sop_content or "",
"remark": s.remark or "",
}
@@ -144,15 +150,15 @@ def list_routes(db: Session, *, page: int = 1, page_size: int = 10,
rows = (
_filtered()
.outerjoin(
BizCellProductType,
BizProductionProductType,
and_(
BizProcessRoute.product_type_id == BizCellProductType.id,
BizCellProductType.del_flag == 0,
BizProcessRoute.product_type_id == BizProductionProductType.id,
BizProductionProductType.del_flag == 0,
),
)
.order_by(
BizCellProductType.product_type_name.is_(None),
BizCellProductType.product_type_name.asc(),
BizProductionProductType.product_type_name.is_(None),
BizProductionProductType.product_type_name.asc(),
BizProcessRoute.create_time.desc(),
)
.offset((page - 1) * page_size)
@@ -168,6 +174,38 @@ def get_route(db: Session, route_id: str) -> Optional[BizProcessRoute]:
).first()
def get_step(db: Session, step_id: str) -> Optional[BizProcessRouteStep]:
return db.query(BizProcessRouteStep).filter(
and_(BizProcessRouteStep.id == step_id, BizProcessRouteStep.del_flag == 0)
).first()
def is_super_admin_user(user: Optional[Dict[str, Any]]) -> bool:
return bool(user) and (user.get("role") or "") == "super_admin"
def assert_route_writable(
db: Session, route_id: str, user: Optional[Dict[str, Any]] = None
) -> BizProcessRoute:
"""内置模版仅超级管理员可写;返回路线对象。"""
route = get_route(db, route_id)
if not route:
raise ValueError("路线不存在")
if (getattr(route, "is_builtin", None) or "0") == "1" and not is_super_admin_user(user):
raise ValueError("内置模版仅超级管理员可修改")
return route
def assert_step_route_writable(
db: Session, step_id: str, user: Optional[Dict[str, Any]] = None
) -> BizProcessRouteStep:
step = get_step(db, step_id)
if not step:
raise ValueError("步骤不存在")
assert_route_writable(db, step.route_id, user)
return step
def get_route_detail(db: Session, route_id: str) -> Optional[Dict[str, Any]]:
route = get_route(db, route_id)
if not route:
@@ -224,6 +262,7 @@ def create_route(db: Session, *, route_code: str, route_name: str,
tenant_no=tenant_no,
order_num=0,
remark=(remark or "").strip() or None,
is_builtin="0",
create_by=user_id,
create_dept_id="",
create_time=now,
@@ -245,6 +284,12 @@ def update_route(db: Session, route_id: str, *, user_id: Optional[str] = None, *
val = kwargs.get(field)
if val is not None:
setattr(obj, field, (val or "").strip() or None if field != "route_code" else (val or "").strip())
if "equipment_ids" in kwargs:
eids, enames = resolve_equipment_list_snapshot(
db, kwargs.get("equipment_ids"), obj.tenant_no or "zkxw"
)
obj.equipment_ids = eids
obj.equipment_name = enames
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
@@ -376,6 +421,7 @@ def add_step(db: Session, *, route_id: str, process_id: str, step_order: Optiona
process_name=proc.process_name, description=proc.description,
estimated_duration=proc.estimated_duration,
sop_ids=proc.sop_ids,
sop_content=proc.sop_content,
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,
@@ -432,10 +478,13 @@ def update_step(db: Session, step_id: str, *, user_id: Optional[str] = None, **k
if not step:
raise ValueError("步骤不存在")
for field in ["process_code", "category_id", "process_name", "description",
"estimated_duration", "sop_ids"]:
"estimated_duration", "sop_ids", "sop_content"]:
val = kwargs.get(field)
if val is not None:
setattr(step, field, (val or "").strip() or None)
if field == "sop_content":
setattr(step, field, val if str(val).strip() else None)
else:
setattr(step, field, (val or "").strip() or None)
step.update_by = user_id
step.update_time = datetime.now()
db.add(step)
@@ -494,6 +543,7 @@ def copy_step(db: Session, step_id: str, *, tenant_no: str = "zkxw",
process_name=new_name, description=src.description,
estimated_duration=src.estimated_duration,
sop_ids=src.sop_ids,
sop_content=src.sop_content,
tenant_no=tenant_no, order_num=0,
remark=src.remark, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
@@ -603,38 +653,147 @@ def soft_delete_route(db: Session, route_id: str) -> None:
# ---------------------------------------------------------------------------
# 子表完整替换更新
# 子表按行更新:保留原行 ID,去掉的才软删除
# ---------------------------------------------------------------------------
def _new_route_step_child(model, row_id, step_id, tenant_no, user_id, now):
return model(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def update_step_reagents(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepReagent, step_id, now)
for it in items:
_create_step_reagent(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.reagent_code = (data.get("reagent_code") or "").strip() or None
row.reagent_name = (data.get("reagent_name") or "").strip() or None
row.spec = (data.get("spec") or "").strip() or None
row.standard_usage = (data.get("standard_usage") or "").strip() or None
row.unit = (data.get("unit") or "").strip() or None
row.is_key_material = data.get("is_key_material", "0")
row.remark = (data.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProcessRouteStepReagent,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepReagent, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("reagent_code") or "").strip(),
row_natural_key=lambda row: (row.reagent_code or "").strip(),
duplicate_message="试剂编号重复",
foreign_message="试剂不属于当前步骤",
)
db.commit()
def update_step_consumables(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepConsumable, step_id, now)
for it in items:
_create_step_consumable(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.consumable_code = (data.get("consumable_code") or "").strip() or None
row.consumable_name = (data.get("consumable_name") or "").strip() or None
row.spec_model = (data.get("spec_model") or "").strip() or None
row.standard_usage = (data.get("standard_usage") or "").strip() or None
row.unit = (data.get("unit") or "").strip() or None
row.is_required = data.get("is_required", "1")
row.remark = (data.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProcessRouteStepConsumable,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepConsumable, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材编号重复",
foreign_message="耗材不属于当前步骤",
)
db.commit()
def update_step_outputs(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepOutput, step_id, now)
for it in items:
_create_step_output(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.output_type_id = (data.get("output_type_id") or "").strip() or None
row.output_name = (data.get("output_name") or "").strip() or None
row.output_barcode = (data.get("output_barcode") or "").strip() or None
row.remark = (data.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProcessRouteStepOutput,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepOutput, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("output_barcode") or item.get("output_name") or "").strip(),
row_natural_key=lambda row: (row.output_barcode or row.output_name or "").strip(),
duplicate_message="产出物编号重复",
foreign_message="产出物不属于当前步骤",
)
db.commit()
def update_step_data_collections(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepDataCollection, step_id, now)
for it in items:
_create_step_data_collection(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.collection_name = (data.get("collection_name") or "").strip() or None
row.unit = (data.get("unit") or "").strip() or None
order_num = data.get("order_num")
row.order_num = idx if order_num is None else (order_num or 0)
row.remark = (data.get("remark") or "").strip() or None
sync_child_rows(
db,
BizProcessRouteStepDataCollection,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepDataCollection, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("collection_name") or "").strip(),
row_natural_key=lambda row: (row.collection_name or "").strip(),
duplicate_message="数据采集编号重复",
foreign_message="数据采集不属于当前步骤",
)
db.commit()
+50 -37
View File
@@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.production_material import _dec, _fmt_dt
from app.models.production_material import BizProductionMaterial, BizProductionMaterialFormula
@@ -84,7 +84,7 @@ def replace_formula(
raise ValueError("请至少添加一条配方明细")
seen_components: set = set()
new_rows: List[BizProductionMaterialFormula] = []
normalized: List[Dict[str, Any]] = []
for idx, it in enumerate(lines):
comp_id = (
@@ -125,45 +125,58 @@ def replace_formula(
it.get("usageUnit") or it.get("usage_unit") or comp.unit or ""
).strip()[:32]
new_rows.append(
BizProductionMaterialFormula(
id=generate_uuid(),
configured_material_id=configured_material_id,
component_material_id=comp_id,
component_material_type=comp_type,
component_material_code=comp.material_code,
component_material_name=comp.material_name,
standard_qty=standard_qty,
usage_unit=usage_unit or None,
tenant_no=tenant_no,
order_num=idx,
remark=(it.get("remark") or "").strip()[:512] or None,
create_by=user_id,
create_dept_id=dept_id or configured.create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
normalized.append(
{
"id": (it.get("id") or "").strip(),
"component_material_id": comp_id,
"component_material_type": comp_type,
"component_material_code": comp.material_code,
"component_material_name": comp.material_name,
"standard_qty": standard_qty,
"usage_unit": usage_unit or None,
"order_num": idx,
"remark": (it.get("remark") or "").strip()[:512] or None,
}
)
existing = (
db.query(BizProductionMaterialFormula)
.filter(
BizProductionMaterialFormula.configured_material_id == configured_material_id,
BizProductionMaterialFormula.tenant_no == tenant_no,
BizProductionMaterialFormula.del_flag == 0,
dept = dept_id or configured.create_dept_id or ""
def build_row(row_id: str) -> BizProductionMaterialFormula:
return BizProductionMaterialFormula(
id=row_id,
configured_material_id=configured_material_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=dept,
create_time=now,
)
.all()
def apply_row(row: BizProductionMaterialFormula, it: Dict[str, Any], idx: int, _now: datetime) -> None:
row.component_material_id = it["component_material_id"]
row.component_material_type = it["component_material_type"]
row.component_material_code = it["component_material_code"]
row.component_material_name = it["component_material_name"]
row.standard_qty = it["standard_qty"]
row.usage_unit = it["usage_unit"]
row.order_num = idx
row.remark = it["remark"]
sync_child_rows(
db,
BizProductionMaterialFormula,
parent_attr="configured_material_id",
parent_id=configured_material_id,
tenant_no=tenant_no,
items=normalized,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("component_material_id") or "").strip(),
row_natural_key=lambda row: (row.component_material_id or "").strip(),
duplicate_message="配方明细编号重复",
foreign_message="配方明细不属于当前配置试剂",
)
for row in existing:
row.del_flag = 1
row.update_by = user_id
row.update_time = now
db.add(row)
for row in new_rows:
db.add(row)
db.commit()
return get_formula(db, configured_material_id, tenant_no)
+56 -34
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime
from app.crud.child_row_sync import sync_child_rows
from app.crud.production_material import _dec, _fmt_dt, _is_enabled_status
from app.crud.production_material_stock_batch import (
_create_batch_and_usage,
@@ -1007,44 +1008,65 @@ def update_prep(
lines_raw = data.get("lines")
if lines_raw is not None:
parsed_lines = _parse_prep_lines(db, lines_raw, tenant_no=tenant_no)
existing = (
db.query(BizProductionMaterialPrepLine)
.filter(
BizProductionMaterialPrepLine.prep_id == prep_id,
BizProductionMaterialPrepLine.del_flag == 0,
)
.all()
)
for ln in existing:
ln.del_flag = 1
ln.update_by = user_id
ln.update_time = now
db.add(ln)
line_items = []
for pl in parsed_lines:
comp = pl["component"]
db.add(
BizProductionMaterialPrepLine(
id=generate_uuid(),
prep_id=prep_id,
component_material_id=comp.id,
component_material_type=pl["component_type"],
component_material_code=comp.material_code,
component_material_name=comp.material_name,
requisition_usage_id=pl["requisition"].id,
actual_qty=pl["actual_qty"],
usage_unit=pl["usage_unit"] or None,
tenant_no=tenant_no,
order_num=pl["idx"],
remark=pl["remark"],
create_by=user_id,
create_dept_id=dept_id or prep.create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
line_items.append(
{
"id": (pl.get("id") or "").strip(),
"component_material_id": comp.id,
"component_material_type": pl["component_type"],
"component_material_code": comp.material_code,
"component_material_name": comp.material_name,
"requisition_usage_id": pl["requisition"].id,
"actual_qty": pl["actual_qty"],
"usage_unit": pl["usage_unit"] or None,
"order_num": pl["idx"],
"remark": pl["remark"],
}
)
def build_line(row_id):
return BizProductionMaterialPrepLine(
id=row_id,
prep_id=prep_id,
component_material_id="",
component_material_type="",
actual_qty=0,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=dept_id or prep.create_dept_id or "",
create_time=now,
)
def apply_line(row, item, _idx, _now):
row.component_material_id = item["component_material_id"]
row.component_material_type = item["component_material_type"]
row.component_material_code = item["component_material_code"]
row.component_material_name = item["component_material_name"]
row.requisition_usage_id = item["requisition_usage_id"]
row.actual_qty = item["actual_qty"]
row.usage_unit = item["usage_unit"]
row.order_num = item["order_num"]
row.remark = item["remark"]
sync_child_rows(
db,
BizProductionMaterialPrepLine,
parent_attr="prep_id",
parent_id=prep_id,
tenant_no=tenant_no,
items=line_items,
user_id=user_id,
now=now,
build_row=build_line,
apply_fields=apply_line,
natural_key=lambda item: f"{item['component_material_id']}:{item['requisition_usage_id'] or ''}",
row_natural_key=lambda row: f"{row.component_material_id}:{row.requisition_usage_id or ''}",
duplicate_message="配制明细重复",
foreign_message="配制明细不属于当前配制单",
)
prep.update_by = user_id
prep.update_time = now
db.add(prep)
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.production_material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
from app.models.models import SysUser
from app.models.production_material import (
@@ -449,6 +450,47 @@ def _resolve_unused_item_fields(
return out
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
usage = (usage_id or "").strip()
if usage:
return f"usage:{usage}"
batch = (batch_id or "").strip()
if batch:
return f"batch:{batch}"
return ""
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
material_type = it.get("material_type") if "material_type" in it else it.get("materialType")
return {
"id": str(it.get("id") or "").strip(),
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
"material_type": (material_type or "").strip() or None if material_type is not None else None,
"has_material_type": "material_type" in it or "materialType" in it,
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
"material_name": name,
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
"unit": (it.get("unit") or "").strip() or None,
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
"manufacturer": (it.get("manufacturer") or "").strip() or None,
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
"holder": (it.get("holder") or "").strip() or None,
"unused_qty": Decimal(str(qty)),
"remark": (it.get("remark") or "").strip() or None,
}
def save_unused_lines(
db: Session,
check_id: str,
@@ -465,73 +507,63 @@ def save_unused_lines(
raise ValueError("仅进行中的盘点单可登记未用完物料")
now = datetime.now()
old_rows = (
db.query(BizProductionMaterialStockCheckUnusedLine)
.filter(
BizProductionMaterialStockCheckUnusedLine.check_id == check_id,
BizProductionMaterialStockCheckUnusedLine.del_flag == 0,
)
.all()
)
for r in old_rows:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
db.add(r)
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
count = 0
for it in items or []:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
qty_dec = Decimal(str(qty))
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
row = BizProductionMaterialStockCheckUnusedLine(
id=generate_uuid(),
def build_row(row_id: str):
return BizProductionMaterialStockCheckUnusedLine(
id=row_id,
check_id=check_id,
usage_id=usage_id,
batch_id=batch_id,
material_type=(it.get("material_type") or it.get("materialType") or "").strip() or None,
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
material_name=name,
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
stock_in_date=stock_in_date,
expire_date=expire_date,
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
manufacturer=(it.get("manufacturer") or "").strip() or None,
unit_price=(
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
else None
),
holder=(it.get("holder") or "").strip() or None,
unused_qty=qty_dec,
material_name="",
unused_qty=0,
tenant_no=tenant_no,
remark=(it.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
count += 1
def apply_row(row, it, idx, _now):
row.usage_id = it["usage_id"]
row.batch_id = it["batch_id"]
if it["has_material_type"]:
row.material_type = it["material_type"]
row.material_code = it["material_code"]
row.material_name = it["material_name"]
row.spec_model = it["spec_model"]
row.unit = it["unit"]
row.stock_in_no = it["stock_in_no"]
row.stock_in_date = it["stock_in_date"]
row.expire_date = it["expire_date"]
row.catalog_no = it["catalog_no"]
row.batch_no = it["batch_no"]
row.manufacturer = it["manufacturer"]
row.unit_price = it["unit_price"]
row.holder = it["holder"]
row.unused_qty = it["unused_qty"]
row.remark = it["remark"]
row.order_num = idx
saved = sync_child_rows(
db,
BizProductionMaterialStockCheckUnusedLine,
parent_attr="check_id",
parent_id=check_id,
tenant_no=tenant_no,
items=prepared,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
duplicate_message="未用完明细重复",
foreign_message="未用完明细不属于当前盘点单",
)
check.update_by = user_id
check.update_time = now
db.add(check)
db.commit()
return count
return len(saved)
def complete_check(
+183
View File
@@ -0,0 +1,183 @@
"""生产产品类型 CRUD(biz_production_product_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 BizProductionProductType
def _row_to_dict(row: BizProductionProductType) -> Dict[str, Any]:
return {
"id": row.id,
"productTypeCode": row.product_type_code or "",
"productTypeName": row.product_type_name or "",
"tenantNo": row.tenant_no or "",
"orderNum": row.order_num if row.order_num is not None else 0,
"remark": row.remark or "",
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
}
def list_production_product_types(
db: Session,
*,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
tenant_no: Optional[str] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
def _filtered():
q0 = db.query(BizProductionProductType).filter(BizProductionProductType.del_flag == 0)
if tenant_no:
q0 = q0.filter(BizProductionProductType.tenant_no == tenant_no)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q0 = q0.filter(
or_(
BizProductionProductType.product_type_code.like(like),
BizProductionProductType.product_type_name.like(like),
)
)
if user_info:
from app.core.data_scope import apply_data_scope
q0 = apply_data_scope(q0, BizProductionProductType, user_info, db, tenant_no)
return q0
total = (
_filtered().order_by(None).with_entities(func.count(BizProductionProductType.id)).scalar() or 0
)
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
_filtered()
.order_by(BizProductionProductType.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_production_product_type(
db: Session, type_id: str, tenant_no: Optional[str] = None
) -> Optional[BizProductionProductType]:
q = db.query(BizProductionProductType).filter(
and_(BizProductionProductType.id == type_id, BizProductionProductType.del_flag == 0)
)
if tenant_no:
q = q.filter(BizProductionProductType.tenant_no == tenant_no)
return q.first()
def product_type_code_exists(
db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None
) -> bool:
q = db.query(BizProductionProductType).filter(
and_(
BizProductionProductType.product_type_code == code,
BizProductionProductType.del_flag == 0,
)
)
if tenant_no:
q = q.filter(BizProductionProductType.tenant_no == tenant_no)
if exclude_id:
q = q.filter(BizProductionProductType.id != exclude_id)
return db.query(q.exists()).scalar()
def create_production_product_type(
db: Session,
*,
product_type_code: str,
product_type_name: str,
tenant_no: str = "zkxw",
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
) -> BizProductionProductType:
code = (product_type_code or "").strip()
if not code:
raise ValueError("产品类型编码不能为空")
if product_type_code_exists(db, code, tenant_no):
raise ValueError(f"产品类型编码 {code} 已存在")
obj = BizProductionProductType(
id=generate_uuid(),
product_type_code=code,
product_type_name=(product_type_name or "").strip() or code,
tenant_no=tenant_no,
order_num=order_num if order_num is not None else 0,
remark=(remark or "").strip() or "",
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_production_product_type(
db: Session,
type_id: str,
*,
product_type_code: Optional[str] = None,
product_type_name: Optional[str] = None,
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
tenant_no: Optional[str] = None,
) -> BizProductionProductType:
obj = get_production_product_type(db, type_id, tenant_no)
if not obj:
raise ValueError("记录不存在")
if product_type_code is not None and product_type_code != obj.product_type_code:
new_code = product_type_code.strip()
if not new_code:
raise ValueError("产品类型编码不能为空")
if product_type_code_exists(db, new_code, tenant_no or obj.tenant_no, exclude_id=type_id):
raise ValueError(f"产品类型编码 {new_code} 已存在")
obj.product_type_code = new_code
if product_type_name is not None:
obj.product_type_name = (product_type_name or "").strip() or obj.product_type_name
if order_num is not None:
obj.order_num = order_num
if remark is not None:
obj.remark = (remark or "").strip() or ""
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def soft_delete_production_product_type(
db: Session, type_id: str, tenant_no: Optional[str] = None
) -> None:
obj = get_production_product_type(db, type_id, 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)))
+406 -139
View File
@@ -9,7 +9,9 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import format_date, generate_uuid, parse_datetime
from app.crud.child_row_sync import sync_child_rows
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
from app.crud.user_display import user_display_name_map
from app.models.models import (
BizCellProduct,
BizCollectionProtocol,
@@ -309,6 +311,7 @@ def _step_to_dict(s: BizProductionTaskStep) -> Dict[str, Any]:
"estimatedDuration": s.estimated_duration or "",
"actualDuration": s.actual_duration or "",
"sopIds": s.sop_ids or "",
"sopContent": s.sop_content or "",
"stepStatus": s.step_status or "dzx",
"plannedStartTime": format_date(getattr(s, "planned_start_time", None)) or "",
"startedTime": _fmt_dt(s.started_time),
@@ -688,6 +691,7 @@ def _clone_route_to_task(
process_name=rs.process_name, description=rs.description,
estimated_duration=rs.estimated_duration,
sop_ids=rs.sop_ids,
sop_content=rs.sop_content,
step_status="dzx",
tenant_no=tenant_no, order_num=0,
remark=None, create_by=user_id, create_dept_id=create_dept_id or "",
@@ -725,12 +729,7 @@ def _enrich_assignee_names(db: Session, items: List[Dict[str, Any]]) -> None:
user_ids = list({(x.get("currentAssigneeUserId") or "").strip() for x in items if (x.get("currentAssigneeUserId") or "").strip()})
if not user_ids:
return
rows = (
db.query(SysUser.id, SysUser.nick_name)
.filter(SysUser.id.in_(user_ids), SysUser.del_flag == 0)
.all()
)
name_map = {r.id: (r.nick_name or "").strip() for r in rows}
name_map = user_display_name_map(db, user_ids)
for item in items:
uid = (item.get("currentAssigneeUserId") or "").strip()
if uid:
@@ -1017,6 +1016,19 @@ def create_production_task(
route_id = data.get("route_id")
if route_id:
_clone_route_to_task(db, task.id, route_id, tenant_no, user_id, create_dept_id)
route = (
db.query(BizProcessRoute)
.filter(
BizProcessRoute.id == route_id,
BizProcessRoute.tenant_no == tenant_no,
BizProcessRoute.del_flag == 0,
)
.first()
)
if route and (getattr(route, "equipment_ids", None) or getattr(route, "equipment_name", None)):
task.equipment_ids = route.equipment_ids
task.equipment_name = route.equipment_name
db.add(task)
from app.crud import production_work_order as production_work_order_svc
@@ -1168,17 +1180,21 @@ def list_production_tasks(
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
keyword_conditions = [
BizProductionTask.task_no.like(like),
BizProductionWorkOrder.work_order_no.like(like),
BizProductionWorkOrder.sample_code.like(like),
]
if tenant_no:
from app.crud.production_work_order import _outbound_work_order_ids_by_keyword
wo_type = (work_order_type or "").strip()
if wo_type == "temporary":
keyword_conditions = [BizProductionWorkOrder.remark.like(like)]
else:
keyword_conditions = [
BizProductionTask.task_no.like(like),
BizProductionWorkOrder.work_order_no.like(like),
BizProductionWorkOrder.sample_code.like(like),
]
if tenant_no:
from app.crud.production_work_order import _outbound_work_order_ids_by_keyword
outbound_ids = _outbound_work_order_ids_by_keyword(db, tenant_no, kw)
if outbound_ids:
keyword_conditions.append(BizProductionWorkOrder.id.in_(outbound_ids))
outbound_ids = _outbound_work_order_ids_by_keyword(db, tenant_no, kw)
if outbound_ids:
keyword_conditions.append(BizProductionWorkOrder.id.in_(outbound_ids))
q = q.outerjoin(
BizProductionWorkOrder,
and_(
@@ -1955,6 +1971,37 @@ def _consume_task_input_cell_products(
pass
def _force_complete_task_steps(
db: Session,
task_id: str,
tenant_no: str,
user_id: Optional[str],
now: datetime,
) -> int:
"""将任务下未完成步骤置为 ywc;不 commit。返回更新条数。"""
steps = (
db.query(BizProductionTaskStep)
.filter(
and_(
BizProductionTaskStep.task_id == task_id,
BizProductionTaskStep.tenant_no == tenant_no,
BizProductionTaskStep.del_flag == 0,
BizProductionTaskStep.step_status != "ywc",
)
)
.all()
)
for step in steps:
step.step_status = "ywc"
if user_id:
step.completed_by = user_id
step.completed_time = now
step.update_by = user_id
step.update_time = now
db.add(step)
return len(steps)
def update_task_status(
db: Session,
task_id: str,
@@ -1986,15 +2033,7 @@ def update_task_status(
if concl not in ("cg", "sb"):
raise ValueError("须选择生产结论(成功或失败)")
if previous_status != "ywc":
incomplete_step_count = db.query(BizProductionTaskStep).filter(
and_(
BizProductionTaskStep.task_id == task_id,
BizProductionTaskStep.del_flag == 0,
BizProductionTaskStep.step_status != "ywc",
)
).count()
if incomplete_step_count > 0:
raise ValueError("尚有未完成的工艺步骤,请完成全部步骤后再完成任务")
_force_complete_task_steps(db, task_id, tenant_no, user_id, now)
task.conclusion = concl
if remark is not None:
task.remark = (remark or "").strip() or None
@@ -2055,6 +2094,7 @@ def add_task_step(
description=proc.description,
estimated_duration=proc.estimated_duration,
sop_ids=proc.sop_ids,
sop_content=proc.sop_content,
step_status="dzx",
tenant_no=tenant_no, order_num=0,
remark=None,
@@ -2135,6 +2175,7 @@ def add_task_step(
estimated_duration=(data.get("estimated_duration") or "").strip() or None,
actual_duration=(data.get("actual_duration") or "").strip() or None,
sop_ids=(data.get("sop_ids") or "").strip() or None,
sop_content=(data.get("sop_content") if str(data.get("sop_content") or "").strip() else None),
step_status=(data.get("step_status") or "dzx"),
tenant_no=tenant_no, order_num=0,
remark=(data.get("remark") or "").strip() or None,
@@ -2186,12 +2227,14 @@ def update_task_step(
for field in [
"step_order", "process_code", "category_id", "process_name",
"description", "estimated_duration", "actual_duration",
"sop_ids", "step_status", "remark",
"sop_ids", "sop_content", "step_status", "remark",
]:
val = data.get(field)
if val is not None:
if field == "step_order":
setattr(step, field, int(val))
elif field == "sop_content":
setattr(step, field, val if str(val).strip() else None)
else:
setattr(step, field, (val or "").strip() or None)
if data.get("started_time"):
@@ -2260,10 +2303,17 @@ def replace_step_reagents(
task_no = task_row.task_no or ""
try:
_soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now)
def build_row(row_id: str) -> BizProductionTaskStepReagent:
return BizProductionTaskStepReagent(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
usage_items: List[Dict[str, Any]] = []
for idx, it in enumerate(items):
def apply_row(row: BizProductionTaskStepReagent, it: Dict[str, Any], idx: int, _now: datetime) -> None:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
@@ -2275,20 +2325,39 @@ def replace_step_reagents(
it.get("actual_usage"),
req_id,
)
db.add(BizProductionTaskStepReagent(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
reagent_code=(it.get("reagent_code") or "").strip() or None,
reagent_name=(it.get("reagent_name") or "").strip() or None,
spec=(it.get("spec") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
requisition_usage_id=req_id or None,
is_key_material=it.get("is_key_material", "0"),
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
row.reagent_code = (it.get("reagent_code") or "").strip() or None
row.reagent_name = (it.get("reagent_name") or "").strip() or None
row.spec = (it.get("spec") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.is_key_material = it.get("is_key_material", "0")
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepReagent,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("reagent_code") or "").strip(),
row_natural_key=lambda row: (row.reagent_code or "").strip(),
duplicate_message="试剂编号重复",
foreign_message="试剂不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
if req_id:
usage_items.append({
"requisitionUsageId": req_id,
@@ -2330,10 +2399,17 @@ def replace_step_consumables(
task_no = task_row.task_no or ""
try:
_soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now)
def build_row(row_id: str) -> BizProductionTaskStepConsumable:
return BizProductionTaskStepConsumable(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
usage_items: List[Dict[str, Any]] = []
for idx, it in enumerate(items):
def apply_row(row: BizProductionTaskStepConsumable, it: Dict[str, Any], idx: int, _now: datetime) -> None:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
@@ -2345,20 +2421,39 @@ def replace_step_consumables(
it.get("actual_usage"),
req_id,
)
db.add(BizProductionTaskStepConsumable(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
consumable_code=(it.get("consumable_code") or "").strip() or None,
consumable_name=(it.get("consumable_name") or "").strip() or None,
spec_model=(it.get("spec_model") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
requisition_usage_id=req_id or None,
is_required=it.get("is_required", "1"),
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
row.consumable_code = (it.get("consumable_code") or "").strip() or None
row.consumable_name = (it.get("consumable_name") or "").strip() or None
row.spec_model = (it.get("spec_model") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.is_required = it.get("is_required", "1")
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepConsumable,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材编号重复",
foreign_message="耗材不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
if req_id:
usage_items.append({
"requisitionUsageId": req_id,
@@ -2399,10 +2494,22 @@ def replace_step_configured_reagents(
task_no = task_row.task_no or ""
try:
_soft_delete_sub(db, BizProductionTaskStepConfiguredReagent, "step_id", step_id, now)
def build_row(row_id: str) -> BizProductionTaskStepConfiguredReagent:
return BizProductionTaskStepConfiguredReagent(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
usage_items: List[Dict[str, Any]] = []
for idx, it in enumerate(items):
def apply_row(
row: BizProductionTaskStepConfiguredReagent,
it: Dict[str, Any],
idx: int,
_now: datetime,
) -> None:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
@@ -2414,19 +2521,38 @@ def replace_step_configured_reagents(
it.get("actual_usage"),
req_id,
)
db.add(BizProductionTaskStepConfiguredReagent(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
configured_reagent_code=(it.get("configured_reagent_code") or "").strip() or None,
configured_reagent_name=(it.get("configured_reagent_name") or "").strip() or None,
spec=(it.get("spec") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
requisition_usage_id=req_id or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
row.configured_reagent_code = (it.get("configured_reagent_code") or "").strip() or None
row.configured_reagent_name = (it.get("configured_reagent_name") or "").strip() or None
row.spec = (it.get("spec") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepConfiguredReagent,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("configured_reagent_code") or "").strip(),
row_natural_key=lambda row: (row.configured_reagent_code or "").strip(),
duplicate_message="配置试剂编号重复",
foreign_message="配置试剂不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
if req_id:
usage_items.append({
"requisitionUsageId": req_id,
@@ -2446,23 +2572,62 @@ def replace_step_configured_reagents(
raise
def _apply_step_output_fields(
row: BizProductionTaskStepOutput,
item: Dict[str, Any],
*,
order_num: int,
user_id: Optional[str],
now: datetime,
) -> None:
row.output_type_id = (item.get("output_type_id") or "").strip() or None
row.output_name = (item.get("output_name") or "").strip() or None
row.output_barcode = (item.get("output_barcode") or "").strip() or None
row.remark = (item.get("remark") or "").strip() or None
row.order_num = order_num
row.update_by = user_id
row.update_time = now
row.del_flag = 0
def replace_step_outputs(
db: Session, step_id: str, tenant_no: str,
user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
) -> List[Dict[str, Any]]:
"""按产出物 ID 更新原行。带 ID 的行原地修改,没有 ID 的才新增,提交列表里没有的才软删除。
质检工单用 production_step_output_id 指向这里。整表软删再插入会换掉 ID,关联就断了。
"""
now = datetime.now()
_soft_delete_sub(db, BizProductionTaskStepOutput, "step_id", step_id, now)
for idx, it in enumerate(items):
db.add(BizProductionTaskStepOutput(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
output_type_id=(it.get("output_type_id") or "").strip() or None,
output_name=(it.get("output_name") or "").strip() or None,
output_barcode=(it.get("output_barcode") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
def build_row(row_id: str) -> BizProductionTaskStepOutput:
return BizProductionTaskStepOutput(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
result_rows = sync_child_rows(
db,
BizProductionTaskStepOutput,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=lambda row, item, idx, now_: _apply_step_output_fields(
row, item, order_num=idx, user_id=user_id, now=now_
),
duplicate_message="产出物编号重复",
foreign_message="产出物不属于当前步骤",
)
db.commit()
return [_step_output_to_dict(row) for row in result_rows]
def replace_step_data_collections(
@@ -2470,17 +2635,45 @@ def replace_step_data_collections(
user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
now = datetime.now()
_soft_delete_sub(db, BizProductionTaskStepDataCollection, "step_id", step_id, now)
for idx, it in enumerate(items):
db.add(BizProductionTaskStepDataCollection(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
collection_name=(it.get("collection_name") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
collection_value=(it.get("collection_value") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
def build_row(row_id: str) -> BizProductionTaskStepDataCollection:
return BizProductionTaskStepDataCollection(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(
row: BizProductionTaskStepDataCollection,
it: Dict[str, Any],
idx: int,
_now: datetime,
) -> None:
row.collection_name = (it.get("collection_name") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.collection_value = (it.get("collection_value") or "").strip() or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepDataCollection,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("collection_name") or "").strip(),
row_natural_key=lambda row: (row.collection_name or "").strip(),
duplicate_message="数据采集编号重复",
foreign_message="数据采集不属于当前步骤",
)
db.commit()
@@ -2558,58 +2751,129 @@ def spawn_temporary_work_order_from_task(
}
def _task_input_identity(input_type: Optional[str], input_id: Optional[str], input_code: Optional[str]) -> str:
kind = (input_type or "").strip()
ident = (input_id or "").strip() or (input_code or "").strip()
if not kind or not ident:
return ""
return f"{kind}:{ident}"
def _task_input_item_identity(it: Dict[str, Any]) -> str:
return _task_input_identity(
it.get("input_type") or it.get("inputType"),
it.get("input_id") or it.get("inputId"),
it.get("input_code") or it.get("inputCode"),
)
def _set_cell_product_status(
db: Session,
product_id: Optional[str],
*,
user_id: Optional[str],
now: datetime,
to_status: str,
from_statuses: tuple,
) -> None:
pid = (product_id or "").strip()
if not pid:
return
product = db.query(BizCellProduct).filter(
BizCellProduct.id == pid, BizCellProduct.del_flag == 0
).first()
if product and product.product_status in from_statuses:
product.product_status = to_status
product.update_by = user_id
product.update_time = now
db.add(product)
def set_task_inputs(
db: Session, task_id: str, tenant_no: str,
user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
now = datetime.now()
# --- 1. 加载旧 inputs 并按 cell_product 恢复状态 ---
old_rows = db.query(BizProductionTaskInput).filter(
and_(
BizProductionTaskInput.task_id == task_id,
BizProductionTaskInput.tenant_no == tenant_no,
BizProductionTaskInput.del_flag == 0,
)
).all()
old_identity = {
row.id: (row.input_type or "", row.input_id or "")
for row in old_rows
}
def build_row(row_id: str) -> BizProductionTaskInput:
return BizProductionTaskInput(
id=row_id,
task_id=task_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row: BizProductionTaskInput, it: Dict[str, Any], idx: int, _now: datetime) -> None:
row.input_type = (it.get("input_type") or it.get("inputType") or "").strip() or None
row.input_id = (it.get("input_id") or it.get("inputId") or "").strip() or None
row.input_code = (it.get("input_code") or it.get("inputCode") or "").strip() or None
row.input_name = (it.get("input_name") or it.get("inputName") or "").strip() or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
saved = sync_child_rows(
db,
BizProductionTaskInput,
parent_attr="task_id",
parent_id=task_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_task_input_item_identity,
row_natural_key=lambda row: _task_input_identity(row.input_type, row.input_id, row.input_code),
duplicate_message="原材料编号重复",
foreign_message="原材料不属于当前任务",
)
saved_ids = {row.id for row in saved}
for old_row in old_rows:
# 如果是细胞产品,恢复状态
if old_row.input_type == "cell_product" and old_row.input_id:
product = db.query(BizCellProduct).filter(
BizCellProduct.id == old_row.input_id, BizCellProduct.del_flag == 0
).first()
if product and product.product_status == "in_production":
product.product_status = "frozen"
product.update_by = user_id
product.update_time = now
db.add(product)
# --- 2. 全量软删除旧行 ---
_soft_delete_sub(db, BizProductionTaskInput, "task_id", task_id, now)
# --- 3. 按传入顺序批量新建 ---
for idx, it in enumerate(items):
inp_type = (it.get("input_type") or it.get("inputType") or "").strip()
inp_id = (it.get("input_id") or it.get("inputId") or "").strip()
# 如果是细胞产品,设置状态为 in_production
if inp_type == "cell_product" and inp_id:
product = db.query(BizCellProduct).filter(
BizCellProduct.id == inp_id, BizCellProduct.del_flag == 0
).first()
if product and product.product_status in ("frozen", "thawed"):
product.product_status = "in_production"
product.update_by = user_id
product.update_time = now
db.add(product)
db.add(BizProductionTaskInput(
id=generate_uuid(), task_id=task_id, tenant_no=tenant_no,
input_type=inp_type or None,
input_id=inp_id or None,
input_code=(it.get("input_code") or it.get("inputCode") or "").strip() or None,
input_name=(it.get("input_name") or it.get("inputName") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
if old_row.id in saved_ids:
continue
if old_row.input_type == "cell_product":
_set_cell_product_status(
db,
old_row.input_id,
user_id=user_id,
now=now,
to_status="frozen",
from_statuses=("in_production",),
)
for row in saved:
previous = old_identity.get(row.id)
current = (row.input_type or "", row.input_id or "")
if previous != current and previous and previous[0] == "cell_product":
_set_cell_product_status(
db,
previous[1],
user_id=user_id,
now=now,
to_status="frozen",
from_statuses=("in_production",),
)
if row.input_type == "cell_product":
_set_cell_product_status(
db,
row.input_id,
user_id=user_id,
now=now,
to_status="in_production",
from_statuses=("frozen", "thawed"),
)
db.flush()
db.commit()
@@ -2765,7 +3029,9 @@ def set_task_outputs(
BizCellProduct.id == cp_id, BizCellProduct.del_flag == 0
).first()
if product:
# 作废并软删:避免同编码再入库时留下 voided+frozen 双行,污染各类选择器
product.product_status = "voided"
product.del_flag = 1
product.update_by = user_id
product.update_time = now
db.add(product)
@@ -2911,6 +3177,7 @@ def copy_task_step(db: Session, step_id: str, *, tenant_no: str = "zkxw",
process_name=new_name, description=src.description,
estimated_duration=src.estimated_duration,
actual_duration=src.actual_duration, sop_ids=src.sop_ids,
sop_content=src.sop_content,
step_status="dzx",
tenant_no=tenant_no, order_num=0,
remark=src.remark, create_by=user_id, create_dept_id="",
+822
View File
@@ -0,0 +1,822 @@
"""生产任务集合 CRUD"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizProcessRoute,
BizProcessRouteStep,
BizProductionTask,
BizProductionTaskSet,
BizProductionTaskSetItem,
BizProductionWorkOrder,
)
from app.utils.production_task_name_placeholders import (
apply_task_name_placeholders,
revert_task_name_placeholders,
)
def resolve_work_order_lab_storage_code(
db: Session,
*,
tenant_no: str,
wo: BizProductionWorkOrder,
) -> str:
"""解析工单对应实验室入库编码(样本接收优先,样本编码回退)。"""
from app.crud import sample_reception as sample_reception_svc
from app.models.models import BizSampleReception
reception_id = (getattr(wo, "reception_id", None) or "").strip()
if reception_id:
rec = (
db.query(BizSampleReception)
.filter(
BizSampleReception.id == reception_id,
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
)
.first()
)
if rec:
code = (rec.lab_storage_code or "").strip()
if code:
return code
sample_code = (getattr(wo, "sample_code", None) or "").strip()
if not sample_code:
return ""
source = (getattr(wo, "reception_source", None) or "").strip() or None
code_map = sample_reception_svc.get_lab_storage_code_map_by_sample_codes(
db, tenant_no, [sample_code], reception_source=source
)
return (code_map.get(sample_code) or "").strip()
def _strip_or_none(raw: Any) -> Optional[str]:
text = str(raw or "").strip()
return text or None
def normalize_item_payload(raw: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
data = raw or {}
task_name = str(data.get("taskName") or data.get("task_name") or "").strip()
if not task_name:
return None
route_raw = data.get("routeId") if "routeId" in data else data.get("route_id")
return {
"task_name": task_name,
"route_id": _strip_or_none(route_raw),
"description": _strip_or_none(data.get("description")),
"remark": _strip_or_none(data.get("remark")),
}
def skip_empty_named_tasks(tasks: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]:
kept: List[Dict[str, Any]] = []
skipped = 0
for row in tasks or []:
name = str(row.get("task_name") or row.get("taskName") or "").strip()
if not name:
skipped += 1
continue
route_raw = row.get("route_id") if "route_id" in row else row.get("routeId")
name_raw = row.get("route_name") if "route_name" in row else row.get("routeName")
kept.append(
{
"task_name": name,
"route_id": _strip_or_none(route_raw),
"route_name": _strip_or_none(name_raw),
"description": _strip_or_none(row.get("description")),
"remark": _strip_or_none(row.get("remark")),
}
)
return kept, skipped
def collect_missing_route_ids(items: List[Dict[str, Any]], existing_ids: Set[str]) -> List[str]:
missing: List[str] = []
seen: Set[str] = set()
alive = existing_ids or set()
for item in items or []:
rid = _strip_or_none(item.get("route_id"))
if not rid or rid in alive or rid in seen:
continue
seen.add(rid)
missing.append(rid)
return missing
def collect_route_ids_without_steps(
items: List[Dict[str, Any]],
route_ids_with_steps: Set[str],
) -> List[str]:
"""返回条目中已绑定路线但无有效工序步骤的 route_id(去重保序)。"""
empty: List[str] = []
seen: Set[str] = set()
with_steps = route_ids_with_steps or set()
for item in items or []:
rid = _strip_or_none(item.get("route_id"))
if not rid or rid in with_steps or rid in seen:
continue
seen.add(rid)
empty.append(rid)
return empty
def _load_route_ids_with_steps(db: Session, route_ids: List[str]) -> Set[str]:
ids = [rid for rid in route_ids if rid]
if not ids:
return set()
rows = (
db.query(BizProcessRouteStep.route_id)
.filter(
BizProcessRouteStep.route_id.in_(ids),
BizProcessRouteStep.del_flag == 0,
)
.distinct()
.all()
)
return {r[0] for r in rows if r and r[0]}
def total_pages(total: int, page_size: int) -> int:
if page_size <= 0:
return 0
return (int(total) + int(page_size) - 1) // int(page_size)
def _item_to_dict(r: BizProductionTaskSetItem) -> Dict[str, Any]:
return {
"id": r.id,
"setId": r.set_id,
"taskName": r.task_name or "",
"routeId": r.route_id or "",
"routeName": r.route_name or "",
"description": r.description or "",
"remark": r.remark or "",
"orderNum": r.order_num if r.order_num is not None else 0,
}
def _set_to_dict(
r: BizProductionTaskSet,
item_count: int = 0,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
data: Dict[str, Any] = {
"id": r.id,
"setName": r.set_name or "",
"status": r.status or "1",
"itemCount": item_count,
"orderNum": r.order_num if r.order_num is not None else 0,
"remark": r.remark or "",
"isBuiltin": getattr(r, "is_builtin", None) or "0",
"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 "",
}
if items is not None:
data["items"] = items
return data
def set_name_exists(
db: Session, name: str, tenant_no: str, exclude_id: Optional[str] = None
) -> bool:
n = (name or "").strip()
if not n:
return False
q = db.query(BizProductionTaskSet).filter(
BizProductionTaskSet.set_name == n,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
if exclude_id:
q = q.filter(BizProductionTaskSet.id != exclude_id)
return q.first() is not None
def _require_status(status: Optional[str]) -> str:
st = (status or "1").strip()
if st not in ("0", "1"):
raise ValueError("状态无效")
return st
def _load_route_name_map(db: Session, tenant_no: str, route_ids: List[str]) -> Dict[str, str]:
ids = [rid for rid in route_ids if rid]
if not ids:
return {}
rows = (
db.query(BizProcessRoute)
.filter(
BizProcessRoute.tenant_no == tenant_no,
BizProcessRoute.del_flag == 0,
BizProcessRoute.id.in_(ids),
)
.all()
)
return {r.id: (r.route_name or "") for r in rows}
def _normalize_items_or_raise(raw_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for raw in raw_items or []:
item = normalize_item_payload(raw)
if item:
out.append(item)
if not out:
raise ValueError("请至少添加一条任务名称非空的条目")
return out
def _assert_routes_exist(
db: Session, tenant_no: str, items: List[Dict[str, Any]]
) -> Dict[str, str]:
wanted = [i["route_id"] for i in items if i.get("route_id")]
name_map = _load_route_name_map(db, tenant_no, wanted)
missing = collect_missing_route_ids(items, set(name_map.keys()))
if missing:
raise ValueError("存在无效或已删除的工艺路线")
return name_map
def _replace_items(
db: Session,
*,
set_id: str,
tenant_no: str,
items: List[Dict[str, Any]],
route_names: Dict[str, str],
user_id: Optional[str],
create_dept_id: str,
) -> None:
now = datetime.now()
def _set_item_key(item):
return f"{(item.get('route_id') or '').strip()}|{(item.get('task_name') or '').strip()}"
def build_item(row_id):
return BizProductionTaskSetItem(
id=row_id,
set_id=set_id,
task_name="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
)
def apply_item(row, item, idx, _now):
rid = item.get("route_id")
row.task_name = item["task_name"]
row.route_id = rid
row.route_name = route_names.get(rid or "") if rid else None
row.description = item.get("description")
row.remark = item.get("remark")
row.order_num = idx + 1
sync_child_rows(
db,
BizProductionTaskSetItem,
parent_attr="set_id",
parent_id=set_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_item,
apply_fields=apply_item,
natural_key=_set_item_key,
row_natural_key=lambda row: f"{(row.route_id or '').strip()}|{(row.task_name or '').strip()}",
duplicate_message="任务集合条目重复",
foreign_message="任务集合条目不属于当前集合",
)
def _item_count_map(db: Session, tenant_no: str, set_ids: List[str]) -> Dict[str, int]:
if not set_ids:
return {}
rows = (
db.query(BizProductionTaskSetItem.set_id, func.count(BizProductionTaskSetItem.id))
.filter(
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
BizProductionTaskSetItem.set_id.in_(set_ids),
)
.group_by(BizProductionTaskSetItem.set_id)
.all()
)
return {sid: int(cnt) for sid, cnt in rows}
def list_sets(
db: Session,
*,
tenant_no: str,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
status: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
q = db.query(BizProductionTaskSet).filter(
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
kw = (keyword or "").strip()
if kw:
q = q.filter(BizProductionTaskSet.set_name.like(f"%{kw}%"))
st = (status or "").strip()
if st in ("0", "1"):
q = q.filter(BizProductionTaskSet.status == st)
total = q.count()
rows = (
q.order_by(BizProductionTaskSet.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
counts = _item_count_map(db, tenant_no, [r.id for r in rows])
return [_set_to_dict(r, item_count=counts.get(r.id, 0)) for r in rows], int(total)
def list_active_sets(db: Session, *, tenant_no: str) -> List[Dict[str, Any]]:
rows = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
BizProductionTaskSet.status == "1",
)
.order_by(BizProductionTaskSet.create_time.desc())
.all()
)
counts = _item_count_map(db, tenant_no, [r.id for r in rows])
return [_set_to_dict(r, item_count=counts.get(r.id, 0)) for r in rows]
def get_set(db: Session, set_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
row = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == set_id,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not row:
return None
items = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == set_id,
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
)
.order_by(
BizProductionTaskSetItem.order_num.asc(),
BizProductionTaskSetItem.create_time.asc(),
)
.all()
)
return _set_to_dict(row, item_count=len(items), items=[_item_to_dict(i) for i in items])
def is_super_admin_user(user: Optional[Dict[str, Any]]) -> bool:
return bool(user) and (user.get("role") or "") == "super_admin"
def assert_set_writable(
db: Session,
set_id: str,
tenant_no: str,
user: Optional[Dict[str, Any]] = None,
) -> BizProductionTaskSet:
"""内置模版仅超级管理员可写。"""
row = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == set_id,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not row:
raise ValueError("集合不存在")
if (getattr(row, "is_builtin", None) or "0") == "1" and not is_super_admin_user(user):
raise ValueError("内置模版仅超级管理员可修改")
return row
def create_set(
db: Session,
*,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
set_name: str,
status: str = "1",
remark: Optional[str] = None,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
name = (set_name or "").strip()
if not name:
raise ValueError("集合名称不能为空")
if set_name_exists(db, name, tenant_no):
raise ValueError("集合名称已存在")
normalized = _normalize_items_or_raise(items or [])
route_names = _assert_routes_exist(db, tenant_no, normalized)
now = datetime.now()
obj = BizProductionTaskSet(
id=generate_uuid(),
set_name=name,
status=_require_status(status),
tenant_no=tenant_no,
order_num=0,
remark=(remark or "").strip() or None,
is_builtin="0",
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
db.flush()
_replace_items(
db,
set_id=obj.id,
tenant_no=tenant_no,
items=normalized,
route_names=route_names,
user_id=user_id,
create_dept_id=create_dept_id,
)
db.flush()
detail = get_set(db, obj.id, tenant_no)
if not detail:
raise ValueError("集合创建失败")
return detail
def update_set(
db: Session,
set_id: str,
*,
tenant_no: str,
user_id: Optional[str] = None,
set_name: Optional[str] = None,
status: Optional[str] = None,
remark: Optional[str] = None,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
obj = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == set_id,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not obj:
raise ValueError("集合不存在")
if set_name is not None:
name = set_name.strip()
if not name:
raise ValueError("集合名称不能为空")
if set_name_exists(db, name, tenant_no, exclude_id=set_id):
raise ValueError("集合名称已存在")
obj.set_name = name
if status is not None:
obj.status = _require_status(status)
if remark is not None:
obj.remark = remark.strip() or None
if items is not None:
normalized = _normalize_items_or_raise(items)
route_names = _assert_routes_exist(db, tenant_no, normalized)
_replace_items(
db,
set_id=obj.id,
tenant_no=tenant_no,
items=normalized,
route_names=route_names,
user_id=user_id,
create_dept_id=obj.create_dept_id or "",
)
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.flush()
detail = get_set(db, obj.id, tenant_no)
if not detail:
raise ValueError("集合不存在")
return detail
def delete_set(db: Session, set_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None:
obj = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == set_id,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not obj:
raise ValueError("集合不存在")
now = datetime.now()
obj.del_flag = 1
obj.update_by = user_id
obj.update_time = now
db.add(obj)
items = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == set_id,
BizProductionTaskSetItem.del_flag == 0,
)
.all()
)
for row in items:
row.del_flag = 1
row.update_by = user_id
row.update_time = now
db.add(row)
db.flush()
def create_set_from_work_order(
db: Session,
*,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
work_order_id: str,
set_name: str,
remark: Optional[str] = None,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
wo_id = (work_order_id or "").strip()
wo = (
db.query(BizProductionWorkOrder)
.filter(
BizProductionWorkOrder.id == wo_id,
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.del_flag == 0,
)
.first()
)
if not wo:
raise ValueError("工单不存在")
if items is not None:
raw = items
else:
tasks = (
db.query(BizProductionTask)
.filter(
BizProductionTask.work_order_id == wo_id,
BizProductionTask.tenant_no == tenant_no,
BizProductionTask.del_flag == 0,
)
.order_by(BizProductionTask.create_time.asc(), BizProductionTask.id.asc())
.all()
)
raw = [
{
"task_name": t.task_name,
"route_id": t.route_id,
"description": t.description,
"remark": t.remark,
}
for t in tasks
]
kept, skipped = skip_empty_named_tasks(raw)
if not kept:
raise ValueError("无可保存的生产任务(任务名称均为空)")
lab_storage_code = resolve_work_order_lab_storage_code(
db, tenant_no=tenant_no, wo=wo
)
route_ids = [i["route_id"] for i in kept if i.get("route_id")]
name_map = _load_route_name_map(db, tenant_no, route_ids)
payload_items = []
for i in kept:
rid = i.get("route_id")
if rid and rid not in name_map:
rid = None
payload_items.append(
{
"taskName": revert_task_name_placeholders(
i["task_name"] or "",
lab_storage_code=lab_storage_code,
),
"routeId": rid,
"description": i.get("description"),
"remark": i.get("remark"),
}
)
detail = create_set(
db,
tenant_no=tenant_no,
user_id=user_id,
create_dept_id=create_dept_id,
set_name=set_name,
status="1",
remark=remark,
items=payload_items,
)
return {**detail, "skippedEmptyNameCount": skipped}
def create_tasks_from_set(
db: Session,
*,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
work_order_id: str,
set_id: str,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
from app.crud import production_task as production_task_svc
wo_id = (work_order_id or "").strip()
wo = (
db.query(BizProductionWorkOrder)
.filter(
BizProductionWorkOrder.id == wo_id,
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.del_flag == 0,
)
.first()
)
if not wo:
raise ValueError("工单不存在")
status = getattr(wo, "work_order_status", None) or ""
if status not in ("dpz", "jxz"):
raise ValueError("仅待配置或进行中的工单可派发任务")
header = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == (set_id or "").strip(),
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not header:
raise ValueError("任务组合模版不存在")
if (header.status or "1") != "1":
raise ValueError("任务组合模版已停用")
if items is not None:
payloads = _normalize_items_or_raise(items)
else:
rows = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == header.id,
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
)
.order_by(
BizProductionTaskSetItem.order_num.asc(),
BizProductionTaskSetItem.create_time.asc(),
)
.all()
)
if not rows:
raise ValueError("任务组合模版没有可派发的任务")
payloads = [
{
"route_id": row.route_id,
"task_name": row.task_name,
"description": row.description,
"remark": row.remark,
}
for row in rows
]
route_ids = [p["route_id"] for p in payloads if p.get("route_id")]
name_map = _load_route_name_map(db, tenant_no, route_ids)
missing = collect_missing_route_ids(payloads, set(name_map.keys()))
if missing:
raise ValueError("工艺路线已删除,无法派发:" + "、".join(missing))
with_steps = _load_route_ids_with_steps(db, route_ids)
empty_steps = collect_route_ids_without_steps(payloads, with_steps)
if empty_steps:
labels = [name_map.get(rid) or rid for rid in empty_steps]
raise ValueError("工艺路线无工序步骤,无法派发:" + "、".join(labels))
lab_storage_code = resolve_work_order_lab_storage_code(
db, tenant_no=tenant_no, wo=wo
)
created = []
for row in payloads:
resolved_name = apply_task_name_placeholders(
row["task_name"] or "",
lab_storage_code=lab_storage_code,
)
task = production_task_svc.create_production_task(
db,
tenant_no=tenant_no,
user_id=user_id,
create_dept_id=create_dept_id,
data={
"work_order_id": wo_id,
"task_name": resolved_name,
"route_id": row.get("route_id"),
"description": row.get("description"),
"remark": row.get("remark"),
},
)
created.append(production_task_svc._task_to_dict(task))
return {"createdCount": len(created), "list": created}
def try_auto_dispatch_builtin_tasks_after_reception(
db: Session,
*,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
work_order: BizProductionWorkOrder,
lab_storage_code: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""样本接收建工单后按入库编码尝试自动派发;失败不抛错。"""
import logging
logger = logging.getLogger(__name__)
wo = work_order
if not wo or not getattr(wo, "id", None):
return None
existing = (
db.query(BizProductionTask.id)
.filter(
BizProductionTask.work_order_id == wo.id,
BizProductionTask.tenant_no == tenant_no,
BizProductionTask.del_flag == 0,
)
.first()
)
if existing:
logger.info(
"skip auto-dispatch: work_order %s already has tasks",
wo.id,
)
return None
code = (lab_storage_code or "").strip()
if not code:
code = resolve_work_order_lab_storage_code(db, tenant_no=tenant_no, wo=wo)
from app.utils.lab_storage_code_task_set import (
match_builtin_task_set_name_by_lab_storage_code,
)
set_name = match_builtin_task_set_name_by_lab_storage_code(code)
if not set_name:
logger.info(
"skip auto-dispatch: lab_storage_code=%r no builtin set rule",
code,
)
return None
header = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.set_name == set_name,
BizProductionTaskSet.is_builtin == "1",
BizProductionTaskSet.status == "1",
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not header:
logger.warning(
"skip auto-dispatch: builtin set %r not found/enabled for tenant %s",
set_name,
tenant_no,
)
return None
try:
return create_tasks_from_set(
db,
tenant_no=tenant_no,
user_id=user_id,
create_dept_id=create_dept_id or "",
work_order_id=wo.id,
set_id=header.id,
)
except Exception:
logger.exception(
"auto-dispatch failed: work_order=%s set=%s lab_storage_code=%r",
wo.id,
set_name,
code,
)
return None
@@ -8,7 +8,8 @@ from sqlalchemy.orm import Session
from app.core.db_collation import eq_unicode
from app.core.utils import generate_uuid
from app.models.models import BizProductionTaskStep, BizProductionTaskStepAssignee, SysRole, SysUser, SysUserRole
from app.models.models import BizProductionTaskStep, BizProductionTaskStepAssignee, SysUser
from app.services.web_auth_roles import list_user_ids_with_effective_role_name
PRODUCTION_STEP_ASSIGNEE_ROLE = "实验专员"
@@ -35,30 +36,17 @@ def _active_users_in_tenant(
def _active_users_with_role_in_tenant(
db: Session, user_ids: List[str], tenant_no: str, role_name: str
) -> Set[str]:
"""校验候选用户是否拥有指定角色(直接绑定 ∪ 职位继承)。"""
ids = [x.strip() for x in user_ids if (x or "").strip()]
rn = (role_name or "").strip()
if not ids or not rn:
return set()
rows = (
db.query(SysUser.id)
.join(SysUserRole, SysUserRole.user_id == SysUser.id)
.join(SysRole, SysRole.id == SysUserRole.role_id)
.filter(
SysUser.id.in_(ids),
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0,
SysUser.status == "1",
SysUserRole.del_flag == 0,
SysUserRole.tenant_no == tenant_no,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant_no,
SysRole.role_name == rn,
)
.distinct()
.all()
return list_user_ids_with_effective_role_name(
db,
tenant_no=tenant_no,
role_name=rn,
candidate_user_ids=ids,
)
return {r[0] for r in rows}
def validate_assignee_user_ids(
@@ -194,7 +182,7 @@ def enrich_step_completed_by_names(db: Session, steps: List[Dict]) -> None:
return
users = {
u.id: u
for u in db.query(SysUser).filter(SysUser.id.in_(uids), SysUser.del_flag == 0).all()
for u in db.query(SysUser).filter(SysUser.id.in_(uids)).all()
}
for step in steps:
uid = (step.get("completedBy") or "").strip()
+185
View File
@@ -0,0 +1,185 @@
"""生产任务步骤废弃物产出 — 只读汇总列表。"""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from app.models.models import (
BizOutputType,
BizProductionTask,
BizProductionTaskStep,
BizProductionTaskStepOutput,
BizProductionWorkOrder,
)
WASTE_OUTPUT_TYPE_CODE = "FQW"
WASTE_OUTPUT_TYPE_NAME = "废弃物"
def pick_waste_output_type_ids(rows: Iterable[Dict[str, Any]]) -> Set[str]:
"""从产出类型行中选出废弃物类型 id:优先 type_code=FQW,否则兜底 type_name=废弃物。"""
rows = list(rows or [])
by_code = {
str(r.get("id") or "").strip()
for r in rows
if str(r.get("type_code") or "").strip() == WASTE_OUTPUT_TYPE_CODE
and str(r.get("id") or "").strip()
}
if by_code:
return by_code
return {
str(r.get("id") or "").strip()
for r in rows
if str(r.get("type_name") or "").strip() == WASTE_OUTPUT_TYPE_NAME
and str(r.get("id") or "").strip()
}
def match_waste_output_keyword(
keyword: Optional[str], output_name: Optional[str], output_barcode: Optional[str]
) -> bool:
kw = (keyword or "").strip()
if not kw:
return True
name = output_name or ""
barcode = output_barcode or ""
return kw in name or kw in barcode
def resolve_waste_output_type_ids(db: Session, tenant_no: str) -> Set[str]:
rows = (
db.query(BizOutputType.id, BizOutputType.type_code, BizOutputType.type_name)
.filter(BizOutputType.tenant_no == tenant_no, BizOutputType.del_flag == 0)
.all()
)
return pick_waste_output_type_ids(
[{"id": r.id, "type_code": r.type_code, "type_name": r.type_name} for r in rows]
)
def _fmt_dt(t) -> str:
if t is None:
return ""
if hasattr(t, "strftime"):
return t.strftime("%Y-%m-%d %H:%M:%S")
return str(t)
def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> str:
v = (value or "").strip()
if not v:
return v
if len(v) <= 10:
return f"{v} 23:59:59" if end_of_day else f"{v} 00:00:00"
return v
def list_production_waste_outputs(
db: Session,
*,
tenant_no: str,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
create_time_start: Optional[str] = None,
create_time_end: Optional[str] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
type_ids = resolve_waste_output_type_ids(db, tenant_no)
if not type_ids:
return [], 0
q = (
db.query(
BizProductionTaskStepOutput,
BizProductionTaskStep,
BizProductionTask,
BizProductionWorkOrder,
)
.join(
BizProductionTaskStep,
and_(
BizProductionTaskStep.id == BizProductionTaskStepOutput.step_id,
BizProductionTaskStep.del_flag == 0,
),
)
.join(
BizProductionTask,
and_(
BizProductionTask.id == BizProductionTaskStep.task_id,
BizProductionTask.del_flag == 0,
BizProductionTask.tenant_no == tenant_no,
),
)
.outerjoin(
BizProductionWorkOrder,
and_(
BizProductionWorkOrder.id == BizProductionTask.work_order_id,
BizProductionWorkOrder.del_flag == 0,
),
)
.filter(
BizProductionTaskStepOutput.tenant_no == tenant_no,
BizProductionTaskStepOutput.del_flag == 0,
BizProductionTaskStepOutput.output_type_id.in_(list(type_ids)),
)
)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q = q.filter(
or_(
BizProductionTaskStepOutput.output_name.like(like),
BizProductionTaskStepOutput.output_barcode.like(like),
)
)
if create_time_start and create_time_start.strip():
q = q.filter(
BizProductionTaskStepOutput.create_time
>= _normalize_date_bound(create_time_start.strip())
)
if create_time_end and create_time_end.strip():
q = q.filter(
BizProductionTaskStepOutput.create_time
<= _normalize_date_bound(create_time_end.strip(), end_of_day=True)
)
if user_info is not None:
from app.core.data_scope import apply_data_scope
q = apply_data_scope(q, BizProductionTask, user_info, db, tenant_no)
q = q.order_by(BizProductionTaskStepOutput.create_time.desc())
total = q.count()
page = max(1, int(page or 1))
page_size = max(1, min(int(page_size or 10), 100))
rows = q.offset((page - 1) * page_size).limit(page_size).all()
items: List[Dict[str, Any]] = []
for out, step, task, wo in rows:
items.append(
{
"id": out.id,
"outputName": out.output_name or "",
"outputBarcode": out.output_barcode or "",
"remark": out.remark or "",
"createTime": _fmt_dt(out.create_time),
"stepId": out.step_id or "",
"processName": (step.process_name if step else None) or "",
"taskId": (task.id if task else None) or "",
"taskNo": (task.task_no if task else None) or "",
"workOrderId": (wo.id if wo else None) or (task.work_order_id if task else "") or "",
"workOrderNo": (wo.work_order_no if wo else None) or "",
"sampleCode": (wo.sample_code if wo else None) or "",
}
)
return items, total
def total_pages(total: int, page_size: int) -> int:
if page_size <= 0:
return 0
return (total + page_size - 1) // page_size
Loaded 100 of 8558 files, more files were not shown because too many files have changed in this diff. Show more