299 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
853 changed files with 438981 additions and 113852 deletions

No files matched your search

+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
+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` 服务。
+19 -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
@@ -84,3 +86,18 @@ DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
# 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
+18
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
@@ -36,6 +40,11 @@ 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",
@@ -44,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",
@@ -75,4 +88,9 @@ __all__ = [
"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",
]
+38 -6
View File
@@ -1,4 +1,5 @@
"""运营端管理接口(租户、角色下拉等)"""
import asyncio
import os
import uuid
from typing import Any, List, Optional
@@ -236,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))
# ---------------------------------------------------------------------------
# 用户管理
# ---------------------------------------------------------------------------
@@ -405,6 +425,7 @@ 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)
update_kwargs: dict[str, Any] = {
"nick_name": payload.get("nick_name"),
@@ -436,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:
@@ -449,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))
@@ -1590,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:]
@@ -1644,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"
+41
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"),
@@ -433,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})
# ---------------------------------------------------------------------------
# 采集地区 — 下属医院
# ---------------------------------------------------------------------------
+284 -79
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,36 +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:
"""持久化协议子资源。
缴费(含明细校验)须在样本之前:明细校验失败时不得已占用样本编码。
各子表 CRUD 内部仍可能分段 commit,创建接口须在失败时做补偿软删。
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)
# 缴费先于样本:避免「应缴合计不符」等校验失败后样本编码已被占用
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 not pay:
pay = cp_svc.get_payment_by_protocol_id(db, protocol_id, tenant_no)
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)
if samples is not None:
cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples)
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")
@@ -306,6 +362,7 @@ 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
@@ -327,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,
@@ -335,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(
@@ -350,21 +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:
# 子表分段 commit 后校验失败时,补偿软删主单及样本,释放样本编码占用
if protocol_id:
try:
cp_svc.soft_delete_collection_protocol(
db,
protocol_id,
tenant_no=tenant_no,
user_id=user_id,
)
except Exception:
pass
db.rollback()
return error_response(400, str(e))
@@ -375,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):
@@ -383,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}
@@ -408,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,
@@ -425,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))
@@ -499,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,
@@ -515,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})
# ---------------------------------------------------------------------------
# 收款确认
# ---------------------------------------------------------------------------
@@ -1089,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})
+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")
+67 -10
View File
@@ -7,7 +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)
@@ -26,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"],
@@ -53,19 +53,70 @@ 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"},
"meta": {"title": "缴费财务确认", "keepAlive": True, "menuCode": "biz:cellStoragePaymentConfirm:list"},
},
# 市场侧入库放行审核(总览)暂下线:改由生产/质检入口审核
# {
@@ -88,6 +139,7 @@ STATIC_MENU_TREE = [
"title": "科研捐赠",
"icon": "ri:hand-heart-line",
"keepAlive": False,
"isHide": True,
"roles": ["admin"],
},
"children": [
@@ -103,14 +155,19 @@ 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",
},
},
],
},
@@ -149,7 +206,7 @@ STATIC_MENU_TREE = [
"name": "BasicInfoInsuranceCompany",
"path": "insurance-company",
"component": "/basic-info/insurance-company/index",
"meta": {"title": "保险公司及险种价格", "keepAlive": True, "menuCode": "basic-info:insurance-company:list"},
"meta": {"title": "保险公司及险种价格", "keepAlive": True, "isHide": True, "menuCode": "basic-info:insurance-company:list"},
},
{
@@ -177,7 +234,7 @@ STATIC_MENU_TREE = [
"name": "BizCustomer",
"path": "customer",
"component": "/basic-info/customer/index",
"meta": {"title": "客户管理", "keepAlive": True, "menuCode": "biz:customer:list"},
"meta": {"title": "客户基础信息", "keepAlive": True, "menuCode": "biz:customer:list"},
},
{
@@ -193,7 +250,7 @@ STATIC_MENU_TREE = [
"name": "CheckupInstitution",
"path": "checkup-institution",
"component": "/pilot-service/checkup-institution/index",
"meta": {"title": "体检机构及项目价格", "keepAlive": True, "menuCode": "pilot-service:checkup-institution:list"},
"meta": {"title": "体检机构及项目价格", "keepAlive": True, "isHide": True, "menuCode": "pilot-service:checkup-institution:list"},
},
{
+82 -46
View File
@@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.core.auth import get_current_user_by_token
from app.core.config import settings
from app.core.database import get_db
from app.core.response import error_response, success_response
from app.models.models import SysMenu, SysRoleMenu
@@ -24,14 +23,13 @@ async def _current_user(request: Request, db: Session) -> dict:
return await get_current_user_by_token(token=token, db=db)
def _find_mobile_root(db: Session) -> SysMenu | None:
tenant = settings.DEFAULT_TENANT_NO
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,
SysMenu.tenant_no == tenant_no,
)
.all()
)
@@ -42,13 +40,14 @@ def _find_mobile_root(db: Session) -> SysMenu | None:
return None
def _collect_descendants(db: Session, root_id: str) -> List[SysMenu]:
tenant = settings.DEFAULT_TENANT_NO
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,
SysMenu.tenant_no == tenant_no,
SysMenu.status == "1",
SysMenu.visible == "1",
)
@@ -71,16 +70,15 @@ def _collect_descendants(db: Session, root_id: str) -> List[SysMenu]:
return out
def _user_menu_ids(db: Session, user_id: str) -> Set[str]:
role_ids = get_effective_role_ids(db, user_id)
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()
tenant = settings.DEFAULT_TENANT_NO
rows = (
db.query(SysRoleMenu.menu_id)
.filter(
SysRoleMenu.del_flag == 0,
SysRoleMenu.tenant_no == tenant,
SysRoleMenu.tenant_no == tenant_no,
SysRoleMenu.role_id.in_(role_ids),
)
.all()
@@ -91,6 +89,20 @@ def _user_menu_ids(db: Session, user_id: str) -> Set[str]:
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:
@@ -115,7 +127,7 @@ def _mobile_menu_icon(path: str) -> str:
def _mobile_menu_icon_colors(path: str) -> dict:
"""宫格图标底色/图形色(可按入口自定义,不强制品牌绿)。"""
p = (path or "").lower()
# OA 菜单 PNG 自带颜色,宫格不套色底
# OA / 市场菜单 PNG 自带颜色,宫格不套色底
if any(
x in p
for x in (
@@ -128,13 +140,61 @@ def _mobile_menu_icon_colors(path: str) -> dict:
"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:
@@ -143,38 +203,14 @@ async def list_my_mobile_menus(request: Request, db: Session = Depends(get_db)):
return error_response(401, "未授权")
user_id = str(user.get("user_id") or user.get("sub") or "").strip()
if not user_id:
tenant_no = str(user.get("tenant_no") or "").strip()
if not user_id or not tenant_no:
return error_response(401, "未授权")
root = _find_mobile_root(db)
if not root:
return success_response({"list": []})
descendants = _collect_descendants(db, root.id)
web_role = resolve_web_role_for_user(db, user_id)
allowed_ids = None if web_role == "super_admin" else _user_menu_ids(db, user_id)
items = []
for m in descendants:
if (m.menu_type or "").upper() == "F":
continue
path = (m.path or "").strip()
if not path:
continue
if "form-sink" in path.lower() or "水槽" in str(m.menu_name or ""):
continue
if allowed_ids is not None and m.id not in allowed_ids:
continue
colors = _mobile_menu_icon_colors(path)
items.append(
{
"id": m.id,
"title": m.menu_name,
"path": path if path.startswith("/") else f"/{path}",
"icon": _mobile_menu_icon(path),
"iconBg": colors["iconBg"],
"iconFg": colors["iconFg"],
"perms": (m.perms or "").strip() or None,
}
)
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})
+118 -1
View File
@@ -19,6 +19,7 @@ from app.services.oa_receive_account import normalize_account_fields
router = APIRouter(prefix="/web/admin/oa-approval", tags=["OA审批"])
OA_START_HISTORY_ALL_PERM = "oa:start:history:all"
OA_DONE_BANK_RECEIPT_PERM = "oa:done:bank-receipt"
def _get_tenant_no(db: Session, user: dict) -> str:
@@ -83,7 +84,15 @@ async def list_receive_account_options(
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
return success_response({"list": ora_crud.list_options(db, tenant_no=tenant_no)})
return success_response(
{
"list": ora_crud.list_options(
db,
tenant_no=tenant_no,
user_id=user.get("user_id") or "",
)
}
)
@router.post("/receive-accounts/batch-delete")
@@ -773,6 +782,19 @@ class OaCommentCreateBody(BaseModel):
mention_user_ids: Optional[List[str]] = Field(None, alias="mentionUserIds")
class OaBankReceiptItemBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
file_url: str = Field(..., alias="fileUrl")
file_name: Optional[str] = Field(None, alias="fileName")
file_type: Optional[str] = Field(None, alias="fileType")
file_size: Optional[int] = Field(None, alias="fileSize")
class OaBankReceiptReplaceBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
items: List[OaBankReceiptItemBody] = Field(default_factory=list)
class OaResubmitBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
form_data: Optional[Dict[str, Any]] = Field(None, alias="formData")
@@ -914,6 +936,7 @@ async def list_oa_instance_history(
applyTimeTo: str = Query(""),
finishTimeFrom: str = Query(""),
finishTimeTo: str = Query(""),
initiatorDeptId: str = Query(""),
page: int = Query(1),
pageSize: int = Query(20, alias="pageSize"),
user: dict = Depends(require_admin_or_super_admin),
@@ -938,6 +961,7 @@ async def list_oa_instance_history(
finish_time_from=finishTimeFrom or None,
finish_time_to=finishTimeTo or None,
instance_status=instanceStatus or None,
initiator_dept_id=initiatorDeptId or None,
page=page,
page_size=pageSize,
)
@@ -957,6 +981,7 @@ async def export_oa_instance_history(
applyTimeTo: str = Query(""),
finishTimeFrom: str = Query(""),
finishTimeTo: str = Query(""),
initiatorDeptId: str = Query(""),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
@@ -981,6 +1006,7 @@ async def export_oa_instance_history(
finish_time_from=finishTimeFrom or None,
finish_time_to=finishTimeTo or None,
instance_status=instanceStatus or None,
initiator_dept_id=initiatorDeptId or None,
)
return success_response(data)
except OaEngineError as e:
@@ -1028,6 +1054,57 @@ async def list_oa_instance_form_attachments(
return error_response(400, str(e))
@router.get("/instances/{instance_id}/bank-receipts")
async def list_oa_bank_receipts(
instance_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.core.menu_guard import user_has_menu_perm
from app.services import oa_bank_receipt as br
if not user_has_menu_perm(db, user, OA_DONE_BANK_RECEIPT_PERM):
return error_response(403, "无权上传银行回执单")
try:
tenant_no = _get_tenant_no(db, user)
items = br.list_bank_receipts(db, instance_id=instance_id, tenant_no=tenant_no)
return success_response({"items": items})
except br.OaBankReceiptError as e:
return error_response(400, str(e))
@router.put("/instances/{instance_id}/bank-receipts")
async def replace_oa_bank_receipts(
instance_id: str,
body: OaBankReceiptReplaceBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.core.menu_guard import user_has_menu_perm
from app.services import oa_bank_receipt as br
if not user_has_menu_perm(db, user, OA_DONE_BANK_RECEIPT_PERM):
return error_response(403, "无权上传银行回执单")
try:
tenant_no = _get_tenant_no(db, user)
items = br.replace_bank_receipts(
db,
instance_id=instance_id,
tenant_no=tenant_no,
items=[it.model_dump(by_alias=True) for it in (body.items or [])],
user_id=user.get("user_id") or "",
dept_id=user.get("dept_id") or "",
)
db.commit()
return success_response({"items": items})
except br.OaBankReceiptError as e:
db.rollback()
return error_response(400, str(e))
except Exception:
db.rollback()
raise
@router.get("/instances/{instance_id}/comments")
async def list_oa_instance_comments(
instance_id: str,
@@ -1188,6 +1265,46 @@ async def oa_cc_unread_count(
return error_response(400, str(e))
@router.get("/mentions/unread-count")
async def oa_mention_unread_count(
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services import oa_instance_comment as oa_comment
from app.services.oa_engine import OaEngineError
try:
tenant_no = _get_tenant_no(db, user)
n = oa_comment.count_mention_unread(
db, tenant_no=tenant_no, user_id=user.get("user_id") or ""
)
return success_response({"count": int(n)})
except OaEngineError as e:
return error_response(400, str(e))
@router.post("/mentions/{instance_id}/read")
async def oa_mention_mark_read(
instance_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services import oa_instance_comment as oa_comment
from app.services.oa_engine import OaEngineError
try:
tenant_no = _get_tenant_no(db, user)
data = oa_comment.mark_mention_instance_read(
db,
tenant_no=tenant_no,
user_id=user.get("user_id") or "",
instance_id=instance_id,
)
return success_response(data, message="已标记已读")
except OaEngineError as e:
return error_response(400, str(e))
@router.post("/tasks/cc/read-batch")
async def oa_cc_read_batch(
body: OaCcReadBatchBody,
+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. 细胞产品价格
# ---------------------------------------------------------------------------
+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))
+11 -2
View File
@@ -1277,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
@@ -1288,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")
@@ -1299,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")
@@ -1307,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")
@@ -2734,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"),
@@ -2767,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"),
@@ -2800,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"),
@@ -2851,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:
+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))
+22 -7
View File
@@ -120,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()
@@ -227,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)
@@ -320,8 +326,13 @@ 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)
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无效或已过期")
@@ -419,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 = []
+5
View File
@@ -826,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")),
):
@@ -842,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,
@@ -2448,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)
+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))
+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))
+6 -3
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
@@ -244,7 +247,7 @@ async def get_current_user_by_token(
"real_name": user.nick_name,
"role": web_role,
"role_names": role_names,
"tenant_no": (getattr(user, "tenant_no", None) or "").strip() or None,
"tenant_no": tenant_no,
"region_id": user.dept_id,
"region_name": dept_name,
"dept_name": dept_name,
+57 -3
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,11 +30,28 @@ 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 * 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 = [
"http://localhost:5173", # art-design-pro 开发服务器
@@ -101,13 +133,23 @@ class Settings(BaseSettings):
WX_TEMPLATE_DELIVERY_WARNING: str = "" # 配送时效预警模板ID
# 微信服务号(OA 审批/抄送通知)
# 总开关:false 时不发送审批/抄送/申请人进度模板消息(模板未就绪或微信能力不可用时关闭)
# 总开关: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"
@@ -126,6 +168,18 @@ class Settings(BaseSettings):
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"
+1 -1
View File
@@ -67,7 +67,7 @@ def _resolve_user_role_scopes(
"""
from app.services.web_auth_roles import get_effective_role_ids
role_ids = get_effective_role_ids(db, user_id)
role_ids = get_effective_role_ids(db, user_id, tenant_no=tenant_no)
if not role_ids:
return []
+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,
+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(
"""
+14 -6
View File
@@ -21,6 +21,7 @@ from app.models.models import (
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"}
@@ -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}%"))
+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}
+40
View File
@@ -456,6 +456,46 @@ 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,
*,
+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
+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(
+7 -1
View File
@@ -109,7 +109,7 @@ def _enrich(db: Session, d: OaInstanceDraft, *, include_payload: bool = False) -
)
if g:
group_name = g.group_name or ""
return row_to_dict(
out = row_to_dict(
d,
app_name=(app.app_name if app else "") or "",
version_no=ver.version_no if ver else None,
@@ -117,6 +117,12 @@ def _enrich(db: Session, d: OaInstanceDraft, *, include_payload: bool = False) -
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:
+32 -8
View File
@@ -8,7 +8,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import OaReceiveAccount
from app.models.models import OaReceiveAccount, SysUser
def row_to_dict(r: OaReceiveAccount) -> Dict[str, Any]:
@@ -26,9 +26,26 @@ def row_to_dict(r: OaReceiveAccount) -> Dict[str, Any]:
"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,
*,
@@ -74,17 +91,24 @@ def list_accounts(
.limit(page_size)
.all()
)
return [row_to_dict(x) for x in rows], int(total)
return _attach_owner_names(db, [row_to_dict(x) for x in rows]), int(total)
def list_options(db: Session, *, tenant_no: str) -> List[Dict[str, Any]]:
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)
.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 [row_to_dict(x) for x in rows]
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]]:
@@ -97,7 +121,7 @@ def get_account(db: Session, account_id: str, tenant_no: str) -> Optional[Dict[s
)
.first()
)
return row_to_dict(row) if row else None
return _attach_owner_names(db, [row_to_dict(row)])[0] if row else None
def create_account(
@@ -131,7 +155,7 @@ def create_account(
db.add(obj)
db.commit()
db.refresh(obj)
return row_to_dict(obj)
return _attach_owner_names(db, [row_to_dict(obj)])[0]
def update_account(
@@ -168,7 +192,7 @@ def update_account(
db.add(obj)
db.commit()
db.refresh(obj)
return row_to_dict(obj)
return _attach_owner_names(db, [row_to_dict(obj)])[0]
def soft_delete_account(
+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)
@@ -524,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,
@@ -604,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):
+14 -26
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_link_rows
from app.models.models import SysPost, SysPostRole, SysRole, SysUser, SysUserPost
@@ -232,7 +233,7 @@ def sync_post_role_bindings(
role_ids: List[str],
updater_id: Optional[str],
) -> None:
"""将本职位绑定的角色集合全量替换为 role_ids。"""
"""按角色更新本职位的角色关联。"""
ordered = list(dict.fromkeys([rid for rid in (role_ids or []) if rid]))
for rid in ordered:
role = (
@@ -249,31 +250,18 @@ def sync_post_role_bindings(
if not role:
raise ValueError("存在无效角色或不属于当前租户")
now = datetime.now()
db.query(SysPostRole).filter(
and_(
SysPostRole.post_id == post_id,
SysPostRole.tenant_no == tenant_no,
SysPostRole.del_flag == 0,
)
).update({"del_flag": 1, "update_time": now, "update_by": updater_id}, synchronize_session=False)
for rid in ordered:
db.add(
SysPostRole(
id=generate_uuid(),
post_id=post_id,
role_id=rid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=updater_id,
create_dept_id="",
create_time=now,
update_by=updater_id,
update_time=now,
del_flag=0,
)
)
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
+132 -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,
@@ -300,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,
*,
@@ -434,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)
+123 -13
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.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
from app.models.models import (
BizProductionProductType,
@@ -652,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(
+337 -119
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,
@@ -727,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:
@@ -2306,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()
)
@@ -2321,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,
@@ -2376,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()
)
@@ -2391,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,
@@ -2445,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()
)
@@ -2460,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,
@@ -2492,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(
@@ -2516,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()
@@ -2604,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()
+80 -36
View File
@@ -8,8 +8,10 @@ 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,
@@ -110,6 +112,39 @@ def collect_missing_route_ids(items: List[Dict[str, Any]], existing_ids: Set[str
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
@@ -222,41 +257,46 @@ def _replace_items(
create_dept_id: str,
) -> None:
now = datetime.now()
existing = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == set_id,
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
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,
)
.all()
)
for row in existing:
row.del_flag = 1
row.update_by = user_id
row.update_time = now
db.add(row)
for idx, item in enumerate(items, start=1):
def apply_item(row, item, idx, _now):
rid = item.get("route_id")
db.add(
BizProductionTaskSetItem(
id=generate_uuid(),
set_id=set_id,
task_name=item["task_name"],
route_id=rid,
route_name=route_names.get(rid or "") if rid else None,
description=item.get("description"),
remark=item.get("remark"),
tenant_no=tenant_no,
order_num=idx,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
)
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]:
@@ -664,12 +704,16 @@ def create_tasks_from_set(
}
for row in rows
]
name_map = _load_route_name_map(
db, tenant_no, [p["route_id"] for p in payloads if p.get("route_id")]
)
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
)
@@ -182,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()
@@ -347,6 +347,8 @@ def _enrich_outbound_items(
"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_price(product.agreement_price),
"actualPrice": _dec_price(product.actual_price),
"quantity": product.quantity,
@@ -435,6 +437,8 @@ def _enrich_outbound_items(
it["totalCourseCount"] = course.get("totalCourseCount")
it["cellType"] = sched.cell_type or ""
it["specText"] = sched.spec_text or ""
it["packageId"] = getattr(sched, "package_id", None) or ""
it["packageText"] = getattr(sched, "package_text", None) or ""
it["infusionTime"] = _fmt_dt(sched.infusion_time) if sched.infusion_time else ""
it["infusionLocation"] = sched.infusion_location or ""
it["products"] = products_map.get(it["infusionScheduleId"], [])
@@ -583,6 +587,8 @@ def _attach_outbound_detail(
out["infusionSchedule"] = {
"cellType": sched.cell_type if sched else "",
"specText": sched.spec_text if sched else "",
"packageId": getattr(sched, "package_id", None) or "" if sched else "",
"packageText": getattr(sched, "package_text", None) or "" if sched else "",
"infusionTime": _fmt_dt(sched.infusion_time) if sched and sched.infusion_time else "",
"infusionLocation": sched.infusion_location if sched else "",
"infusionMethod": sched.infusion_method if sched else "",
+19 -2
View File
@@ -8,7 +8,7 @@ from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import BizCollectionProtocol, BizProtocolSample
from app.models.models import BizCollectionProtocol, BizPresignOrder, BizProtocolSample
from app.models.research_donation import (
BizProtocolDonationConvert,
BizResearchDonation,
@@ -193,10 +193,11 @@ def assert_biz_no_available(
biz_no: str,
exclude_protocol_id: Optional[str] = None,
exclude_donation_id: Optional[str] = None,
exclude_presign_order_id: Optional[str] = None,
for_donation: bool = False,
) -> None:
"""
校验业务编号在采集协议与科研捐赠间可用。
校验业务编号在采集协议、科研捐赠与预签约订单间可用。
若当前单存在转移配对,则豁免配对单据上的编号占用。
"""
pn = (biz_no or "").strip()
@@ -259,6 +260,22 @@ def assert_biz_no_available(
raise ValueError("捐赠协议号已存在")
raise ValueError("协议编号已被捐赠协议单使用")
presign_rows = (
db.query(BizPresignOrder)
.filter(
and_(
BizPresignOrder.protocol_no == pn,
BizPresignOrder.tenant_no == tenant_no,
BizPresignOrder.del_flag == 0,
)
)
.all()
)
for row in presign_rows:
if exclude_presign_order_id and row.id == exclude_presign_order_id:
continue
raise ValueError("协议编号已存在")
def upsert_convert_link_for_to_storage(
db: Session,
+182
View File
@@ -0,0 +1,182 @@
"""采集协议单 — 协议级退费登记 CRUD(不改转账笔 pay_status)"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime
from app.crud.collection_protocol import urls_to_attachment_list
from app.models.models import BizProtocolRefundRecord
from app.services.protocol_refund_record import validate_refund_amount
def _now() -> datetime:
return datetime.now()
def _record_to_dict(
db: Session, r: BizProtocolRefundRecord, tenant_no: str
) -> Dict[str, Any]:
return {
"id": r.id,
"collectionProtocolId": r.collection_protocol_id,
"refundAmount": float(r.refund_amount) if r.refund_amount is not None else None,
"refundTime": r.refund_time.strftime("%Y-%m-%d %H:%M:%S") if r.refund_time else "",
"refundRemark": r.refund_remark or "",
"refundAttachments": r.refund_attachments or "",
"refundAttachmentFiles": urls_to_attachment_list(
r.refund_attachments, db, tenant_no
),
"workflowInstanceId": r.workflow_instance_id or "",
"remark": r.remark or "",
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
}
def list_refund_records(
db: Session, *, collection_protocol_id: str, tenant_no: str
) -> List[Dict[str, Any]]:
rows = (
db.query(BizProtocolRefundRecord)
.filter(
BizProtocolRefundRecord.collection_protocol_id == collection_protocol_id,
BizProtocolRefundRecord.tenant_no == tenant_no,
BizProtocolRefundRecord.del_flag == 0,
)
.order_by(BizProtocolRefundRecord.create_time.desc())
.all()
)
return [_record_to_dict(db, r, tenant_no) for r in rows]
def create_refund_record(
db: Session,
*,
collection_protocol_id: str,
tenant_no: str,
user_id: Optional[str],
refund_amount: Any,
refund_time: Any = None,
refund_remark: Optional[str] = None,
refund_attachments: Optional[str] = None,
workflow_instance_id: Optional[str] = None,
remark: Optional[str] = None,
) -> BizProtocolRefundRecord:
amt = validate_refund_amount(refund_amount)
now = _now()
rt = None
if refund_time not in (None, ""):
rt = (
refund_time
if isinstance(refund_time, datetime)
else parse_datetime(str(refund_time))
)
obj = BizProtocolRefundRecord(
id=generate_uuid(),
collection_protocol_id=collection_protocol_id,
refund_amount=amt,
refund_time=rt or now,
refund_remark=(refund_remark or "").strip() or None,
refund_attachments=(refund_attachments or "").strip() or None,
workflow_instance_id=(workflow_instance_id or "").strip() or None,
tenant_no=tenant_no,
order_num=0,
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_refund_record(
db: Session,
*,
record_id: str,
tenant_no: str,
user_id: Optional[str],
refund_amount: Any = None,
refund_time: Any = None,
refund_remark: Optional[str] = None,
refund_attachments: Optional[str] = None,
workflow_instance_id: Optional[str] = None,
remark: Optional[str] = None,
fields_set: Optional[set] = None,
) -> BizProtocolRefundRecord:
obj = (
db.query(BizProtocolRefundRecord)
.filter(
BizProtocolRefundRecord.id == record_id,
BizProtocolRefundRecord.tenant_no == tenant_no,
BizProtocolRefundRecord.del_flag == 0,
)
.first()
)
if not obj:
raise ValueError("退费登记不存在")
fs = fields_set or {
"refund_amount",
"refund_time",
"refund_remark",
"refund_attachments",
"workflow_instance_id",
"remark",
}
if "refund_amount" in fs and refund_amount is not None:
obj.refund_amount = validate_refund_amount(refund_amount)
if "refund_time" in fs:
if refund_time in (None, ""):
obj.refund_time = None
elif isinstance(refund_time, datetime):
obj.refund_time = refund_time
else:
obj.refund_time = parse_datetime(str(refund_time))
if "refund_remark" in fs:
obj.refund_remark = (refund_remark or "").strip() or None
if "refund_attachments" in fs:
obj.refund_attachments = (refund_attachments or "").strip() or None
if "workflow_instance_id" in fs:
obj.workflow_instance_id = (workflow_instance_id or "").strip() or None
if "remark" in fs:
obj.remark = (remark or "").strip() or None
obj.update_by = user_id
obj.update_time = _now()
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def soft_delete_refund_record(
db: Session,
*,
record_id: str,
tenant_no: str,
user_id: Optional[str],
) -> None:
obj = (
db.query(BizProtocolRefundRecord)
.filter(
BizProtocolRefundRecord.id == record_id,
BizProtocolRefundRecord.tenant_no == tenant_no,
BizProtocolRefundRecord.del_flag == 0,
)
.first()
)
if not obj:
raise ValueError("退费登记不存在")
obj.del_flag = 1
obj.update_by = user_id
obj.update_time = _now()
db.add(obj)
db.commit()
+199 -1
View File
@@ -126,9 +126,113 @@ def _apply_sample_status_filter(q, sample_status: Optional[str]):
BizProtocolSample.sample_status == "",
)
)
if st == "collected":
return q.filter(BizProtocolSample.sample_status.in_(["ycj", "jcz", "yrk"]))
if st == "voided":
return q.filter(BizProtocolSample.sample_status == "yzf")
return q.filter(BizProtocolSample.sample_status == st)
def _apply_reception_flag_filter(q, flag: Optional[str], *, sample_code_col, tenant_no: str, source: str):
from sqlalchemy import exists
from app.models.models import BizSampleReception
f = (flag or "").strip()
if not f:
return q
base = and_(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
BizSampleReception.reception_source == source,
BizSampleReception.sample_code == sample_code_col,
)
if f == "js":
return q.filter(exists().where(and_(base, BizSampleReception.accept_conclusion == "js")))
if f == "bj":
return q.filter(exists().where(and_(base, BizSampleReception.accept_conclusion == "bj")))
if f == "none":
return q.filter(
~exists().where(and_(base, BizSampleReception.accept_conclusion.in_(["js", "bj"])))
)
return q
def _apply_qc_flag_filter(q, flag: Optional[str], *, sample_code_col, tenant_no: str, source: str):
from sqlalchemy import exists
from app.models.models import BizQcWorkOrder
f = (flag or "").strip()
if not f:
return q
base = and_(
BizQcWorkOrder.tenant_no == tenant_no,
BizQcWorkOrder.del_flag == 0,
BizQcWorkOrder.reception_source == source,
BizQcWorkOrder.trigger_source == "reception",
BizQcWorkOrder.sample_code == sample_code_col,
)
if f == "complete":
return q.filter(exists().where(and_(base, BizQcWorkOrder.conclusion.in_(["tg", "btg"]))))
if f == "voided":
return q.filter(exists().where(and_(base, BizQcWorkOrder.work_order_status == "yzf")))
if f == "incomplete":
return q.filter(
~exists().where(
and_(
base,
or_(
BizQcWorkOrder.conclusion.in_(["tg", "btg"]),
BizQcWorkOrder.work_order_status == "yzf",
),
)
)
)
return q
def _apply_storage_flag_filter(q, flag: Optional[str], *, sample_code_col, tenant_no: str, source: str):
from sqlalchemy import exists
from app.models.models import BizStorageReleaseReview
f = (flag or "").strip()
if not f:
return q
base = and_(
BizStorageReleaseReview.tenant_no == tenant_no,
BizStorageReleaseReview.del_flag == 0,
BizStorageReleaseReview.reception_source == source,
BizStorageReleaseReview.sample_code == sample_code_col,
)
if f == "yrk":
return q.filter(exists().where(and_(base, BizStorageReleaseReview.storage_status == "yrk")))
if f == "rejected":
return q.filter(
exists().where(
and_(
base,
BizStorageReleaseReview.storage_status != "yrk",
BizStorageReleaseReview.review_conclusion == "btg",
)
)
)
if f == "none":
return q.filter(
~exists().where(
and_(
base,
or_(
BizStorageReleaseReview.storage_status == "yrk",
BizStorageReleaseReview.review_conclusion == "btg",
),
)
)
)
return q
def list_protocol_sample_collections(
db: Session,
*,
@@ -137,13 +241,64 @@ def list_protocol_sample_collections(
tenant_no: str,
keyword: Optional[str] = None,
sample_status: Optional[str] = None,
collection_flag: Optional[str] = None,
reception_flag: Optional[str] = None,
qc_flag: Optional[str] = None,
storage_flag: Optional[str] = None,
storage_cert_handled: Optional[str] = None,
insurance_handled: Optional[str] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
if not tenant_no:
return [], 0
q = _base_query(db, tenant_no)
q = _apply_sample_status_filter(q, sample_status)
q = _apply_sample_status_filter(q, collection_flag or sample_status)
q = _apply_reception_flag_filter(
q, reception_flag, sample_code_col=BizProtocolSample.sample_code, tenant_no=tenant_no, source="cell_storage"
)
q = _apply_qc_flag_filter(
q, qc_flag, sample_code_col=BizProtocolSample.sample_code, tenant_no=tenant_no, source="cell_storage"
)
q = _apply_storage_flag_filter(
q, storage_flag, sample_code_col=BizProtocolSample.sample_code, tenant_no=tenant_no, source="cell_storage"
)
from app.models.models import BizProtocolFullmoonTodo, BizProtocolStorageCert
sch = (storage_cert_handled or "").strip()
if sch in ("1", "0", "true", "false"):
want = sch in ("1", "true")
handled_pred = or_(
BizProtocolStorageCert.handle_time.isnot(None),
BizProtocolStorageCert.make_status == "1",
and_(BizProtocolStorageCert.make_date.isnot(None), BizProtocolStorageCert.make_date != ""),
)
q = q.outerjoin(
BizProtocolStorageCert,
and_(
BizProtocolStorageCert.collection_protocol_id == BizCollectionProtocol.id,
BizProtocolStorageCert.tenant_no == tenant_no,
BizProtocolStorageCert.del_flag == 0,
),
)
q = q.filter(handled_pred) if want else q.filter(~handled_pred)
ih = (insurance_handled or "").strip()
if ih in ("1", "0", "true", "false"):
want = ih in ("1", "true")
handled_pred = or_(
BizProtocolFullmoonTodo.handle_time.isnot(None),
BizProtocolFullmoonTodo.todo_status == "1",
and_(BizProtocolFullmoonTodo.handle_date.isnot(None), BizProtocolFullmoonTodo.handle_date != ""),
)
q = q.outerjoin(
BizProtocolFullmoonTodo,
and_(
BizProtocolFullmoonTodo.collection_protocol_id == BizCollectionProtocol.id,
BizProtocolFullmoonTodo.tenant_no == tenant_no,
BizProtocolFullmoonTodo.del_flag == 0,
),
)
q = q.filter(handled_pred) if want else q.filter(~handled_pred)
if keyword and keyword.strip():
kw = f"%{keyword.strip()}%"
@@ -244,15 +399,58 @@ def list_protocol_sample_collections(
maternal_blood_collect_time=_fmt_dt(ci.maternal_blood_collect_time) if ci else "",
)
item.update(sample_special_to_dict(psample))
# 客户服务宽表:当事人 / 协议附件 / 备注 / 市场专员 id
item["motherPhone"] = (party.mother_phone or "") if party else ""
item["motherIdCard"] = (party.mother_id_card or "") if party else ""
item["fatherName"] = (party.father_name or "") if party else ""
item["fatherPhone"] = (party.father_phone or "") if party else ""
item["fatherIdCard"] = (party.father_id_card or "") if party else ""
item["newbornIdCard"] = (party.newborn_id_card or "") if party else ""
item["newbornSex"] = (party.newborn_sex or "") if party else ""
item["newbornDeliveryTime"] = _fmt_dt(party.newborn_delivery_time) if party else ""
item["protocolRemark"] = (proto.remark or "") if proto else ""
item["protocolAttachments"] = (proto.protocol_attachments or "") if proto else ""
item["consentFormUrls"] = (proto.consent_form_urls or "") if proto else ""
item["healthSurveyFormUrls"] = (proto.health_survey_form_urls or "") if proto else ""
item["salesUserId"] = (ci.sales_user_id or "") if ci else ""
items.append(item)
if items and tenant_no:
from app.crud import sample_reception as reception_svc
from app.crud.storage_release_review import _get_user_name
from app.models.models import BizProtocolSamplePayment
codes = [i["sampleCode"] for i in items if i.get("sampleCode")]
code_map = reception_svc.get_lab_storage_code_map_by_sample_codes(
db, tenant_no, codes, reception_source="cell_storage"
)
pay_mode_map: Dict[str, str] = {}
if codes:
for sp in (
db.query(BizProtocolSamplePayment)
.filter(
BizProtocolSamplePayment.tenant_no == tenant_no,
BizProtocolSamplePayment.del_flag == 0,
BizProtocolSamplePayment.sample_code.in_(codes),
)
.all()
):
sc = (sp.sample_code or "").strip()
if sc and sc not in pay_mode_map:
pay_mode_map[sc] = (sp.pay_mode or "").strip()
sales_ids = list({(i.get("salesUserId") or "").strip() for i in items if i.get("salesUserId")})
sales_name_map = {uid: _get_user_name(db, uid) for uid in sales_ids if uid}
pay_mode_label = {"dj": "趸交", "fq": "分期"}
for item in items:
sc = (item.get("sampleCode") or "").strip()
item["labStorageCode"] = code_map.get(sc, "") if sc else ""
mode = pay_mode_map.get(sc, "")
item["payMode"] = mode
item["payModeLabel"] = pay_mode_label.get(mode, mode or "")
sid = (item.get("salesUserId") or "").strip()
item["marketSpecialistName"] = sales_name_map.get(sid, "") if sid else ""
from app.services.sample_info_list_enrich import enrich_sample_info_items
enrich_sample_info_items(
db, tenant_no, items, reception_source="cell_storage", include_handle_flags=True
)
return items, total
File diff suppressed because it is too large. Load diff
+4 -14
View File
@@ -13,6 +13,7 @@ from app.crud import qc_task as task_svc
from app.crud import qc_task_assignee as assignee_svc
from app.crud import qc_third_party_item as third_item_svc
from app.crud.qc_third_party_company import get_company
from app.crud.user_display import user_display_name_map
from app.models.models import (
BizQcBatchAssignee,
BizQcTask,
@@ -20,7 +21,6 @@ from app.models.models import (
BizQcWorkOrderInspectionBatch,
BizQcWorkOrderLabItem,
BizQcWorkOrderThirdItem,
SysUser,
)
_CONFIGURABLE_WORK_ORDER_STATUSES = frozenset({"dpz", "ypz", "ywc", "yzf"})
@@ -180,13 +180,8 @@ def _batch_assignee_user_ids(db: Session, batch_id: str, tenant_no: str) -> List
def _assignee_names(db: Session, user_ids: List[str]) -> str:
if not user_ids:
return ""
rows = (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(user_ids), SysUser.del_flag == 0)
.all()
)
name_map = {r.id: (r.nick_name or r.user_no or r.id) for r in rows}
return "、".join(name_map.get(uid, uid) for uid in user_ids)
name_map = user_display_name_map(db, user_ids)
return "、".join(name_map.get(uid) or uid for uid in user_ids)
def _prefetch_batch_side_data(
@@ -248,12 +243,7 @@ def _prefetch_batch_side_data(
[uid for ids in assignees_by_batch.values() for uid in ids]
)
if all_user_ids:
for row in (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(all_user_ids), SysUser.del_flag == 0)
.all()
):
name_map[row.id] = row.nick_name or row.user_no or row.id
name_map = user_display_name_map(db, all_user_ids)
return {
"assignees_by_batch": assignees_by_batch,
+43 -34
View File
@@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional
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.qc_param import BizQcItemParam
@@ -80,18 +80,23 @@ def save_params_for_item(
_validate_param(p, idx)
now = datetime.now()
db.query(BizQcItemParam).filter(
BizQcItemParam.item_source == item_source,
BizQcItemParam.item_id == item_id,
BizQcItemParam.tenant_no == tenant_no,
BizQcItemParam.del_flag == 0,
).update(
{"del_flag": 1, "update_by": user_id, "update_time": now},
synchronize_session="fetch",
)
db.flush()
for idx, p in enumerate(params):
def build_row(row_id):
return BizQcItemParam(
id=row_id,
item_source=item_source,
item_id=item_id,
param_name="",
param_code="",
param_type="text",
required_flag="0",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row, p, idx, _now):
param_type = (p.get("paramType") or "text").strip()
options_val = None
default_val = None
@@ -99,28 +104,32 @@ def save_params_for_item(
opts = _parse_options_list(p.get("options"))
options_val = json.dumps(opts, ensure_ascii=False)
default_val = opts[0] if opts else None
row.param_name = p.get("paramName", "")
row.param_code = p.get("paramCode", "")
row.param_unit = p.get("paramUnit") or None
row.param_type = param_type
row.options = options_val
row.default_value = default_val
row.required_flag = p.get("requiredFlag", "0")
row.order_num = p.get("orderNum", idx)
obj = BizQcItemParam(
id=generate_uuid(),
item_source=item_source,
item_id=item_id,
param_name=p.get("paramName", ""),
param_code=p.get("paramCode", ""),
param_unit=p.get("paramUnit") or None,
param_type=param_type,
options=options_val,
default_value=default_val,
required_flag=p.get("requiredFlag", "0"),
tenant_no=tenant_no,
order_num=p.get("orderNum", idx),
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
sync_child_rows(
db,
BizQcItemParam,
parent_attr="item_id",
parent_id=item_id,
tenant_no=tenant_no,
items=params,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("paramCode") or "").strip(),
row_natural_key=lambda row: (row.param_code or "").strip(),
duplicate_message="参数编码重复",
foreign_message="参数不属于当前检测项目",
extra_filters={"item_source": item_source},
reuse_deleted=True,
)
db.commit()
return list_params_by_item(db, item_source, item_id, tenant_no)
+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.qc_material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
from app.models.models import SysUser
from app.models.qc_material import (
@@ -439,6 +440,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,
@@ -455,72 +494,61 @@ def save_unused_lines(
raise ValueError("仅进行中的盘点单可登记未用完物料")
now = datetime.now()
old_rows = (
db.query(BizQcMaterialStockCheckUnusedLine)
.filter(
BizQcMaterialStockCheckUnusedLine.check_id == check_id,
BizQcMaterialStockCheckUnusedLine.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 = BizQcMaterialStockCheckUnusedLine(
id=generate_uuid(),
def build_row(row_id: str):
return BizQcMaterialStockCheckUnusedLine(
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,
BizQcMaterialStockCheckUnusedLine,
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(
+40 -23
View File
@@ -5,8 +5,8 @@ from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud import qc_item_param as param_svc
from app.crud.child_row_sync import sync_child_rows
from app.crud.qc_task import get_task
from app.models.models import BizQcWorkOrderLabItem, BizQcWorkOrderThirdItem
from app.models.qc_param import BizQcTaskResult
@@ -152,36 +152,53 @@ def save_results_for_task(
if not task:
raise ValueError("任务不存在")
now = datetime.now()
db.query(BizQcTaskResult).filter(
BizQcTaskResult.task_id == task_id,
BizQcTaskResult.tenant_no == tenant_no,
BizQcTaskResult.del_flag == 0,
).update(
{"del_flag": 1, "update_by": user_id, "update_time": now},
synchronize_session="fetch",
)
db.flush()
for res in results:
obj = BizQcTaskResult(
id=generate_uuid(),
def _result_key(item: Dict[str, Any]) -> str:
param_id = (item.get("paramId") or "").strip()
if param_id:
return "id:" + param_id
return "code:" + (item.get("paramCode") or "").strip()
def build_row(row_id):
return BizQcTaskResult(
id=row_id,
task_id=task_id,
param_id=res.get("paramId") or None,
param_name=res.get("paramName", ""),
param_code=res.get("paramCode") or None,
param_unit=res.get("paramUnit") or None,
param_type=res.get("paramType", "text"),
param_value=res.get("paramValue") or None,
param_name="",
param_type="text",
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(obj)
def apply_row(row, res, _idx, _now):
row.param_id = res.get("paramId") or None
row.param_name = res.get("paramName", "")
row.param_code = res.get("paramCode") or None
row.param_unit = res.get("paramUnit") or None
row.param_type = res.get("paramType", "text")
row.param_value = res.get("paramValue") or None
sync_child_rows(
db,
BizQcTaskResult,
parent_attr="task_id",
parent_id=task_id,
tenant_no=tenant_no,
items=results,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_result_key,
row_natural_key=lambda row: (
"id:" + row.param_id.strip()
if (row.param_id or "").strip()
else "code:" + (row.param_code or "").strip()
),
duplicate_message="检测结果重复",
foreign_message="检测结果不属于当前任务",
)
if commit:
db.commit()
else:
@@ -0,0 +1,159 @@
"""临时质检列表:同生产工单成组,组位置按组内最新创建时间倒序。"""
from __future__ import annotations
from functools import cmp_to_key
from typing import Any, Dict, List, Optional, Sequence, Tuple
from sqlalchemy import and_, func
from sqlalchemy.orm import Query
from app.models.models import BizQcWorkOrder
def uses_temporary_production_cluster_list_sort(work_order_type: Optional[str]) -> bool:
return (work_order_type or "").strip() == "temporary"
def temporary_production_cluster_key(
production_work_order_id: Optional[str], row_id: str
) -> str:
pwo = (production_work_order_id or "").strip()
if pwo:
return f"pwo:{pwo}"
return f"id:{(row_id or '').strip()}"
def _max_create_time_by_cluster(
rows: Sequence[Tuple[str, Optional[str], str]],
) -> Dict[str, str]:
"""rows: (id, production_work_order_id, create_time_str)"""
group_max: Dict[str, str] = {}
for row_id, pwo_id, create_time in rows:
key = temporary_production_cluster_key(pwo_id, row_id)
t = (create_time or "").strip()
prev = group_max.get(key, "")
if t > prev:
group_max[key] = t
return group_max
def compare_temporary_production_cluster_desc(
id_a: str,
pwo_a: Optional[str],
time_a: Optional[str],
group_max_a: str,
id_b: str,
pwo_b: Optional[str],
time_b: Optional[str],
group_max_b: str,
) -> int:
"""负值表示 a 应排在 b 前。"""
ga = (group_max_a or "").strip()
gb = (group_max_b or "").strip()
if ga != gb:
return -1 if ga > gb else 1
key_a = temporary_production_cluster_key(pwo_a, id_a)
key_b = temporary_production_cluster_key(pwo_b, id_b)
if key_a != key_b:
return -1 if key_a < key_b else 1
ta = (time_a or "").strip()
tb = (time_b or "").strip()
if ta != tb:
return -1 if ta > tb else 1
ia = (id_a or "").strip()
ib = (id_b or "").strip()
if ia != ib:
return -1 if ia > ib else 1
return 0
def sort_dicts_by_temporary_production_cluster(
items: List[Dict[str, Any]],
*,
id_key: str = "id",
pwo_key: str = "productionWorkOrderId",
time_key: str = "createTime",
) -> List[Dict[str, Any]]:
"""按设计规则排序质检工单 dict 列表(供单测 / 内存排序)。"""
triples: List[Tuple[str, Optional[str], str]] = []
for item in items:
rid = str(item.get(id_key) or "")
pwo = item.get(pwo_key)
pwo_s: Optional[str]
if pwo is None:
pwo_s = None
elif isinstance(pwo, str):
pwo_s = pwo
else:
pwo_s = str(pwo)
triples.append((rid, pwo_s, str(item.get(time_key) or "")))
group_max = _max_create_time_by_cluster(triples)
def cmp(a: Dict[str, Any], b: Dict[str, Any]) -> int:
id_a = str(a.get(id_key) or "")
id_b = str(b.get(id_key) or "")
pwo_a = a.get(pwo_key)
pwo_b = b.get(pwo_key)
if pwo_a is not None and not isinstance(pwo_a, str):
pwo_a = str(pwo_a)
if pwo_b is not None and not isinstance(pwo_b, str):
pwo_b = str(pwo_b)
time_a = str(a.get(time_key) or "")
time_b = str(b.get(time_key) or "")
key_a = temporary_production_cluster_key(pwo_a, id_a)
key_b = temporary_production_cluster_key(pwo_b, id_b)
return compare_temporary_production_cluster_desc(
id_a,
pwo_a,
time_a,
group_max.get(key_a, ""),
id_b,
pwo_b,
time_b,
group_max.get(key_b, ""),
)
return sorted(items, key=cmp_to_key(cmp))
def apply_temporary_production_cluster_order(q: Query) -> Query:
"""对已过滤的 BizQcWorkOrder Query 施加成组排序(MySQL 5.7 友好)。"""
session = q.session
base = (
q.order_by(None)
.with_entities(
BizQcWorkOrder.id.label("wo_id"),
BizQcWorkOrder.production_work_order_id.label("pwo_id"),
BizQcWorkOrder.create_time.label("ct"),
)
.subquery()
)
pwo_max = (
session.query(
base.c.pwo_id.label("pwo_id"),
func.max(base.c.ct).label("group_ct"),
)
.select_from(base)
.filter(
base.c.pwo_id.isnot(None),
base.c.pwo_id != "",
)
.group_by(base.c.pwo_id)
.subquery()
)
return (
q.outerjoin(
pwo_max,
and_(
BizQcWorkOrder.production_work_order_id == pwo_max.c.pwo_id,
BizQcWorkOrder.production_work_order_id.isnot(None),
BizQcWorkOrder.production_work_order_id != "",
),
)
.order_by(
func.coalesce(pwo_max.c.group_ct, BizQcWorkOrder.create_time).desc(),
func.coalesce(BizQcWorkOrder.production_work_order_id, BizQcWorkOrder.id).asc(),
BizQcWorkOrder.create_time.desc(),
BizQcWorkOrder.id.desc(),
)
)
+130 -7
View File
@@ -6,10 +6,14 @@ from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
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, like_unicode, unicode_ci
from app.core.protocol_status_display import (
DEFAULT_QC_DONATION_PROTOCOL_STATUSES,
resolve_research_donation_protocol_status_filter,
)
from app.core.utils import generate_uuid
from app.crud import qc_lab_test_item as lab_item_svc
from app.crud import qc_task as task_svc
@@ -20,6 +24,10 @@ from app.crud.qc_inbound_lab_storage_sort import (
inbound_lab_storage_order_by_clauses,
uses_inbound_lab_storage_list_sort,
)
from app.crud.qc_temporary_production_cluster_sort import (
apply_temporary_production_cluster_order,
uses_temporary_production_cluster_list_sort,
)
from app.crud.collection_protocol import urls_to_attachment_list
from app.utils.sample_special import apply_special_to_item_from_samples
from app.crud import sample_reception as sample_reception_svc
@@ -219,10 +227,51 @@ def _enrich_work_order_cell_products(
return items
def _step_output_identity(row: BizProductionTaskStepOutput) -> Tuple[str, str, str, str]:
return (
(row.step_id or "").strip(),
(row.output_name or "").strip(),
(row.output_barcode or "").strip(),
(row.output_type_id or "").strip(),
)
def _live_output_replacements(
db: Session,
tenant_no: str,
deleted_rows: List[BizProductionTaskStepOutput],
) -> Dict[str, BizProductionTaskStepOutput]:
"""已软删产出物 → 同一步骤上名称、条码、产出类型都一致的唯一有效行。"""
step_ids = list({(row.step_id or "").strip() for row in deleted_rows if (row.step_id or "").strip()})
if not step_ids:
return {}
live_rows = (
db.query(BizProductionTaskStepOutput)
.filter(
BizProductionTaskStepOutput.step_id.in_(step_ids),
BizProductionTaskStepOutput.tenant_no == tenant_no,
BizProductionTaskStepOutput.del_flag == 0,
)
.all()
)
grouped: Dict[Tuple[str, str, str, str], List[BizProductionTaskStepOutput]] = defaultdict(list)
for row in live_rows:
grouped[_step_output_identity(row)].append(row)
replacements: Dict[str, BizProductionTaskStepOutput] = {}
for old in deleted_rows:
matches = [row for row in grouped.get(_step_output_identity(old), []) if row.id != old.id]
if len(matches) == 1:
replacements[old.id] = matches[0]
return replacements
def _enrich_work_order_step_outputs(
db: Session, tenant_no: str, items: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""步骤产出物触发的质检:无细胞产品时用工单条码/名称展示质检对象。"""
"""步骤产出物触发的质检:无细胞产品时用有效产出物的条码和名称展示质检对象。
工单若仍指向已软删产出物,改用同一步骤上的那条有效产出物,不再拿软删行当质检对象。
"""
if not items:
return items
step_output_ids = list(
@@ -240,17 +289,23 @@ def _enrich_work_order_step_outputs(
.filter(
BizProductionTaskStepOutput.id.in_(step_output_ids),
BizProductionTaskStepOutput.tenant_no == tenant_no,
BizProductionTaskStepOutput.del_flag == 0,
)
.all()
)
row_map = {r.id: r for r in rows}
deleted_rows = [row for row in rows if int(row.del_flag or 0) == 1]
live_by_deleted_id = (
_live_output_replacements(db, tenant_no, deleted_rows) if deleted_rows else {}
)
row_map = {row.id: row for row in rows}
for item in items:
if (item.get("cellProductId") or "").strip():
continue
so = row_map.get(item.get("productionStepOutputId") or "")
if not so:
if so is not None and int(so.del_flag or 0) == 1:
so = live_by_deleted_id.get(so.id)
if so is None or int(so.del_flag or 0) == 1:
continue
item["productionStepOutputId"] = so.id
if not (item.get("cellProductCode") or "").strip():
item["cellProductCode"] = (so.output_barcode or "").strip()
if not (item.get("cellProductName") or "").strip():
@@ -298,6 +353,8 @@ def _enrich_outbound_work_orders(
"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,
@@ -381,6 +438,8 @@ def _enrich_outbound_work_orders(
item["totalCourseCount"] = course.get("totalCourseCount")
item["cellType"] = sched.cell_type or ""
item["specText"] = sched.spec_text or ""
item["packageId"] = getattr(sched, "package_id", None) or ""
item["packageText"] = getattr(sched, "package_text", None) or ""
item["infusionTime"] = _fmt_dt(sched.infusion_time)
item["products"] = products_map.get(sid, [])
return items
@@ -1566,6 +1625,52 @@ def _protocol_sample_ids_for_sample_type(
return ids
def _research_donation_status_exists(statuses: List[str]):
"""质检工单关联科研捐赠单且 donation_status 命中 statuses。"""
return exists().where(
and_(
eq_unicode(BizResearchDonation.id, BizQcWorkOrder.collection_protocol_id),
BizResearchDonation.del_flag == 0,
BizResearchDonation.donation_status.in_(statuses),
)
)
def apply_research_donation_protocol_status_filter(
query,
protocol_status: Optional[str],
*,
reception_source: Optional[str] = None,
force_research_donation_scope: bool = False,
):
"""科研捐赠质检:按协议/捐赠状态过滤。
- force_research_donation_scope=True:用于全量 Tab 统计,仅剔除 RD 废弃行,其它来源不动。
- 否则仅当 reception_source == research_donation 时生效。
"""
rs = (reception_source or "").strip()
if force_research_donation_scope:
# 角标与列表默认一致:科研捐赠仅计待生效/生效中
not_default = and_(
BizQcWorkOrder.reception_source == "research_donation",
~_research_donation_status_exists(list(DEFAULT_QC_DONATION_PROTOCOL_STATUSES)),
)
return query.filter(~not_default)
if rs != "research_donation":
return query
mode, statuses = resolve_research_donation_protocol_status_filter(protocol_status)
if mode == "none":
return query.filter(BizQcWorkOrder.id.in_([]))
if mode == "in":
return query.filter(_research_donation_status_exists(statuses))
# default:与前端默认勾选一致
return query.filter(
_research_donation_status_exists(list(DEFAULT_QC_DONATION_PROTOCOL_STATUSES))
)
def list_work_orders(
db: Session,
*,
@@ -1578,6 +1683,7 @@ def list_work_orders(
reception_source: Optional[str] = None,
trigger_source: Optional[str] = None,
sample_type_id: Optional[str] = None,
protocol_status: Optional[str] = None,
create_time_start: Optional[str] = None,
create_time_end: Optional[str] = None,
infusion_time_start: Optional[str] = None,
@@ -1708,6 +1814,11 @@ def list_work_orders(
if user_info:
from app.core.data_scope import apply_data_scope
q0 = apply_data_scope(q0, BizQcWorkOrder, user_info, db, tenant_no)
q0 = apply_research_donation_protocol_status_filter(
q0,
protocol_status,
reception_source=rs,
)
return q0
total = _filtered().order_by(None).with_entities(func.count(BizQcWorkOrder.id)).scalar() or 0
@@ -1716,6 +1827,8 @@ def list_work_orders(
q_page = _filtered()
if uses_inbound_lab_storage_list_sort(reception_source):
q_page = q_page.order_by(*inbound_lab_storage_order_by_clauses())
elif uses_temporary_production_cluster_list_sort(work_order_type):
q_page = apply_temporary_production_cluster_order(q_page)
else:
q_page = q_page.order_by(BizQcWorkOrder.create_time.desc())
rows = (
@@ -1941,6 +2054,9 @@ def count_unfinished_work_orders_by_tab(
BizQcWorkOrder.tenant_no == tenant_no,
)
q = _unfinished_work_order_filter(q)
q = apply_research_donation_protocol_status_filter(
q, None, force_research_donation_scope=True
)
if user_info:
from app.core.data_scope import apply_data_scope
@@ -1997,6 +2113,9 @@ def count_mine_unfinished_work_orders_by_tab(
)
)
q = _unfinished_work_order_filter(q)
q = apply_research_donation_protocol_status_filter(
q, None, force_research_donation_scope=True
)
rows = (
q.group_by(
BizQcWorkOrder.work_order_type,
@@ -3215,9 +3334,9 @@ def list_temp_qc_work_orders(
total = _filtered().order_by(None).with_entities(func.count(BizQcWorkOrder.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
q_page = apply_temporary_production_cluster_order(_filtered())
rows = (
_filtered()
.order_by(BizQcWorkOrder.create_time.desc())
q_page
.offset((page - 1) * page_size)
.limit(page_size)
.all()
@@ -3422,6 +3541,8 @@ def build_outbound_pilot_context_by_schedule(
"infusionMethod": sched.infusion_method or "",
"cellType": sched.cell_type or "",
"specText": sched.spec_text or "",
"packageId": getattr(sched, "package_id", None) or "",
"packageText": getattr(sched, "package_text", None) or "",
"scheduleStatus": sched.schedule_status or "",
"infusionStatus": sched.infusion_status or "",
"qcStatus": sched.qc_status or "",
@@ -3510,6 +3631,8 @@ def build_outbound_pilot_context_by_schedule(
"projectName": p.project_name or "",
"cellTypeName": p.cell_type_name or "",
"specText": p.spec_text or "",
"packageId": getattr(p, "package_id", None) or "",
"packageText": getattr(p, "package_text", None) or "",
"agreementPrice": _dec(p.agreement_price),
"actualPrice": _dec(p.actual_price),
"quantity": p.quantity,
File diff suppressed because it is too large. Load diff
@@ -128,6 +128,10 @@ def _apply_sample_status_filter(q, sample_status: Optional[str]):
BizResearchDonationSample.sample_status == "",
)
)
if st == "collected":
return q.filter(BizResearchDonationSample.sample_status.in_(["ycj", "jcz", "yrk"]))
if st == "voided":
return q.filter(BizResearchDonationSample.sample_status == "yzf")
return q.filter(BizResearchDonationSample.sample_status == st)
@@ -139,13 +143,44 @@ def list_donation_sample_collections(
tenant_no: str,
keyword: Optional[str] = None,
sample_status: Optional[str] = None,
collection_flag: Optional[str] = None,
reception_flag: Optional[str] = None,
qc_flag: Optional[str] = None,
storage_flag: Optional[str] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
if not tenant_no:
return [], 0
from app.crud.protocol_sample_collection import (
_apply_qc_flag_filter,
_apply_reception_flag_filter,
_apply_storage_flag_filter,
)
q = _base_query(db, tenant_no)
q = _apply_sample_status_filter(q, sample_status)
q = _apply_sample_status_filter(q, collection_flag or sample_status)
q = _apply_reception_flag_filter(
q,
reception_flag,
sample_code_col=BizResearchDonationSample.sample_code,
tenant_no=tenant_no,
source="research_donation",
)
q = _apply_qc_flag_filter(
q,
qc_flag,
sample_code_col=BizResearchDonationSample.sample_code,
tenant_no=tenant_no,
source="research_donation",
)
q = _apply_storage_flag_filter(
q,
storage_flag,
sample_code_col=BizResearchDonationSample.sample_code,
tenant_no=tenant_no,
source="research_donation",
)
if keyword and keyword.strip():
kw = f"%{keyword.strip()}%"
@@ -257,4 +292,13 @@ def list_donation_sample_collections(
for item in items:
sc = (item.get("sampleCode") or "").strip()
item["labStorageCode"] = code_map.get(sc, "") if sc else ""
from app.services.sample_info_list_enrich import enrich_sample_info_items
enrich_sample_info_items(
db,
tenant_no,
items,
reception_source="research_donation",
include_handle_flags=False,
)
return items, total
+49 -43
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows, sync_link_rows
from app.models.models import (
SysPost,
SysPostRole,
@@ -377,26 +378,18 @@ def update_role_menus(
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
# uk_role_menu 为 (role_id, menu_id, del_flag):先全量软删再插入会在再次保存时
# 出现两条 (role_id, menu_id, del_flag=1),触发 1062。关联表采用整角色物理清理后重建。
unique_ids = list(dict.fromkeys(menu_ids or []))
db.query(SysRoleMenu).filter(SysRoleMenu.role_id == role_id).delete(synchronize_session=False)
now = datetime.now()
for mid in unique_ids:
obj = SysRoleMenu(
id=generate_uuid(),
role_id=role_id,
menu_id=mid,
tenant_no=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(obj)
sync_link_rows(
db,
SysRoleMenu,
parent_attr="role_id",
parent_id=role_id,
link_attr="menu_id",
link_ids=menu_ids,
tenant_no=tenant_no,
user_id=user_id,
duplicate_message="菜单重复",
foreign_message="菜单关联不属于当前角色",
)
db.commit()
@@ -430,28 +423,41 @@ def update_role_data_scope(
obj.update_time = datetime.now()
db.add(obj)
# soft-delete old scope details
db.query(SysRoleDataScopeDetail).filter(
and_(SysRoleDataScopeDetail.role_id == role_id, SysRoleDataScopeDetail.del_flag == 0)
).update({"del_flag": 1, "update_time": datetime.now()})
now = datetime.now()
details = scope_details if data_scope == "5" and scope_details else []
# insert new scope details (only when custom data_scope == '5')
if data_scope == "5" and scope_details:
now = datetime.now()
for detail in scope_details:
sd = SysRoleDataScopeDetail(
id=generate_uuid(),
role_id=role_id,
scope_type=detail.get("scopeType", "dept"),
scope_value=detail.get("scopeValue", ""),
tenant_no=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(sd)
def build_scope(row_id):
return SysRoleDataScopeDetail(
id=row_id,
role_id=role_id,
scope_type="",
scope_value="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_scope(row, detail, _idx, _now):
row.scope_type = detail.get("scopeType", "dept")
row.scope_value = detail.get("scopeValue", "")
row.order_num = 0
sync_child_rows(
db,
SysRoleDataScopeDetail,
parent_attr="role_id",
parent_id=role_id,
tenant_no=tenant_no,
items=details,
user_id=user_id,
now=now,
build_row=build_scope,
apply_fields=apply_scope,
natural_key=lambda detail: f"{detail.get('scopeType', 'dept')}:{detail.get('scopeValue', '')}",
row_natural_key=lambda row: f"{row.scope_type}:{row.scope_value}",
duplicate_message="数据权限重复",
foreign_message="数据权限不属于当前角色",
reuse_deleted=True,
)
db.commit()
+1 -1
View File
@@ -23,7 +23,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
+149
View File
@@ -0,0 +1,149 @@
"""样本客户服务扩展 CRUD。"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.customer_service_dict import ensure_dict_value
from app.models.models import BizSampleCustomerService
from app.services.sample_customer_service_register import (
CUSTOMER_SERVICE_FIELD_KEYS,
merge_customer_service_fields,
normalize_dict_value,
)
DICT_TYPE_BY_FIELD = {
"sms_send_status": "sms_send_status",
"microbe_followup_status": "microbe_followup_status",
"fullmoon_followup_status": "fullmoon_followup_status",
"household_registered": "household_registered",
}
def _format_date(value: Optional[date]) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d")
return value.strftime("%Y-%m-%d")
def _format_datetime(value: Optional[datetime]) -> Optional[str]:
if value is None:
return None
return value.strftime("%Y-%m-%d %H:%M:%S")
def _row_field_dict(row: BizSampleCustomerService) -> Dict[str, Any]:
return {key: getattr(row, key) for key in CUSTOMER_SERVICE_FIELD_KEYS}
def get_by_sample_code(
db: Session, tenant_no: str, sample_code: str
) -> Optional[BizSampleCustomerService]:
code = (sample_code or "").strip()
if not code:
return None
return (
db.query(BizSampleCustomerService)
.filter(
BizSampleCustomerService.tenant_no == tenant_no,
BizSampleCustomerService.sample_code == code,
BizSampleCustomerService.del_flag == 0,
)
.first()
)
def get_map_by_sample_codes(
db: Session, tenant_no: str, sample_codes: List[str]
) -> Dict[str, BizSampleCustomerService]:
codes = list({(c or "").strip() for c in sample_codes if (c or "").strip()})
if not codes:
return {}
rows = (
db.query(BizSampleCustomerService)
.filter(
BizSampleCustomerService.tenant_no == tenant_no,
BizSampleCustomerService.sample_code.in_(codes),
BizSampleCustomerService.del_flag == 0,
)
.all()
)
return {row.sample_code: row for row in rows}
def row_to_dict(row: BizSampleCustomerService) -> Dict[str, Any]:
return {
"id": row.id,
"sampleCode": row.sample_code,
"collectionProtocolId": row.collection_protocol_id,
"smsSendDate": _format_date(row.sms_send_date),
"smsSendStatus": row.sms_send_status,
"microbeFollowupDate": _format_date(row.microbe_followup_date),
"microbeFollowupStatus": row.microbe_followup_status,
"fullmoonFollowupDate": _format_date(row.fullmoon_followup_date),
"fullmoonFollowupStatus": row.fullmoon_followup_status,
"householdRegistered": row.household_registered,
"einvoiceSendTime": _format_datetime(row.einvoice_send_time),
"effectiveAddress": row.effective_address,
}
def upsert_by_sample_code(
db: Session,
*,
tenant_no: str,
sample_code: str,
collection_protocol_id: Optional[str],
user_id: Optional[str],
incoming: Dict[str, Any],
) -> BizSampleCustomerService:
code = (sample_code or "").strip()
if not code:
raise ValueError("sample_code is required")
row = get_by_sample_code(db, tenant_no, code)
now = datetime.now()
if row is None:
row = BizSampleCustomerService(
id=generate_uuid(),
sample_code=code,
collection_protocol_id=collection_protocol_id,
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)
else:
if collection_protocol_id and not row.collection_protocol_id:
row.collection_protocol_id = collection_protocol_id
row.update_by = user_id
row.update_time = now
existing = _row_field_dict(row)
merged = merge_customer_service_fields(existing, incoming)
for field, dict_type in DICT_TYPE_BY_FIELD.items():
if field not in merged:
continue
value = merged[field]
if value is None or normalize_dict_value(value) is None:
continue
merged[field] = ensure_dict_value(
db, tenant_no, dict_type, value, user_id=user_id
)
for key in CUSTOMER_SERVICE_FIELD_KEYS:
if key in merged:
setattr(row, key, merged[key])
db.flush()
return row
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,310 @@
"""样本信息列表快捷登记写入。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud import sample_customer_service as sample_customer_service_crud
from app.crud import sample_insurance as sample_insurance_crud
from app.crud import sample_storage_cert as sample_storage_cert_crud
from app.crud.storage_release_review import _get_user_name
from app.services.sample_customer_service_register import has_any_customer_service_payload
from app.models.models import (
BizCollectionProtocol,
BizProtocolSample,
BizStorageReleaseReview,
)
from app.models.research_donation import BizResearchDonation, BizResearchDonationSample
from app.services.sample_info_quick_register import (
apply_quick_storage_payload,
unique_sample_items,
)
def _protocol_accessible(db: Session, protocol_id: str, tenant_no: str) -> bool:
"""登记写入只校验本租户协议存在,不再按数据权限拦截。"""
row = (
db.query(BizCollectionProtocol.id)
.filter(
BizCollectionProtocol.id == protocol_id,
BizCollectionProtocol.del_flag == 0,
BizCollectionProtocol.tenant_no == tenant_no,
)
.first()
)
return row is not None
def _donation_accessible(db: Session, donation_id: str, tenant_no: str) -> bool:
"""登记写入只校验本租户捐赠单存在,不再按数据权限拦截。"""
row = (
db.query(BizResearchDonation.id)
.filter(
BizResearchDonation.id == donation_id,
BizResearchDonation.del_flag == 0,
BizResearchDonation.tenant_no == tenant_no,
)
.first()
)
return row is not None
def _pick_latest_review(
rows: List[BizStorageReleaseReview],
) -> Optional[BizStorageReleaseReview]:
best = None
best_t = None
for rev in rows:
cur_t = rev.storage_time or rev.update_time or rev.create_time
if best is None:
best, best_t = rev, cur_t
continue
if cur_t is None:
continue
if best_t is None or cur_t > best_t:
best, best_t = rev, cur_t
return best
def batch_handle_storage_certs(
db: Session,
*,
items: List[Dict[str, str]],
tenant_no: str,
user_id: str,
handle_time: Optional[datetime],
handler_user_id: Optional[str],
attachments: str,
remark: str,
mail_cert_time: Optional[datetime] = None,
update_mail_cert_time: bool = False,
do_commit: bool = True,
) -> Dict[str, int]:
pairs = unique_sample_items(items)
if not pairs:
raise ValueError("请选择样本")
success = 0
fail = 0
for sample_code, protocol_id in pairs:
if not protocol_id or not _protocol_accessible(db, protocol_id, tenant_no):
fail += 1
continue
try:
sample_storage_cert_crud.upsert_handle(
db,
tenant_no=tenant_no,
sample_code=sample_code,
collection_protocol_id=protocol_id,
user_id=user_id,
handle_time=handle_time,
handler_user_id=handler_user_id,
cert_image_urls=attachments,
remark=remark,
mail_cert_time=mail_cert_time,
update_mail_cert_time=update_mail_cert_time,
)
success += 1
except ValueError:
fail += 1
if do_commit:
db.commit()
return {"success": success, "fail": fail}
def batch_handle_insurance(
db: Session,
*,
items: List[Dict[str, str]],
tenant_no: str,
user_id: str,
handle_time: Optional[datetime],
handler_user_id: Optional[str],
attachments: str,
remark: str,
policy_send_time: Optional[datetime] = None,
update_policy_send_time: bool = False,
do_commit: bool = True,
) -> Dict[str, int]:
pairs = unique_sample_items(items)
if not pairs:
raise ValueError("请选择样本")
success = 0
fail = 0
for sample_code, protocol_id in pairs:
if not protocol_id or not _protocol_accessible(db, protocol_id, tenant_no):
fail += 1
continue
try:
sample_insurance_crud.upsert_handle(
db,
tenant_no=tenant_no,
sample_code=sample_code,
collection_protocol_id=protocol_id,
user_id=user_id,
handle_time=handle_time,
handler_user_id=handler_user_id,
insurance_attachments=attachments,
handle_remark=remark,
policy_send_time=policy_send_time,
update_policy_send_time=update_policy_send_time,
)
success += 1
except ValueError:
fail += 1
if do_commit:
db.commit()
return {"success": success, "fail": fail}
def batch_register_customer_service(
db: Session,
*,
items: List[Dict[str, str]],
tenant_no: str,
user_id: str,
incoming: Dict[str, Any],
do_commit: bool = True,
) -> Dict[str, int]:
if not has_any_customer_service_payload(incoming):
raise ValueError("请至少填写一项客户服务信息")
pairs = unique_sample_items(items)
if not pairs:
raise ValueError("请选择样本")
success = 0
fail = 0
for sample_code, protocol_id in pairs:
if not protocol_id or not _protocol_accessible(db, protocol_id, tenant_no):
fail += 1
continue
try:
sample_customer_service_crud.upsert_by_sample_code(
db,
tenant_no=tenant_no,
sample_code=sample_code,
collection_protocol_id=protocol_id,
user_id=user_id,
incoming=incoming,
)
success += 1
except ValueError:
fail += 1
if do_commit:
db.commit()
return {"success": success, "fail": fail}
def batch_quick_register_storage(
db: Session,
*,
items: List[Dict[str, str]],
reception_source: str,
tenant_no: str,
user_id: str,
handle_time: Optional[datetime],
handler_user_id: Optional[str],
attachments: str,
remark: str,
do_commit: bool = True,
) -> Dict[str, int]:
source = (reception_source or "").strip()
if source not in ("cell_storage", "research_donation"):
raise ValueError("来源无效")
fields = apply_quick_storage_payload(
handle_time=handle_time,
handler_user_id=handler_user_id,
attachments=attachments,
remark=remark,
)
pairs = unique_sample_items(items)
if not pairs:
raise ValueError("请选择样本")
handler_name = _get_user_name(db, fields["storage_by"] or user_id)
success = 0
fail = 0
now = datetime.now()
for sample_code, protocol_id in pairs:
if source == "cell_storage":
if not protocol_id or not _protocol_accessible(db, protocol_id, tenant_no):
fail += 1
continue
else:
if not protocol_id or not _donation_accessible(db, protocol_id, tenant_no):
fail += 1
continue
rows = (
db.query(BizStorageReleaseReview)
.filter(
BizStorageReleaseReview.tenant_no == tenant_no,
BizStorageReleaseReview.del_flag == 0,
BizStorageReleaseReview.reception_source == source,
BizStorageReleaseReview.sample_code == sample_code,
)
.all()
)
rev = _pick_latest_review(rows)
if rev is None:
rev = BizStorageReleaseReview(
id=generate_uuid(),
sample_code=sample_code,
reception_source=source,
protocol_id=protocol_id,
storage_status="yrk",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
update_by=user_id,
create_time=now,
update_time=now,
del_flag=0,
)
db.add(rev)
rev.storage_status = fields["storage_status"]
rev.storage_time = fields["storage_time"]
rev.storage_by = fields["storage_by"]
rev.storage_by_name = handler_name
rev.remark = fields["remark"] or None
if fields["attachments"]:
rev.review_attachments = fields["attachments"]
rev.update_by = user_id
rev.update_time = now
db.add(rev)
ymd = fields["storage_in_date"]
if source == "cell_storage":
ps = (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.sample_code == sample_code,
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
)
.first()
)
if ps:
ps.sample_status = "yrk"
ps.storage_in_date = ymd
ps.update_time = now
db.add(ps)
else:
ds = (
db.query(BizResearchDonationSample)
.filter(
BizResearchDonationSample.sample_code == sample_code,
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
)
.first()
)
if ds:
ds.sample_status = "yrk"
ds.storage_in_date = ymd
ds.update_time = now
db.add(ds)
success += 1
if do_commit:
db.commit()
return {"success": success, "fail": fail}
+134
View File
@@ -0,0 +1,134 @@
"""样本保险办理 CRUD。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import BizSampleInsurance
from app.services.sample_info_progress import apply_handle_payload, insurance_handled
def _fmt_dt(t: Optional[datetime]) -> str:
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
def get_by_sample_code(
db: Session, tenant_no: str, sample_code: str
) -> Optional[BizSampleInsurance]:
code = (sample_code or "").strip()
if not code:
return None
return (
db.query(BizSampleInsurance)
.filter(
BizSampleInsurance.tenant_no == tenant_no,
BizSampleInsurance.sample_code == code,
BizSampleInsurance.del_flag == 0,
)
.first()
)
def get_map_by_sample_codes(
db: Session, tenant_no: str, codes: List[str]
) -> Dict[str, BizSampleInsurance]:
cleaned = list({(c or "").strip() for c in codes if (c or "").strip()})
if not cleaned:
return {}
rows = (
db.query(BizSampleInsurance)
.filter(
BizSampleInsurance.tenant_no == tenant_no,
BizSampleInsurance.sample_code.in_(cleaned),
BizSampleInsurance.del_flag == 0,
)
.all()
)
return {row.sample_code: row for row in rows}
def upsert_handle(
db: Session,
*,
tenant_no: str,
sample_code: str,
collection_protocol_id: Optional[str],
user_id: Optional[str],
handle_time: Optional[datetime],
handler_user_id: Optional[str],
insurance_attachments: str = "",
handle_remark: str = "",
policy_send_time: Optional[datetime] = None,
update_policy_send_time: bool = False,
) -> BizSampleInsurance:
code = (sample_code or "").strip()
if not code:
raise ValueError("sample_code is required")
payload = apply_handle_payload(
handle_time=handle_time,
handler_user_id=handler_user_id,
attachments=insurance_attachments or "",
remark=handle_remark or "",
)
now = datetime.now()
row = get_by_sample_code(db, tenant_no, code)
if row is None:
row = BizSampleInsurance(
id=generate_uuid(),
sample_code=code,
collection_protocol_id=(collection_protocol_id or "").strip() or None,
tenant_no=tenant_no,
order_num=0,
todo_status="0",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
else:
if collection_protocol_id and not row.collection_protocol_id:
row.collection_protocol_id = (collection_protocol_id or "").strip() or None
row.update_by = user_id
row.update_time = now
row.handle_time = payload["handle_time"]
row.handler_user_id = payload["handler_user_id"]
row.todo_status = payload["make_or_todo_status"]
row.handle_date = payload["legacy_date"] or None
row.insurance_attachments = payload["attachments"] or None
row.handle_remark = payload["remark"] or None
if update_policy_send_time:
row.policy_send_time = policy_send_time
db.flush()
return row
def row_to_dict(row: BizSampleInsurance) -> Dict[str, Any]:
return {
"id": row.id,
"sampleCode": row.sample_code or "",
"collectionProtocolId": row.collection_protocol_id or "",
"todoStatus": row.todo_status or "0",
"handleDate": (row.handle_date or "").strip() if row.handle_date else "",
"handleTime": _fmt_dt(row.handle_time) if getattr(row, "handle_time", None) else "",
"handlerUserId": getattr(row, "handler_user_id", None) or "",
"handleRemark": row.handle_remark or "",
"insuranceAttachments": row.insurance_attachments or "",
"policySendTime": _fmt_dt(row.policy_send_time)
if getattr(row, "policy_send_time", None)
else "",
"insuranceHandled": insurance_handled(row),
"tenantNo": row.tenant_no,
"orderNum": row.order_num if row.order_num is not None else 0,
"remark": row.remark or "",
"createTime": _fmt_dt(row.create_time),
"updateTime": _fmt_dt(row.update_time),
}
+22 -11
View File
@@ -48,6 +48,18 @@ def _fmt_dt(t: Optional[datetime]) -> str:
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
def _sort_pending_list_items(items: List[Dict[str, Any]]) -> None:
"""待接收:etaTime 升序(空值最后),同 ETA 时 sampleCode 倒序。"""
items.sort(key=lambda x: (x.get("sampleCode") or ""), reverse=True)
items.sort(key=lambda x: (not x.get("etaTime"), x.get("etaTime") or ""))
def _sort_received_list_items(items: List[Dict[str, Any]]) -> None:
"""已接收:receptionTime 倒序,同接收时间时 sampleCode 倒序。"""
items.sort(key=lambda x: (x.get("sampleCode") or ""), reverse=True)
items.sort(key=lambda x: (x.get("receptionTime") or ""), reverse=True)
def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> str:
val = (value or "").strip()
if len(val) == 10:
@@ -501,6 +513,7 @@ def list_pending_protocol_samples(
BizProtocolSampleTransport.eta_time.is_(None),
BizProtocolSampleTransport.eta_time.asc(),
BizProtocolSample.create_time.asc(),
BizProtocolSample.sample_code.desc(),
)
.all()
)
@@ -516,13 +529,14 @@ def list_pending_protocol_samples(
BizResearchDonationSampleTransport.eta_time.is_(None),
BizResearchDonationSampleTransport.eta_time.asc(),
BizResearchDonationSample.create_time.asc(),
BizResearchDonationSample.sample_code.desc(),
)
.all()
)
all_items.extend(_donation_rows_to_sample_list_items(db, donation_rows, tenant_no))
# 统一按 etaTime 排序(空值排后)
all_items.sort(key=lambda x: (not x.get("etaTime"), x.get("etaTime") or ""))
# 统一按 etaTime 升序(空值排后),同 ETA 时 sampleCode 倒序
_sort_pending_list_items(all_items)
start_eta = _normalize_date_bound(eta_time_start) if eta_time_start else ""
end_eta = _normalize_date_bound(eta_time_end, end_of_day=True) if eta_time_end else ""
@@ -628,7 +642,10 @@ def list_received_protocol_samples(
page = max(1, page)
page_size = min(max(1, page_size), 100)
receptions = (
q_rec.order_by(BizSampleReception.reception_time.desc())
q_rec.order_by(
BizSampleReception.reception_time.desc(),
BizSampleReception.sample_code.desc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
@@ -681,8 +698,8 @@ def list_received_protocol_samples(
donation_ordered = [donation_row_by_sample[sid] for sid in donation_sample_ids if sid in donation_row_by_sample]
items.extend(_donation_rows_to_sample_list_items(db, donation_ordered, tenant_no, reception_map=donation_reception_map))
# 按 reception_time DESC 排序
items.sort(key=lambda x: x.get("receptionTime") or "", reverse=True)
# 按 receptionTime 倒序,同时间时 sampleCode 倒序
_sort_received_list_items(items)
return items, int(total)
@@ -1114,12 +1131,6 @@ def update_sample_reception(
rec.update_by = user_id
rec.update_time = datetime.now()
check_result_svc.soft_delete_reception_check_results(
db,
reception_id=reception_id,
tenant_no=tenant_no,
user_id=user_id,
)
check_result_svc.save_reception_check_results(
db,
reception_id=reception_id,
@@ -7,8 +7,8 @@ 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 import sample_reception_check_item as check_item_svc
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizSampleReception,
BizSampleReceptionCheckItem,
@@ -105,7 +105,7 @@ def soft_delete_reception_check_results(
tenant_no: str,
user_id: Optional[str],
) -> None:
"""软删除某次接收的全部检查项结果(编辑接收时先清空再重写)。"""
"""软删除某次接收的全部检查项结果。删除接收记录时使用。"""
now = datetime.now()
(
db.query(BizSampleReceptionCheckResult)
@@ -143,26 +143,56 @@ def save_reception_check_results(
if unknown:
raise ValueError("存在无效的检查项")
now = datetime.now()
payload = []
for item in active_items:
item_id = str(item["id"])
checked = submitted.get(item_id, False)
row = BizSampleReceptionCheckResult(
id=generate_uuid(),
payload.append(
{
"check_item_id": item_id,
"item_code": item["itemCode"],
"item_name": item["itemName"],
"checked": 1 if submitted.get(item_id, False) else 0,
"order_num": item.get("orderNum") or 0,
}
)
def build_row(row_id):
return BizSampleReceptionCheckResult(
id=row_id,
reception_id=reception_id,
check_item_id=item_id,
item_code=item["itemCode"],
item_name=item["itemName"],
checked=1 if checked else 0,
check_item_id="",
item_code="",
item_name="",
checked=0,
tenant_no=tenant_no,
order_num=item.get("orderNum") or 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(row)
def apply_row(row, item, _idx, _now):
row.check_item_id = item["check_item_id"]
row.item_code = item["item_code"]
row.item_name = item["item_name"]
row.checked = item["checked"]
row.order_num = item["order_num"]
sync_child_rows(
db,
BizSampleReceptionCheckResult,
parent_attr="reception_id",
parent_id=reception_id,
tenant_no=tenant_no,
items=payload,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: item["check_item_id"],
row_natural_key=lambda row: row.check_item_id or "",
duplicate_message="检查项重复",
foreign_message="检查结果不属于当前接收记录",
)
def sync_legacy_columns_from_results(
+130
View File
@@ -0,0 +1,130 @@
"""样本储存证办理 CRUD。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import BizSampleStorageCert
from app.services.sample_info_progress import apply_handle_payload, storage_cert_handled
def _fmt_dt(t: Optional[datetime]) -> str:
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
def get_by_sample_code(
db: Session, tenant_no: str, sample_code: str
) -> Optional[BizSampleStorageCert]:
code = (sample_code or "").strip()
if not code:
return None
return (
db.query(BizSampleStorageCert)
.filter(
BizSampleStorageCert.tenant_no == tenant_no,
BizSampleStorageCert.sample_code == code,
BizSampleStorageCert.del_flag == 0,
)
.first()
)
def get_map_by_sample_codes(
db: Session, tenant_no: str, codes: List[str]
) -> Dict[str, BizSampleStorageCert]:
cleaned = list({(c or "").strip() for c in codes if (c or "").strip()})
if not cleaned:
return {}
rows = (
db.query(BizSampleStorageCert)
.filter(
BizSampleStorageCert.tenant_no == tenant_no,
BizSampleStorageCert.sample_code.in_(cleaned),
BizSampleStorageCert.del_flag == 0,
)
.all()
)
return {row.sample_code: row for row in rows}
def upsert_handle(
db: Session,
*,
tenant_no: str,
sample_code: str,
collection_protocol_id: Optional[str],
user_id: Optional[str],
handle_time: Optional[datetime],
handler_user_id: Optional[str],
cert_image_urls: str = "",
remark: str = "",
mail_cert_time: Optional[datetime] = None,
update_mail_cert_time: bool = False,
) -> BizSampleStorageCert:
code = (sample_code or "").strip()
if not code:
raise ValueError("sample_code is required")
payload = apply_handle_payload(
handle_time=handle_time,
handler_user_id=handler_user_id,
attachments=cert_image_urls or "",
remark=remark or "",
)
now = datetime.now()
row = get_by_sample_code(db, tenant_no, code)
if row is None:
row = BizSampleStorageCert(
id=generate_uuid(),
sample_code=code,
collection_protocol_id=(collection_protocol_id or "").strip() or None,
tenant_no=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(row)
else:
if collection_protocol_id and not row.collection_protocol_id:
row.collection_protocol_id = (collection_protocol_id or "").strip() or None
row.update_by = user_id
row.update_time = now
row.handle_time = payload["handle_time"]
row.handler_user_id = payload["handler_user_id"]
row.make_status = payload["make_or_todo_status"]
row.make_date = payload["legacy_date"] or None
row.cert_image_urls = payload["attachments"] or None
row.remark = payload["remark"] or None
if update_mail_cert_time:
row.mail_cert_time = mail_cert_time
db.flush()
return row
def row_to_dict(row: BizSampleStorageCert) -> Dict[str, Any]:
return {
"id": row.id,
"sampleCode": row.sample_code or "",
"collectionProtocolId": row.collection_protocol_id or "",
"makeStatus": row.make_status or "0",
"certImageUrls": row.cert_image_urls or "",
"makeDate": (row.make_date or "").strip() if row.make_date else "",
"handleTime": _fmt_dt(row.handle_time) if getattr(row, "handle_time", None) else "",
"handlerUserId": getattr(row, "handler_user_id", None) or "",
"mailCertTime": _fmt_dt(row.mail_cert_time) if getattr(row, "mail_cert_time", None) else "",
"storageCertHandled": storage_cert_handled(row),
"tenantNo": row.tenant_no,
"orderNum": row.order_num if row.order_num is not None else 0,
"remark": row.remark or "",
"createTime": _fmt_dt(row.create_time),
"updateTime": _fmt_dt(row.update_time),
}
+222
View File
@@ -0,0 +1,222 @@
"""样本储存证质检信息 CRUD。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud import sample_reception as reception_svc
from app.models.models import (
BizCellSampleType,
BizCollectionProtocol,
BizProtocolPartyCustomer,
BizProtocolSample,
BizSampleStorageCertQc,
)
from app.services.sample_storage_cert_qc import (
dumps_payload,
empty_detail,
loads_payload,
normalize_payload,
resolve_sample_type_key,
)
from app.services.sample_storage_cert_qc_prefill import collect_prefill
def _row_to_dict(
row: BizSampleStorageCertQc,
*,
lab_flow_code: str = "",
protocol_code: str = "",
mother_name: str = "",
sample_type_name: str = "",
) -> Dict[str, Any]:
return {
"id": row.id,
"sampleCode": row.sample_code or "",
"labFlowCode": lab_flow_code or "",
"protocolCode": protocol_code or "",
"motherName": mother_name or "",
"sampleTypeName": sample_type_name or "",
"sampleTypeKey": row.sample_type_key,
"collectionProtocolId": row.collection_protocol_id or "",
"qcWorkOrderId": row.qc_work_order_id or "",
"filledFlag": str(row.filled_flag or "0") == "1",
"payload": loads_payload(row.payload),
"createTime": row.create_time.isoformat(sep=" ", timespec="seconds") if row.create_time else "",
"updateTime": row.update_time.isoformat(sep=" ", timespec="seconds") if row.update_time else "",
}
def _load_sample_context(db: Session, tenant_no: str, sample_code: str):
code = (sample_code or "").strip()
if not code:
return None
sample = (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.sample_code == code,
BizProtocolSample.del_flag == 0,
)
.first()
)
if not sample:
return None
type_name = ""
if sample.sample_type_id:
st = (
db.query(BizCellSampleType)
.filter(
BizCellSampleType.id == sample.sample_type_id,
BizCellSampleType.del_flag == 0,
)
.first()
)
if st:
type_name = st.type_name or ""
protocol_code = ""
mother_name = ""
if sample.collection_protocol_id:
proto = (
db.query(BizCollectionProtocol)
.filter(
BizCollectionProtocol.id == sample.collection_protocol_id,
BizCollectionProtocol.tenant_no == tenant_no,
BizCollectionProtocol.del_flag == 0,
)
.first()
)
if proto:
protocol_code = proto.protocol_no or ""
pc = (
db.query(BizProtocolPartyCustomer)
.filter(
BizProtocolPartyCustomer.collection_protocol_id == sample.collection_protocol_id,
BizProtocolPartyCustomer.tenant_no == tenant_no,
BizProtocolPartyCustomer.del_flag == 0,
)
.first()
)
if pc:
mother_name = pc.mother_name or ""
lab_flow_code = ""
sc = (sample.sample_code or "").strip()
if sc:
code_map = reception_svc.get_lab_storage_code_map_by_sample_codes(db, tenant_no, [sc])
lab_flow_code = code_map.get(sc, "") or ""
return sample, type_name, protocol_code, mother_name, lab_flow_code
def _with_prefill(db: Session, tenant_no: str, detail: Dict[str, Any]) -> Dict[str, Any]:
detail["prefill"] = collect_prefill(
db,
tenant_no,
detail.get("sampleCode") or "",
detail.get("sampleTypeKey"),
)
return detail
def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[str, Any]]:
"""样本不存在返回 None;存在但未填返回 empty_detail。prefill 只补空,不写入已存档 payload。"""
ctx = _load_sample_context(db, tenant_no, sample_code)
if not ctx:
return None
sample, type_name, protocol_code, mother_name, lab_flow_code = ctx
key = resolve_sample_type_key(type_name)
row = (
db.query(BizSampleStorageCertQc)
.filter(
BizSampleStorageCertQc.tenant_no == tenant_no,
BizSampleStorageCertQc.sample_code == sample.sample_code,
BizSampleStorageCertQc.del_flag == 0,
)
.first()
)
if not row:
detail = empty_detail(
sample_code=sample.sample_code or "",
lab_flow_code=lab_flow_code,
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
sample_type_key=key,
collection_protocol_id=sample.collection_protocol_id or "",
)
return _with_prefill(db, tenant_no, detail)
detail = _row_to_dict(
row,
lab_flow_code=lab_flow_code,
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
)
return _with_prefill(db, tenant_no, detail)
def upsert(
db: Session,
*,
tenant_no: str,
user_id: str,
sample_code: str,
payload: Any,
qc_work_order_id: Optional[str] = None,
) -> Dict[str, Any]:
ctx = _load_sample_context(db, tenant_no, sample_code)
if not ctx:
raise ValueError("样本不存在")
sample, type_name, protocol_code, mother_name, lab_flow_code = ctx
key = resolve_sample_type_key(type_name)
if not key:
raise ValueError("该样本类型不支持填写储存证质检信息")
clean = normalize_payload(payload)
now = datetime.now()
row = (
db.query(BizSampleStorageCertQc)
.filter(
BizSampleStorageCertQc.tenant_no == tenant_no,
BizSampleStorageCertQc.sample_code == sample.sample_code,
BizSampleStorageCertQc.del_flag == 0,
)
.first()
)
if not row:
row = BizSampleStorageCertQc(
id=generate_uuid(),
sample_code=sample.sample_code,
collection_protocol_id=sample.collection_protocol_id,
qc_work_order_id=(qc_work_order_id or None),
sample_type_key=key,
payload=dumps_payload(clean),
filled_flag="1",
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)
else:
row.collection_protocol_id = sample.collection_protocol_id
row.sample_type_key = key
row.payload = dumps_payload(clean)
row.filled_flag = "1"
if qc_work_order_id:
row.qc_work_order_id = qc_work_order_id
row.update_by = user_id
row.update_time = now
db.commit()
db.refresh(row)
return _row_to_dict(
row,
lab_flow_code=lab_flow_code,
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
)
+199
View File
@@ -0,0 +1,199 @@
"""顺丰月结列表、结算、补填。只处理 transport_mode = sfkd。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.core.data_scope import apply_data_scope
from app.crud.sample_freight import _fmt_dt, _normalize_date_bound, _user_nick_name_map
from app.models.models import BizSampleFreight
from app.services.sf_express_settlement import (
SF_TRANSPORT_MODE,
SETTLE_SETTLED,
assert_settle_request,
freight_amount_label,
plan_fill_amount,
plan_settle,
)
def _base_query(db: Session, *, tenant_no: str, user_info: Optional[dict]):
query = db.query(BizSampleFreight).filter(
BizSampleFreight.tenant_no == tenant_no,
BizSampleFreight.del_flag == 0,
BizSampleFreight.transport_mode == SF_TRANSPORT_MODE,
)
return apply_data_scope(query, BizSampleFreight, user_info, db, tenant_no)
def _apply_filters(
query,
*,
waybill_no: Optional[str],
settle_status: Optional[str],
create_time_from: Optional[str],
create_time_to: Optional[str],
):
waybill = (waybill_no or "").strip()
if waybill:
query = query.filter(BizSampleFreight.waybill_no.like(f"%{waybill}%"))
status = (settle_status or "").strip()
if status:
query = query.filter(BizSampleFreight.settle_status == status)
if create_time_from and str(create_time_from).strip():
query = query.filter(
BizSampleFreight.create_time
>= _normalize_date_bound(str(create_time_from).strip())
)
if create_time_to and str(create_time_to).strip():
query = query.filter(
BizSampleFreight.create_time
<= _normalize_date_bound(str(create_time_to).strip(), end_of_day=True)
)
return query
def _to_row(obj: BizSampleFreight, create_by_name: str) -> Dict[str, Any]:
amount = obj.freight_amount
status = obj.settle_status or ""
return {
"id": obj.id,
"waybillNo": obj.waybill_no or "",
"createBy": obj.create_by or "",
"createByName": create_by_name or obj.create_by or "",
"createTime": _fmt_dt(obj.create_time),
"settleStatus": status,
"settleStatusLabel": "已结算" if status == SETTLE_SETTLED else "未结算",
"freightAmount": float(amount) if amount is not None else None,
"freightAmountLabel": freight_amount_label(obj.transport_mode, amount),
"settleTime": _fmt_dt(obj.settle_time) if obj.settle_time else "",
"transportMode": obj.transport_mode or "",
}
def list_sf_settlements(
db,
*,
tenant_no: str,
user_info: Optional[dict],
page: int = 1,
page_size: int = 10,
waybill_no: Optional[str] = None,
settle_status: Optional[str] = None,
create_time_from: Optional[str] = None,
create_time_to: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
page = max(1, int(page or 1))
page_size = max(1, min(100, int(page_size or 10)))
query = _apply_filters(
_base_query(db, tenant_no=tenant_no, user_info=user_info),
waybill_no=waybill_no,
settle_status=settle_status,
create_time_from=create_time_from,
create_time_to=create_time_to,
)
total = query.count()
rows = (
query.order_by(BizSampleFreight.create_time.desc(), BizSampleFreight.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
names = _user_nick_name_map(db, [row.create_by for row in rows if row.create_by])
return [_to_row(row, names.get(row.create_by or "", "")) for row in rows], total
def list_sf_settlements_for_export(
db,
*,
tenant_no: str,
user_info: Optional[dict],
waybill_no: Optional[str] = None,
settle_status: Optional[str] = None,
create_time_from: Optional[str] = None,
create_time_to: Optional[str] = None,
) -> List[Dict[str, Any]]:
query = _apply_filters(
_base_query(db, tenant_no=tenant_no, user_info=user_info),
waybill_no=waybill_no,
settle_status=settle_status,
create_time_from=create_time_from,
create_time_to=create_time_to,
)
rows = query.order_by(
BizSampleFreight.create_time.desc(), BizSampleFreight.id.desc()
).all()
if not rows:
raise ValueError("没有可导出的数据")
names = _user_nick_name_map(db, [row.create_by for row in rows if row.create_by])
return [_to_row(row, names.get(row.create_by or "", "")) for row in rows]
def _lock_rows(
db, *, tenant_no: str, user_info: Optional[dict], ids: List[str]
) -> List[BizSampleFreight]:
wanted = [str(item or "").strip() for item in ids if str(item or "").strip()]
rows = (
_base_query(db, tenant_no=tenant_no, user_info=user_info)
.filter(BizSampleFreight.id.in_(wanted))
.all()
)
if len(rows) != len(set(wanted)):
raise ValueError("运费批次不存在")
return rows
def settle_sf_freights(
db,
*,
tenant_no: str,
user_id: Optional[str],
user_info: Optional[dict],
ids: List[str],
freight_amount: Any = None,
):
assert_settle_request(ids, freight_amount)
rows = _lock_rows(db, tenant_no=tenant_no, user_info=user_info, ids=ids)
now = datetime.now()
submitted = None if len(ids) != 1 else freight_amount
for obj in rows:
if obj.transport_mode != SF_TRANSPORT_MODE:
raise ValueError("只能结算顺丰快递运费")
if (obj.settle_status or "") == SETTLE_SETTLED:
raise ValueError("该运费已结算")
amount, status, settle_time = plan_settle(
current_amount=obj.freight_amount, submitted_amount=submitted, now=now
)
obj.freight_amount = amount
obj.settle_status = status
obj.settle_time = settle_time
obj.update_by = user_id
obj.update_time = now
db.commit()
return {"ids": [row.id for row in rows]}
def fill_sf_freight_amount(
db,
*,
tenant_no: str,
user_id: Optional[str],
user_info: Optional[dict],
freight_id: str,
freight_amount: Any,
):
rows = _lock_rows(db, tenant_no=tenant_no, user_info=user_info, ids=[freight_id])
obj = rows[0]
if obj.transport_mode != SF_TRANSPORT_MODE:
raise ValueError("只能补填顺丰快递运费")
obj.freight_amount = plan_fill_amount(
current_amount=obj.freight_amount,
settle_status=obj.settle_status,
submitted_amount=freight_amount,
)
obj.update_by = user_id
obj.update_time = datetime.now()
db.commit()
return {"id": obj.id}
+24
View File
@@ -741,6 +741,30 @@ def _search_slot_select_matched_ids(
return matched_ids
def search_slot_ids_by_keyword(
db: Session,
tenant_no: str,
keyword: str,
*,
max_results: int = SLOT_SELECT_KEYWORD_SCAN_LIMIT,
) -> List[str]:
"""按路径/格位编码关键词解析储物格 ID(供细胞产品列表等跨实体检索复用)。"""
kw = (keyword or "").strip()
if not kw or max_results <= 0:
return []
base = db.query(BizStorageFacilitySlot).filter(
BizStorageFacilitySlot.del_flag == 0,
BizStorageFacilitySlot.tenant_no == tenant_no,
)
return _search_slot_select_matched_ids(
db,
base,
tenant_no,
kw,
max_results=max_results,
)
def _apply_slot_select_query_filters(
db: Session,
query,
+49 -70
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_link_rows
from app.crud.base import CRUDBase
from app.models.models import SysUser, SysUserRole, SysUserPost, SysDept
@@ -156,13 +157,19 @@ def get_user(db: Session, user_id: str) -> Optional[SysUser]:
)
def list_post_ids_for_user(db: Session, user_id: str) -> List[str]:
def list_post_ids_for_user(
db: Session, user_id: str, tenant_no: Optional[str] = None
) -> List[str]:
"""用户当前有效职位 ID 列表。"""
query = db.query(SysUserPost).filter(
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
)
tenant = (tenant_no or "").strip()
if tenant:
query = query.filter(SysUserPost.tenant_no == tenant)
return [
r.post_id
for r in db.query(SysUserPost)
.filter(and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0))
.all()
for r in query.all()
]
@@ -173,29 +180,19 @@ def set_user_post_ids_only(
post_ids: List[str],
updater_id: Optional[str],
) -> None:
"""仅替换 sys_user_post,不影响 sys_user_role。"""
now = datetime.now()
uniq = [p for p in dict.fromkeys(post_ids or []) if p]
db.query(SysUserPost).filter(
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
for pid in uniq:
db.add(
SysUserPost(
id=generate_uuid(),
user_id=user_id,
post_id=pid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=updater_id,
create_dept_id="",
create_time=now,
update_by=updater_id,
update_time=now,
del_flag=0,
)
)
"""按职位更新 sys_user_post,不影响 sys_user_role。"""
sync_link_rows(
db,
SysUserPost,
parent_attr="user_id",
parent_id=user_id,
link_attr="post_id",
link_ids=post_ids,
tenant_no=tenant_no,
user_id=updater_id,
duplicate_message="职位重复",
foreign_message="职位关联不属于当前用户",
)
def list_user_ids_bound_to_post(db: Session, post_id: str, tenant_no: str) -> List[str]:
@@ -508,51 +505,33 @@ def _replace_associations(
role_ids: Optional[List[str]] = None,
post_ids: Optional[List[str]] = None,
) -> None:
"""全量替换关联。仅当对应参数不为 None 时才替换该侧(避免只改职位时清空直接角色)。"""
now = datetime.now()
"""按关联 id 更新。仅当对应参数不为 None 时才替换该侧(避免只改职位时清空直接角色)。"""
if role_ids is not None:
db.query(SysUserRole).filter(
and_(SysUserRole.user_id == user_id, SysUserRole.del_flag == 0)
).update({"del_flag": 1, "update_time": now})
for rid in role_ids:
if not rid:
continue
db.add(SysUserRole(
id=generate_uuid(),
user_id=user_id,
role_id=rid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=creator_id,
create_dept_id="",
create_time=now,
update_by=creator_id,
update_time=now,
del_flag=0,
))
sync_link_rows(
db,
SysUserRole,
parent_attr="user_id",
parent_id=user_id,
link_attr="role_id",
link_ids=role_ids,
tenant_no=tenant_no,
user_id=creator_id,
duplicate_message="角色重复",
foreign_message="角色关联不属于当前用户",
)
if post_ids is not None:
db.query(SysUserPost).filter(
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
).update({"del_flag": 1, "update_time": now})
for pid in post_ids:
if not pid:
continue
db.add(SysUserPost(
id=generate_uuid(),
user_id=user_id,
post_id=pid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=creator_id,
create_dept_id="",
create_time=now,
update_by=creator_id,
update_time=now,
del_flag=0,
))
sync_link_rows(
db,
SysUserPost,
parent_attr="user_id",
parent_id=user_id,
link_attr="post_id",
link_ids=post_ids,
tenant_no=tenant_no,
user_id=creator_id,
duplicate_message="职位重复",
foreign_message="职位关联不属于当前用户",
)
def _parse_hire_date(value: Optional[Union[str, date]]) -> Optional[date]:
+40
View File
@@ -0,0 +1,40 @@
"""历史单据上的人员姓名。
用户管理里删除人员只会把 sys_user.del_flag 置为 1,账号不能再登录,
也不能再被选为新的负责人。已经写在工单、步骤、协议上的 user_id
仍要显示 nick_name;查不到人时调用方再回退成 id。
选人、校验、登录不要用本模块。
"""
from __future__ import annotations
from typing import Dict, Iterable, List
from sqlalchemy.orm import Session
from app.models.models import SysUser
def user_display_name_map(db: Session, user_ids: Iterable[str]) -> Dict[str, str]:
"""按 user_id 取展示名,包含已删除用户。优先昵称,否则账号。"""
ids: List[str] = []
seen = set()
for raw in user_ids or []:
uid = str(raw or "").strip()
if not uid or uid in seen:
continue
seen.add(uid)
ids.append(uid)
if not ids:
return {}
rows = (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(ids))
.all()
)
out: Dict[str, str] = {}
for row in rows:
name = (row.nick_name or "").strip() or (row.user_no or "").strip()
if name:
out[row.id] = name
return out
+15 -4
View File
@@ -13,7 +13,7 @@ from app.core.database import SessionLocal
from app.core.api_request_log_middleware import ApiRequestLogMiddleware
from app.core.api_crypto_middleware import ApiCryptoMiddleware
from app.models import models # noqa: F401
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router
from app.api import public_router, menu_router, admin_router, 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, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, 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
@asynccontextmanager
@@ -56,8 +56,9 @@ app = FastAPI(
title=settings.APP_NAME,
description="zkxw 管理端骨架后端(鉴权与菜单)",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
docs_url="/docs" if settings.DEBUG else None,
redoc_url="/redoc" if settings.DEBUG else None,
openapi_url="/openapi.json" if settings.DEBUG else None,
lifespan=lifespan,
)
@@ -116,6 +117,10 @@ app.include_router(admin_router, prefix=settings.API_V1_PREFIX)
app.include_router(workflow_router, prefix=settings.API_V1_PREFIX)
app.include_router(basic_info_router, prefix=settings.API_V1_PREFIX)
app.include_router(collection_protocol_router, prefix=settings.API_V1_PREFIX)
app.include_router(protocol_sample_payment_router, prefix=settings.API_V1_PREFIX)
app.include_router(protocol_refund_record_router, prefix=settings.API_V1_PREFIX)
app.include_router(presign_order_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_freight_router, prefix=settings.API_V1_PREFIX)
app.include_router(leave_router, prefix=settings.API_V1_PREFIX)
app.include_router(operations_router, prefix=settings.API_V1_PREFIX)
app.include_router(ethnic_router, prefix=settings.API_V1_PREFIX)
@@ -147,6 +152,11 @@ app.include_router(pre_transit_sample_router, prefix=settings.API_V1_PREFIX)
app.include_router(oa_approval_router, prefix=settings.API_V1_PREFIX)
app.include_router(mini_auth_router, prefix=settings.API_V1_PREFIX)
app.include_router(mini_menu_router, prefix=settings.API_V1_PREFIX)
app.include_router(market_mobile_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_storage_cert_qc_router, prefix=settings.API_V1_PREFIX)
app.include_router(customer_service_dict_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_cert_insurance_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_code_correction_router, prefix=settings.API_V1_PREFIX)
def _mask_secret(value: str) -> str:
@@ -172,7 +182,8 @@ logger.info(_api_crypto_boot_msg)
@app.get("/")
def root():
return {"name": settings.APP_NAME, "version": "1.0.0", "docs": "/docs"}
docs = "/docs" if settings.DEBUG else None
return {"name": settings.APP_NAME, "version": "1.0.0", "docs": docs}
@app.get("/health")
+4
View File
@@ -49,6 +49,8 @@ from app.models.models import (
BizProductionTaskStepDataCollection,
BizProductionTaskOutput,
BizStorageSlotOccupancy,
BizSampleFreight,
BizSampleFreightItem,
)
from app.models.production_material import ( # noqa: F401
BizProductionMaterial,
@@ -123,6 +125,8 @@ __all__ = [
"BizProductionTaskStepDataCollection",
"BizProductionTaskOutput",
"BizStorageSlotOccupancy",
"BizSampleFreight",
"BizSampleFreightItem",
"BizProductionMaterial",
"BizProductionMaterialStockBatch",
"BizProductionMaterialUsage",
+471 -2
View File
@@ -2,7 +2,7 @@
from datetime import datetime
import enum
from sqlalchemy import Column, String, DateTime, Date, Integer, Text, CHAR, SmallInteger, Numeric
from sqlalchemy import Column, String, DateTime, Date, Integer, Text, CHAR, SmallInteger, Numeric, UniqueConstraint
from app.core.database import Base
@@ -334,6 +334,10 @@ class BizCustomer(Base):
sex = Column(CHAR(1), nullable=True, default="2", comment="性别(0男1女2未知)")
id_card_no = Column(String(32), nullable=True, comment="身份证号")
age = Column(Integer, nullable=True, comment="年龄")
birth_ym = Column(CHAR(6), nullable=True, comment="出生年月YYYYMM")
ethnic = Column(String(32), nullable=True, comment="民族")
email = Column(String(128), nullable=True, comment="邮箱")
address = Column(String(512), nullable=True, comment="家庭住址")
phone_no = Column(String(32), nullable=True, comment="联系电话")
customer_tags = Column(String(512), nullable=True, comment="客户标签,逗号分隔")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
@@ -1016,6 +1020,7 @@ class OaInstanceViewGrant(Base):
source = Column(String(32), nullable=False, default="comment_mention", comment="comment_mention")
comment_id = Column(String(64), nullable=True, comment="来源评论ID,撤回评论不删除授权")
granted_by = Column(String(64), nullable=True, comment="授权人")
is_read = Column(SmallInteger, nullable=False, default=0, comment="是否已读 0未读 1已读")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
@@ -1056,6 +1061,13 @@ class BizCollectionProtocol(Base):
"""采集协议单 biz_collection_protocol"""
__tablename__ = "biz_collection_protocol"
__table_args__ = (
UniqueConstraint(
"tenant_no",
"source_presign_order_id",
name="uk_collection_protocol_presign_source",
),
)
id = Column(String(64), primary_key=True, comment="主键ID")
protocol_no_id = Column(String(64), nullable=True, comment="采集协议号表ID")
@@ -1075,6 +1087,9 @@ class BizCollectionProtocol(Base):
to_research_time = Column(DateTime, nullable=True, comment="协议转科研时间")
to_research_desc = Column(String(512), nullable=True, comment="协议转科研描述")
collector_user_id = Column(String(64), nullable=True, comment="采集专员用户ID")
protocol_total_amount = Column(Numeric(18, 2), nullable=True, comment="手填协议总金额")
source_presign_order_id = Column(String(64), nullable=True, comment="来源预签约订单ID")
payment_model_version = Column(SmallInteger, nullable=False, default=2, comment="缴费模型版本 1旧/2新")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注信息")
@@ -1107,6 +1122,31 @@ class BizProtocolAudit(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizProtocolStatusChange(Base):
"""采集协议单-状态变更记录 biz_protocol_status_change"""
__tablename__ = "biz_protocol_status_change"
id = Column(String(64), primary_key=True, comment="主键ID")
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
change_type = Column(String(32), nullable=False, comment="变更类型 activate/expire/void/to_research")
from_status = Column(String(16), nullable=True, comment="变更前状态码")
to_status = Column(String(16), nullable=False, comment="变更后状态码")
change_time = Column(DateTime, nullable=False, comment="业务变更时间")
change_desc = Column(String(512), nullable=True, comment="变更描述")
void_type = Column(String(32), nullable=True, comment="作废类型(字典 xyzflx)")
operator_user_id = Column(String(64), nullable=True, comment="操作人用户ID")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizStorageReleaseReview(Base):
"""入库放行审核 biz_storage_release_review"""
@@ -1211,6 +1251,184 @@ class BizProtocolPaymentDetail(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizProtocolSamplePayment(Base):
"""采集协议单-按样本缴费方案 biz_protocol_sample_payment"""
__tablename__ = "biz_protocol_sample_payment"
id = Column(String(64), primary_key=True, comment="主键ID")
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
protocol_sample_id = Column(String(64), nullable=True, comment="协议样本ID(可空兜底)")
sample_code = Column(String(32), nullable=False, comment="样本编码(业务关联键)")
pay_mode = Column(String(8), nullable=False, comment="dj趸交 fq分期")
contract_amount = Column(Numeric(18, 2), nullable=True, comment="该样本合同金额")
installment_count = Column(Integer, nullable=True, comment="分期期数")
installment_interval_months = Column(Numeric(10, 2), nullable=True, comment="每期间隔(月)")
installment_start_date = Column(CHAR(8), nullable=True, comment="分期开始YYYYMMDD")
installment_advance_days = Column(Integer, nullable=True, comment="第2期起应缴费提前天数")
installment_ratios = Column(String(256), nullable=True, comment="各期缴费比例JSON数组")
lump_sum_deadline = Column(CHAR(8), nullable=True, comment="趸交截止时间YYYYMMDD")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizProtocolPaymentSchedule(Base):
"""采集协议单-应缴批次 biz_protocol_payment_schedule"""
__tablename__ = "biz_protocol_payment_schedule"
id = Column(String(64), primary_key=True, comment="主键ID")
sample_payment_id = Column(String(64), nullable=False, comment="样本缴费方案ID")
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
sample_code = Column(String(32), nullable=False, comment="样本编码")
schedule_no = Column(Integer, nullable=True, comment="期次序号")
due_amount = Column(Numeric(18, 2), nullable=True, comment="应缴金额")
due_date = Column(CHAR(8), nullable=True, comment="应缴日期YYYYMMDD")
schedule_status = Column(String(16), nullable=True, comment="批次状态 open/cleared/void")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizProtocolPaymentTransfer(Base):
"""采集协议单-缴费转账笔 biz_protocol_payment_transfer"""
__tablename__ = "biz_protocol_payment_transfer"
id = Column(String(64), primary_key=True, comment="主键ID")
schedule_id = Column(String(64), nullable=False, comment="应缴批次ID")
sample_payment_id = Column(String(64), nullable=False, comment="样本缴费方案ID")
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
sample_code = Column(String(32), nullable=False, comment="样本编码")
transfer_amount = Column(Numeric(18, 2), nullable=True, comment="转账金额")
transfer_time = Column(DateTime, nullable=True, comment="转账时间")
transfer_type = Column(String(16), nullable=True, comment="转账方式")
transfer_attachments = Column(Text, nullable=True, comment="转账附件")
pay_status = Column(String(32), nullable=False, default="dfj", comment="缴费状态 dfj/jfqrz/yjf")
finance_confirm_attachments = Column(Text, nullable=True, comment="缴费财务确认附件")
finance_remark = Column(String(512), nullable=True, comment="缴费财务确认备注")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizProtocolRefundRecord(Base):
"""采集协议单-退费登记 biz_protocol_refund_record"""
__tablename__ = "biz_protocol_refund_record"
id = Column(String(64), primary_key=True, comment="主键ID")
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
refund_amount = Column(Numeric(18, 2), nullable=True, comment="退费金额")
refund_time = Column(DateTime, nullable=True, comment="退费时间")
refund_remark = Column(String(512), nullable=True, comment="退费备注")
refund_attachments = Column(Text, nullable=True, comment="退费附件")
workflow_instance_id = Column(String(64), nullable=True, comment="OA工作流实例ID")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizPresignOrder(Base):
"""预签约订单 biz_presign_order"""
__tablename__ = "biz_presign_order"
id = Column(String(64), primary_key=True, comment="主键ID")
order_date = Column(CHAR(8), nullable=True, comment="业务日期YYYYMMDD")
protocol_no = Column(String(64), nullable=True, comment="协议编号")
mother_name = Column(String(64), nullable=True, comment="母亲姓名")
expected_delivery_date = Column(CHAR(8), nullable=True, comment="预产期YYYYMMDD")
region_id = Column(String(64), nullable=True, comment="采集地区ID")
hospital_id = Column(String(64), nullable=True, comment="医院ID")
sample_collect_time = Column(DateTime, nullable=True, comment="采集时间")
pay_mode = Column(String(8), nullable=True, comment="整单缴费方式 dj/fq")
contract_amount = Column(Numeric(18, 2), nullable=True, comment="整单合同金额")
collector_user_id = Column(String(64), nullable=True, comment="采集专员用户ID")
sales_user_id = Column(String(64), nullable=True, comment="市场专员用户ID")
region_manager_user_id = Column(String(64), nullable=True, comment="采集经理/地区经理用户ID")
order_status = Column(String(16), nullable=True, comment="订单状态 draft/active/converted/void")
converted_protocol_id = Column(String(64), nullable=True, comment="转成的正式协议ID")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizPresignOrderSampleType(Base):
"""预签约订单-样本类型 biz_presign_order_sample_type"""
__tablename__ = "biz_presign_order_sample_type"
id = Column(String(64), primary_key=True, comment="主键ID")
presign_order_id = Column(String(64), nullable=False, comment="预签约订单ID")
sample_type_id = Column(String(64), nullable=False, comment="样本类型ID")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizPresignTransfer(Base):
"""预签约订单-意向金转账笔 biz_presign_transfer"""
__tablename__ = "biz_presign_transfer"
id = Column(String(64), primary_key=True, comment="主键ID")
presign_order_id = Column(String(64), nullable=False, comment="预签约订单ID")
transfer_amount = Column(Numeric(18, 2), nullable=True, comment="转账金额")
transfer_time = Column(DateTime, nullable=True, comment="转账时间")
transfer_type = Column(String(16), nullable=True, comment="转账方式")
transfer_attachments = Column(Text, nullable=True, comment="转账附件")
pay_status = Column(String(32), nullable=False, default="dfj", comment="缴费状态 dfj/jfqrz/yjf")
finance_confirm_attachments = Column(Text, nullable=True, comment="缴费财务确认附件")
finance_remark = Column(String(512), nullable=True, comment="缴费财务确认备注")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizProtocolInvoiceRequest(Base):
"""采集协议单开票申请主表 biz_protocol_invoice_request"""
@@ -1271,6 +1489,8 @@ class BizProtocolPartyCustomer(Base):
id = Column(String(64), primary_key=True, comment="主键ID")
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
customer_id = Column(String(64), nullable=True, comment="客户ID")
father_customer_id = Column(String(64), nullable=True, comment="父亲客户ID")
newborn_customer_id = Column(String(64), nullable=True, comment="子女客户ID")
mother_name = Column(String(100), nullable=True, comment="母亲姓名")
mother_phone = Column(String(32), nullable=True, comment="母亲电话")
mother_id_card = Column(String(32), nullable=True, comment="母亲身份证号")
@@ -1313,7 +1533,8 @@ class BizProtocolCollectionInfo(Base):
region_id = Column(String(64), nullable=True, comment="区域ID")
hospital_id = Column(String(64), nullable=True, comment="医院ID")
medical_staff = Column(String(200), nullable=True, comment="医护人员")
sales_user_id = Column(String(64), nullable=True, comment="销售人员用户ID")
sales_user_id = Column(String(64), nullable=True, comment="市场专员用户ID")
region_manager_user_id = Column(String(64), nullable=True, comment="地区经理用户ID")
sample_collect_time = Column(DateTime, nullable=True, comment="样本采集时间")
maternal_blood_collect_time = Column(DateTime, nullable=True, comment="母血采集时间")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
@@ -1388,6 +1609,51 @@ class BizProtocolSampleTransport(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizSampleFreight(Base):
"""样本运费批次 biz_sample_freight"""
__tablename__ = "biz_sample_freight"
id = Column(String(64), primary_key=True, comment="主键ID")
freight_no = Column(String(32), nullable=False, default="", comment="运费批次号 YF+YYYYMMDD+序号")
transport_mode = Column(String(100), nullable=False, default="", comment="运输方式字典值")
freight_amount = Column(Numeric(18, 2), nullable=True, comment="运费(元),顺丰月结未填时为空")
waybill_no = Column(String(64), nullable=True, comment="顺丰单号")
settle_status = Column(String(8), nullable=True, comment="结算状态 wjs/yjs")
settle_time = Column(DateTime, nullable=True, comment="结算时间")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizSampleFreightItem(Base):
"""样本运费关联 biz_sample_freight_item"""
__tablename__ = "biz_sample_freight_item"
id = Column(String(64), primary_key=True, comment="主键ID")
freight_id = Column(String(64), nullable=False, comment="运费批次ID")
sample_source = Column(String(32), nullable=False, comment="cell_storage|research_donation")
sample_id = Column(String(64), nullable=False, comment="样本ID")
sample_code = Column(String(32), nullable=True, comment="样本编码快照")
protocol_id = Column(String(64), nullable=True, comment="协议单ID")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizAttachment(Base):
"""业务附件 biz_attachment"""
@@ -1422,6 +1688,126 @@ class BizProtocolStorageCert(Base):
make_status = Column(CHAR(1), nullable=True, default="0", comment="制作状态 0-未制作 1-已制作")
cert_image_urls = Column(String(4000), nullable=True, comment="证书图片URLs")
make_date = Column(CHAR(8), nullable=True, comment="制作日期YYYYMMDD")
handle_time = Column(DateTime, nullable=True, comment="办理时间")
handler_user_id = Column(String(64), nullable=True, comment="办理人用户ID")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizSampleStorageCert(Base):
"""样本储存证办理 biz_sample_storage_cert"""
__tablename__ = "biz_sample_storage_cert"
id = Column(String(64), primary_key=True, comment="主键ID")
sample_code = Column(String(32), nullable=False, comment="样本编码")
collection_protocol_id = Column(String(64), nullable=True, comment="采集协议单ID冗余")
make_status = Column(CHAR(1), nullable=True, default="0", comment="制作状态 0-未制作 1-已制作")
cert_image_urls = Column(String(4000), nullable=True, comment="证书图片URLs")
make_date = Column(CHAR(8), nullable=True, comment="制作日期YYYYMMDD")
handle_time = Column(DateTime, nullable=True, comment="办理时间")
handler_user_id = Column(String(64), nullable=True, comment="办理人用户ID")
mail_cert_time = Column(DateTime, nullable=True, comment="邮寄储存证时间")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizSampleStorageCertQc(Base):
"""样本储存证质检信息 biz_sample_storage_cert_qc"""
__tablename__ = "biz_sample_storage_cert_qc"
id = Column(String(64), primary_key=True, comment="主键ID")
sample_code = Column(String(32), nullable=False, comment="样本编码")
collection_protocol_id = Column(String(64), nullable=True, comment="采集协议单ID冗余")
qc_work_order_id = Column(String(64), nullable=True, comment="最近填写来源工单ID")
sample_type_key = Column(String(32), nullable=False, comment="perinatal_hsc|uc_msc")
payload = Column(Text, nullable=False, comment="业务字段 JSON")
filled_flag = Column(CHAR(1), nullable=False, default="0", comment="是否已保存过 0否1是")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizSampleCodeCorrectionLog(Base):
"""样本编码纠错审计日志 biz_sample_code_correction_log"""
__tablename__ = "biz_sample_code_correction_log"
id = Column(String(64), primary_key=True, comment="主键ID")
reception_source = Column(String(32), nullable=False, comment="来源 cell_storage/research_donation")
old_sample_code = Column(String(32), nullable=False, comment="旧样本编码")
new_sample_code = Column(String(32), nullable=False, comment="新样本编码")
impact_json = Column(Text, nullable=True, comment="各表影响行数 JSON")
reason = Column(String(512), nullable=True, comment="更正原因")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizSampleCustomerService(Base):
"""样本客户服务扩展 biz_sample_customer_service"""
__tablename__ = "biz_sample_customer_service"
id = Column(String(64), primary_key=True, comment="主键ID")
sample_code = Column(String(32), nullable=False, comment="样本编码")
collection_protocol_id = Column(String(64), nullable=True, comment="采集协议单ID冗余")
sms_send_date = Column(Date, nullable=True, comment="短信发送日期")
sms_send_status = Column(String(64), nullable=True, comment="短信发送情况")
microbe_followup_date = Column(Date, nullable=True, comment="微生物回访日期")
microbe_followup_status = Column(String(64), nullable=True, comment="微生物回访情况")
fullmoon_followup_date = Column(Date, nullable=True, comment="满月回访时间")
fullmoon_followup_status = Column(String(64), nullable=True, comment="满月回访情况")
household_registered = Column(String(64), nullable=True, comment="是否上户口")
einvoice_send_time = Column(DateTime, nullable=True, comment="电子发票发送时间")
effective_address = Column(String(512), nullable=True, comment="有效地址")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizCustomerServiceDict(Base):
"""客户服务字典 biz_customer_service_dict"""
__tablename__ = "biz_customer_service_dict"
id = Column(String(64), primary_key=True, comment="主键ID")
dict_type = Column(String(64), nullable=False, comment="字典类型")
dict_value = Column(String(128), nullable=False, comment="选项值")
is_system = Column(CHAR(1), nullable=False, default="0", comment="1预置 0手输")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
@@ -2146,6 +2532,25 @@ class BizCellProductSpec(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizCellProductPackage(Base):
"""细胞产品包装 biz_cell_product_package"""
__tablename__ = "biz_cell_product_package"
id = Column(String(64), primary_key=True, comment="主键ID")
package_code = Column(String(64), nullable=False, comment="包装编码")
package_name = Column(String(200), nullable=False, comment="包装名称")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizCellProductPrice(Base):
"""细胞产品价格体系 biz_cell_product_price"""
@@ -3439,6 +3844,8 @@ class BizProtocolFullmoonTodo(Base):
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
todo_status = Column(CHAR(1), nullable=False, default="0", comment="1已办保险 0未办保险")
handle_date = Column(CHAR(8), nullable=True, comment="办理日期")
handle_time = Column(DateTime, nullable=True, comment="办理时间")
handler_user_id = Column(String(64), nullable=True, comment="办理人用户ID")
handle_remark = Column(String(512), nullable=True, comment="办理备注")
insurance_attachments = Column(String(4000), nullable=True, comment="保险附件逗号分隔")
insurance_company_id = Column(String(64), nullable=True, comment="保险公司ID")
@@ -3460,6 +3867,40 @@ class BizProtocolFullmoonTodo(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizSampleInsurance(Base):
"""样本保险办理 biz_sample_insurance"""
__tablename__ = "biz_sample_insurance"
id = Column(String(64), primary_key=True, comment="主键ID")
sample_code = Column(String(32), nullable=False, comment="样本编码")
collection_protocol_id = Column(String(64), nullable=True, comment="采集协议单ID冗余")
todo_status = Column(CHAR(1), nullable=False, default="0", comment="1已办保险 0未办保险")
handle_date = Column(CHAR(8), nullable=True, comment="办理日期")
handle_time = Column(DateTime, nullable=True, comment="办理时间")
handler_user_id = Column(String(64), nullable=True, comment="办理人用户ID")
handle_remark = Column(String(512), nullable=True, comment="办理备注")
insurance_attachments = Column(String(4000), nullable=True, comment="保险附件逗号分隔")
insurance_company_id = Column(String(64), nullable=True, comment="保险公司ID")
insurance_company_name = Column(String(200), nullable=True, comment="保险公司名称(冗余快照)")
insurance_type = Column(String(200), nullable=True, comment="保险险种")
insurance_policy_no = Column(String(100), nullable=True, comment="保险单号")
insurance_effective_date = Column(CHAR(8), nullable=True, comment="保险单生效时间YYYYMMDD")
insurance_expiry_date = Column(CHAR(8), nullable=True, comment="保险单失效时间YYYYMMDD")
insurance_subscribed = Column(CHAR(1), nullable=True, default="0", comment="是否已订购保险")
insurance_fee = Column(Numeric(18, 2), nullable=True, comment="保单费用(元)")
policy_send_time = Column(DateTime, nullable=True, comment="保单发送时间")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizInsuranceCompany(Base):
"""保险公司 biz_insurance_company"""
@@ -3681,6 +4122,8 @@ class BizPilotInfusionSchedule(Base):
infusion_plan_id = Column(String(64), nullable=False, comment="输注方案ID")
cell_type = Column(String(200), nullable=True, comment="细胞类型")
spec_text = Column(String(200), nullable=True, comment="规格")
package_id = Column(String(64), nullable=True, comment="产品包装ID")
package_text = Column(String(200), nullable=True, comment="产品包装名称(冗余展示)")
infusion_time = Column(DateTime, nullable=True, comment="输注时间")
infusion_location = Column(String(200), nullable=True, comment="输注地点")
infusion_method = Column(String(32), nullable=True, comment="输注方式(字典szfs)")
@@ -3713,6 +4156,8 @@ class BizPilotInfusionScheduleProduct(Base):
project_name = Column(String(200), nullable=True, comment="项目名称")
cell_type_name = Column(String(200), nullable=True, comment="细胞类型")
spec_text = Column(String(200), nullable=True, comment="规格")
package_id = Column(String(64), nullable=True, comment="产品包装ID")
package_text = Column(String(200), nullable=True, comment="产品包装名称(冗余展示)")
agreement_price = Column(Numeric(18, 2), nullable=True, comment="协议单价(元)")
actual_price = Column(Numeric(18, 2), nullable=True, comment="实际单价(元)")
quantity = Column(Integer, nullable=True, default=1, comment="份数")
@@ -3728,6 +4173,30 @@ class BizPilotInfusionScheduleProduct(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizWxNotifyLog(Base):
"""微信服务号通知发送日志(幂等) biz_wx_notify_log"""
__tablename__ = "biz_wx_notify_log"
id = Column(String(64), primary_key=True, comment="主键ID")
biz_type = Column(String(64), nullable=False, comment="业务类型,如 basic_experiment_delivery_remind")
biz_id = Column(String(64), nullable=False, comment="业务主键(如交付计划ID)")
notify_date = Column(String(10), nullable=False, comment="提醒日 YYYY-MM-DD")
user_id = Column(String(64), nullable=True, comment="接收用户ID")
status = Column(String(16), nullable=False, default="success", comment="success/fail")
errcode = Column(String(64), nullable=True, comment="微信/内部错误码")
errmsg = Column(String(512), nullable=True, comment="错误信息")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizPilotCheckup(Base):
"""中试申请单-体检信息 biz_pilot_checkup"""
+31 -2
View File
@@ -13,6 +13,7 @@ class BizResearchDonation(Base):
id = Column(String(64), primary_key=True, comment="主键ID")
donation_no = Column(String(64), nullable=False, comment="捐赠协议编号")
monthly_verify_no = Column(String(16), nullable=True, comment="当月核量号 YYYYMM+3位序号")
donation_status = Column(String(16), nullable=False, default="dsx", comment="状态 dsx/sxz/ygq/yzf/sdfq/zckc")
donation_date = Column(String(8), nullable=True, comment="捐赠日期YYYYMMDD")
source_type = Column(String(20), nullable=False, default="direct", comment="来源类型 direct/from_storage")
@@ -64,6 +65,31 @@ class BizResearchDonationAudit(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizDonationStatusChange(Base):
"""科研捐赠-状态变更记录 biz_donation_status_change"""
__tablename__ = "biz_donation_status_change"
id = Column(String(64), primary_key=True, comment="主键ID")
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠单ID")
change_type = Column(String(32), nullable=False, comment="变更类型 activate/expire/void/to_storage")
from_status = Column(String(16), nullable=True, comment="变更前状态码")
to_status = Column(String(16), nullable=False, comment="变更后状态码")
change_time = Column(DateTime, nullable=False, comment="业务变更时间")
change_desc = Column(String(512), nullable=True, comment="变更描述")
void_type = Column(String(32), nullable=True, comment="作废类型(字典 xyzflx)")
operator_user_id = Column(String(64), nullable=True, comment="操作人用户ID")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizResearchDonationParty(Base):
"""科研捐赠当事人信息 biz_research_donation_party"""
@@ -71,7 +97,9 @@ class BizResearchDonationParty(Base):
id = Column(String(64), primary_key=True, comment="主键ID")
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠ID")
customer_id = Column(String(64), nullable=True, comment="客户ID")
customer_id = Column(String(64), nullable=True, comment="母亲客户ID")
father_customer_id = Column(String(64), nullable=True, comment="父亲客户ID")
newborn_customer_id = Column(String(64), nullable=True, comment="子女客户ID")
mother_name = Column(String(100), nullable=True, comment="母亲姓名")
mother_phone = Column(String(32), nullable=True, comment="母亲电话")
mother_id_card = Column(String(32), nullable=True, comment="母亲身份证号")
@@ -114,7 +142,8 @@ class BizResearchDonationCollection(Base):
region_id = Column(String(64), nullable=True, comment="区域ID")
hospital_id = Column(String(64), nullable=True, comment="医院ID")
medical_staff = Column(String(200), nullable=True, comment="医护人员")
sales_user_id = Column(String(64), nullable=True, comment="销售人员用户ID")
sales_user_id = Column(String(64), nullable=True, comment="市场专员用户ID")
region_manager_user_id = Column(String(64), nullable=True, comment="地区经理用户ID")
sample_collect_time = Column(DateTime, nullable=True, comment="样本采集时间")
maternal_blood_collect_time = Column(DateTime, nullable=True, comment="母血采集时间")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
+575
View File
@@ -0,0 +1,575 @@
"""移动市场接口的白名单请求 DTO。"""
from __future__ import annotations
import re
from datetime import datetime
from decimal import Decimal
from typing import List, Literal, Optional
from pydantic import (
AliasChoices,
BaseModel,
ConfigDict,
Field,
condecimal,
constr,
field_validator,
)
MarketPaymentAmount = condecimal(
gt=Decimal('0'),
max_digits=18,
decimal_places=2,
allow_inf_nan=False,
)
MarketContractAmount = condecimal(
ge=Decimal('0'),
max_digits=18,
decimal_places=2,
allow_inf_nan=False,
)
MarketSampleTypeId = constr(min_length=1, max_length=64)
_CUSTOMER_INT_LABELS = {
'birth_order': '胎次',
'newborn_weight_gram': '新生儿体重',
}
def _blank_to_none(value):
"""移动端数字框清空后会提交 "",可选数字应视为未填。"""
if isinstance(value, str):
text = value.strip()
return None if text == '' else text
return value
def _optional_customer_int(value, field_name: str):
if value is None or isinstance(value, bool):
return value
if isinstance(value, int):
return value
label = _CUSTOMER_INT_LABELS[field_name]
if isinstance(value, float):
if value.is_integer():
return int(value)
raise ValueError(f'{label}须为整数')
if isinstance(value, str):
text = value.strip().replace(',', '')
if text == '':
return None
if re.fullmatch(r'[+-]?\d+\.?', text):
return int(text.rstrip('.'))
if re.fullmatch(r'[+-]?\d+\.\d+', text):
number = Decimal(text)
if number == number.to_integral_value():
return int(number)
raise ValueError(f'{label}须为整数')
return value
class MarketAttachmentDownloadBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
file_url: str = Field(..., min_length=1, max_length=1000, alias='fileUrl')
file_name: Optional[str] = Field(None, max_length=200, alias='fileName')
class MarketPresignTransferBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
transfer_amount: Optional[MarketPaymentAmount] = Field(
None, alias="transferAmount"
)
transfer_time: Optional[str] = Field(None, alias="transferTime")
transfer_type: Optional[str] = Field(
None, max_length=16, alias="transferType"
)
transfer_attachments: Optional[str] = Field(
None, max_length=4000, alias="transferAttachments"
)
remark: Optional[str] = Field(None, max_length=512)
@field_validator('transfer_time')
@classmethod
def validate_transfer_time(cls, value: Optional[str]) -> Optional[str]:
if value in (None, ''):
return value
text = str(value).strip()
if re.fullmatch(
r'(?:\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'
r'|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}'
r'(?::\d{2}(?:\.\d{1,6})?)?)',
text,
) is None:
raise ValueError('转账时间格式无效')
try:
datetime.fromisoformat(text)
except ValueError as exc:
raise ValueError('转账时间格式无效') from exc
return text
class MarketPresignOrderBody(BaseModel):
"""预签单可写字段;租户与财务字段不接受客户端输入。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
order_date: Optional[str] = Field(None, alias="orderDate")
protocol_no: Optional[str] = Field(
None, max_length=64, alias="protocolNo"
)
mother_name: Optional[str] = Field(
None, max_length=64, alias="motherName"
)
expected_delivery_date: Optional[str] = Field(
None, alias="expectedDeliveryDate"
)
region_id: Optional[str] = Field(
None, max_length=64, alias="regionId"
)
hospital_id: Optional[str] = Field(
None, max_length=64, alias="hospitalId"
)
sample_collect_time: Optional[str] = Field(None, alias="sampleCollectTime")
pay_mode: Optional[str] = Field(None, max_length=8, alias="payMode")
contract_amount: Optional[MarketContractAmount] = Field(
None, alias="contractAmount"
)
collector_user_id: Optional[str] = Field(
None, max_length=64, alias="collectorUserId"
)
region_manager_user_id: Optional[str] = Field(
None, max_length=64, alias="regionManagerUserId"
)
order_status: Optional[
Literal['draft', 'active', 'converted', 'void']
] = Field(None, alias='orderStatus')
remark: Optional[str] = Field(None, max_length=512)
sample_type_ids: Optional[List[MarketSampleTypeId]] = Field(
None, min_length=1, max_length=100, alias="sampleTypeIds"
)
transfers: Optional[List[MarketPresignTransferBody]] = 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 = str(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
@field_validator('mother_name')
@classmethod
def validate_mother_name(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
if not text:
raise ValueError('母亲姓名不能为空')
return text
@field_validator('sample_collect_time')
@classmethod
def validate_sample_collect_time(
cls, value: Optional[str]
) -> Optional[str]:
if value in (None, ''):
return value
text = str(value).strip()
try:
datetime.fromisoformat(text.replace('Z', '+00:00'))
except ValueError as exc:
raise ValueError('采集时间格式无效') from exc
return text
class MarketPresignOrderCreateBody(MarketPresignOrderBody):
"""新增预签单必填字段;销售专员始终由服务端身份决定。"""
mother_name: str = Field(min_length=1, max_length=64, alias='motherName')
sample_type_ids: List[MarketSampleTypeId] = Field(
min_length=1, max_length=100, alias='sampleTypeIds'
)
class MarketProtocolCustomerBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
customer_id: Optional[str] = Field(None, alias='customerId')
father_customer_id: Optional[str] = Field(None, alias='fatherCustomerId')
newborn_customer_id: Optional[str] = Field(None, alias='newbornCustomerId')
mother_name: Optional[str] = Field(None, alias='motherName')
mother_phone: Optional[str] = Field(None, alias='motherPhone')
mother_id_card: Optional[str] = Field(None, alias='motherIdCard')
mother_birth_ym: Optional[str] = Field(None, alias='motherBirthYm')
mother_ethnic: Optional[str] = Field(None, alias='motherEthnic')
mother_email: Optional[str] = Field(None, alias='motherEmail')
mother_address: Optional[str] = Field(None, alias='motherAddress')
father_name: Optional[str] = Field(None, alias='fatherName')
father_phone: Optional[str] = Field(None, alias='fatherPhone')
father_id_card: Optional[str] = Field(None, alias='fatherIdCard')
newborn_name: Optional[str] = Field(None, alias='newbornName')
newborn_id_card: Optional[str] = Field(None, alias='newbornIdCard')
expected_delivery_date: Optional[str] = Field(None, alias='expectedDeliveryDate')
newborn_delivery_time: Optional[str] = Field(None, alias='newbornDeliveryTime')
newborn_sex: Optional[str] = Field(None, alias='newbornSex')
delivery_mode: Optional[str] = Field(None, alias='deliveryMode')
birth_order: Optional[int] = Field(None, alias='birthOrder')
newborn_weight_gram: Optional[int] = Field(None, alias='newbornWeightGram')
apgar_1min: Optional[float] = Field(None, alias='apgar1min')
apgar_5min: Optional[float] = Field(None, alias='apgar5min')
collector_user_id: Optional[str] = Field(None, alias='collectorUserId')
remark: Optional[str] = None
@field_validator('birth_order', 'newborn_weight_gram', mode='before')
@classmethod
def normalize_optional_int(cls, value, info):
return _optional_customer_int(value, info.field_name)
@field_validator('apgar_1min', 'apgar_5min', mode='before')
@classmethod
def normalize_optional_float(cls, value):
return _blank_to_none(value)
class MarketProtocolCollectionBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
region_id: Optional[str] = Field(None, alias='regionId')
hospital_id: Optional[str] = Field(None, alias='hospitalId')
medical_staff: Optional[str] = Field(None, alias='medicalStaff')
sales_user_id: Optional[str] = Field(None, alias='salesUserId')
region_manager_user_id: Optional[str] = Field(
None, alias='regionManagerUserId'
)
sample_collect_time: Optional[str] = Field(None, alias='sampleCollectTime')
maternal_blood_collect_time: Optional[str] = Field(
None, alias='maternalBloodCollectTime'
)
remark: Optional[str] = None
class MarketProtocolSampleFreightDraftBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
client_key: Optional[str] = Field(None, alias='clientKey')
transport_mode: Optional[str] = Field(None, alias='transportMode')
freight_amount: Optional[MarketContractAmount] = Field(None, alias='freightAmount')
waybill_no: Optional[str] = Field(None, alias='waybillNo')
class MarketProtocolSampleBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
id: Optional[str] = Field(None, max_length=64, alias='sampleId')
sample_type_id: Optional[str] = Field(None, alias='sampleTypeId')
sample_code_id: Optional[str] = Field(None, alias='sampleCodeId')
sample_code: Optional[str] = Field(None, alias='sampleCode')
collect_amount: Optional[float] = Field(None, alias='collectAmount')
unit_id: Optional[str] = Field(None, alias='unitId')
price: Optional[MarketContractAmount] = None
original_price: Optional[MarketContractAmount] = Field(None, alias='originalPrice')
storage_years: Optional[int] = Field(None, alias='storageYears')
sample_status: Optional[str] = Field(None, alias='sampleStatus')
is_special: Optional[int] = Field(None, alias='isSpecial')
special_remark: Optional[str] = Field(None, alias='specialRemark')
remark: Optional[str] = None
eta_time: Optional[str] = Field(None, alias='etaTime')
freight_id: Optional[str] = Field(None, alias='freightId')
freight_draft: Optional[MarketProtocolSampleFreightDraftBody] = Field(
None, alias='freightDraft'
)
class MarketProtocolAttachmentsBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
protocol_attachments: Optional[str] = Field(
None, max_length=4000, alias='protocolAttachments'
)
consent_form_urls: Optional[str] = Field(
None, max_length=4000, alias='consentFormUrls'
)
health_survey_form_urls: Optional[str] = Field(
None, max_length=4000, alias='healthSurveyFormUrls'
)
attachment_urls: Optional[str] = Field(
None, max_length=4000, alias='attachmentUrls'
)
infectious_disease_report_urls: Optional[str] = Field(
None, max_length=4000, alias='infectiousDiseaseReportUrls'
)
class MarketProtocolNestedTransferBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
id: Optional[str] = None
transfer_amount: Optional[MarketPaymentAmount] = Field(None, alias='transferAmount')
transfer_time: Optional[str] = Field(None, alias='transferTime')
transfer_type: Optional[str] = Field(None, max_length=16, alias='transferType')
transfer_attachments: Optional[str] = Field(None, max_length=4000, alias='transferAttachments')
remark: Optional[str] = Field(None, max_length=512)
class MarketProtocolPaymentScheduleBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
id: Optional[str] = None
schedule_no: Optional[int] = Field(None, alias='scheduleNo')
due_amount: Optional[MarketContractAmount] = Field(None, alias='dueAmount')
due_date: Optional[str] = Field(None, alias='dueDate')
schedule_status: Optional[str] = Field(None, alias='scheduleStatus')
transfers: Optional[List[MarketProtocolNestedTransferBody]] = None
class MarketProtocolSamplePaymentBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
id: Optional[str] = None
sample_code: str = Field(..., min_length=1, alias='sampleCode')
protocol_sample_id: Optional[str] = Field(None, alias='protocolSampleId')
pay_mode: str = Field(..., alias='payMode')
contract_amount: Optional[MarketContractAmount] = Field(None, alias='contractAmount')
lump_sum_deadline: Optional[str] = Field(None, alias='lumpSumDeadline')
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[List[MarketContractAmount]] = Field(
None, alias='installmentRatios'
)
remark: Optional[str] = None
schedules: Optional[List[MarketProtocolPaymentScheduleBody]] = None
class MarketProtocolPaymentBody(BaseModel):
"""历史 v1 整单缴费方案;不接收协议版本或财务状态字段。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
pay_mode: Literal['dj', 'fq', 'djfq', 'fgd'] = Field(..., alias='payMode')
contract_amount: Optional[MarketContractAmount] = Field(
None, alias='contractAmount'
)
lump_sum_deadline: Optional[str] = Field(None, max_length=8, alias='lumpSumDeadline')
installment_count: Optional[int] = Field(None, ge=1, alias='installmentCount')
installment_interval_months: Optional[Decimal] = Field(
None, ge=Decimal('0'), alias='installmentIntervalMonths'
)
installment_start_date: Optional[str] = Field(
None, max_length=8, alias='installmentStartDate'
)
installment_advance_days: Optional[int] = Field(
None, ge=0, le=365, alias='installmentAdvanceDays'
)
installment_ratios: Optional[List[MarketContractAmount]] = Field(
None, alias='installmentRatios'
)
@field_validator('lump_sum_deadline', 'installment_start_date')
@classmethod
def validate_ymd(cls, value: Optional[str]) -> Optional[str]:
if value in (None, ''):
return value
text = str(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 MarketProtocolPaymentDetailBody(BaseModel):
"""历史 v1 缴费明细的市场可写字段白名单。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
id: Optional[str] = Field(None, max_length=64)
installment_no: Optional[int] = Field(None, ge=1, alias='installmentNo')
installment_due_date: Optional[str] = Field(
None, max_length=8, alias='installmentDueDate'
)
due_amount: Optional[MarketContractAmount] = Field(None, alias='dueAmount')
transfer_type: Optional[str] = Field(None, max_length=16, alias='transferType')
transfer_amount: Optional[MarketPaymentAmount] = Field(
None, alias='transferAmount'
)
transfer_time: Optional[str] = Field(None, alias='transferTime')
transfer_attachments: Optional[str] = Field(
None,
max_length=4000,
validation_alias=AliasChoices('transferAttachments', 'attachments'),
serialization_alias='transferAttachments',
)
remark: Optional[str] = Field(None, max_length=512)
@field_validator('installment_due_date')
@classmethod
def validate_ymd(cls, value: Optional[str]) -> Optional[str]:
if value in (None, ''):
return value
text = str(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 MarketProtocolBody(BaseModel):
"""移动协议白名单。协议/捐赠状态可改;审核、财务、退款及转换时间字段一律拒绝。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
source: Literal['cell_storage', 'research_donation']
protocol_status: Optional[
Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zky']
] = Field(None, alias='protocolStatus')
donation_status: Optional[
Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zckc']
] = Field(None, alias='donationStatus')
protocol_no: Optional[str] = Field(None, alias='protocolNo')
donation_no: Optional[str] = Field(None, alias='donationNo')
register_date: Optional[str] = Field(None, alias='registerDate')
donation_date: Optional[str] = Field(None, alias='donationDate')
collector_user_id: Optional[str] = Field(None, alias='collectorUserId')
protocol_total_amount: Optional[MarketContractAmount] = Field(
None, alias='protocolTotalAmount'
)
remark: Optional[str] = None
customer: Optional[MarketProtocolCustomerBody] = None
collection: Optional[MarketProtocolCollectionBody] = None
samples: Optional[List[MarketProtocolSampleBody]] = None
attachments: Optional[MarketProtocolAttachmentsBody] = None
sample_payments: Optional[List[MarketProtocolSamplePaymentBody]] = Field(
None, alias='samplePayments'
)
payment: Optional[MarketProtocolPaymentBody] = None
payment_details: Optional[List[MarketProtocolPaymentDetailBody]] = Field(
None, alias='paymentDetails'
)
@field_validator('register_date', 'donation_date')
@classmethod
def validate_protocol_ymd(cls, value: Optional[str]) -> Optional[str]:
"""接受 YYYYMMDD / YYYY-MM-DD,统一规范为 CHAR(8) YYYYMMDD。"""
if value in (None, ''):
return value
text = str(value).strip().replace('-', '').replace('/', '')
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 MarketProtocolAuditBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
audit_status: Literal['shtg', 'shth'] = Field(..., alias='auditStatus')
audit_remark: Optional[str] = Field(None, max_length=512, alias='auditRemark')
class MarketPaymentTransferBody(BaseModel):
"""市场侧仅可登记/编辑业务转账草稿,不接收状态及财务字段。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
transfer_amount: Optional[MarketPaymentAmount] = Field(
None,
alias='transferAmount',
)
transfer_time: Optional[str] = Field(None, alias='transferTime')
transfer_type: Optional[str] = Field(None, max_length=16, alias='transferType')
transfer_attachments: Optional[str] = Field(
None, max_length=4000, alias='transferAttachments'
)
remark: Optional[str] = Field(None, max_length=512)
class MarketPaymentTransferCreateBody(MarketPaymentTransferBody):
schedule_id: str = Field(..., min_length=1, max_length=64, alias='scheduleId')
class MarketSampleHandleBody(BaseModel):
"""市场样本仅允许登记轻量办理信息。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
handle_time: Optional[str] = Field(None, alias='handleTime')
handler_user_id: Optional[str] = Field(
None, max_length=64, alias='handlerUserId'
)
attachments: Optional[str] = Field(None, max_length=4000)
remark: Optional[str] = Field(None, max_length=512)
class MarketSampleStorageCertBody(MarketSampleHandleBody):
pass
class MarketSampleInsuranceBody(MarketSampleHandleBody):
pass
class MarketFreightItemBody(BaseModel):
"""客户端只提交样本身份;协议与样本编码快照由服务端派生。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
sample_source: Literal['cell_storage', 'research_donation'] = Field(
..., alias='sampleSource'
)
sample_id: str = Field(..., min_length=1, max_length=64, alias='sampleId')
class MarketFreightItemsBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
items: List[MarketFreightItemBody] = Field(..., min_length=1)
class MarketFreightCreateBody(MarketFreightItemsBody):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
transport_mode: str = Field(..., min_length=1, max_length=100, alias='transportMode')
freight_amount: Optional[Decimal] = Field(
None, ge=Decimal('0'), max_digits=18, decimal_places=2,
allow_inf_nan=False, alias='freightAmount'
)
waybill_no: Optional[str] = Field(None, alias='waybillNo')
class MarketFreightUpdateBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
transport_mode: Optional[str] = Field(
None, min_length=1, max_length=100, alias='transportMode'
)
freight_amount: Optional[Decimal] = Field(
None, ge=Decimal('0'), max_digits=18, decimal_places=2,
allow_inf_nan=False, alias='freightAmount'
)
waybill_no: Optional[str] = Field(None, alias='waybillNo')
Loaded 100 of 853 files, more files were not shown because too many files have changed in this diff. Show more