Compare commits
297
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4efd423d2d | ||
|
|
5e563a6669 | ||
|
|
d66e55af65 | ||
|
|
045ee8d0e3 | ||
|
|
4583c54568 | ||
|
|
5dad1bb0e3 | ||
|
|
19bc66ba45 | ||
|
|
076e892df9 | ||
|
|
07508e677b | ||
|
|
bab304abd3 | ||
|
|
6a9a8bddd6 | ||
|
|
c2c5078ee6 | ||
|
|
ef7ea3c190 | ||
|
|
61fdb6ebcd | ||
|
|
5651bcd85e | ||
|
|
17f3948dc7 | ||
|
|
85f99ed757 | ||
|
|
b8b1631d46 | ||
|
|
97d73d835b | ||
|
|
a51d4e10aa | ||
|
|
33c24129b4 | ||
|
|
063727a7d8 | ||
|
|
6e2656f999 | ||
|
|
a81f177cb6 | ||
|
|
77e8c78cb6 | ||
|
|
9b34f9e1ba | ||
|
|
0fb3ee50e3 | ||
|
|
d259004f9d | ||
|
|
9561f94074 | ||
|
|
afcb4e9ebe | ||
|
|
0506fe7e8a | ||
|
|
545a4c5fc1 | ||
|
|
d37bb41948 | ||
|
|
f76ef8a6e5 | ||
|
|
41e8d6f7c4 | ||
|
|
7aa685840f | ||
|
|
bd669ea77e | ||
|
|
96468f3c48 | ||
|
|
11a46bf1eb | ||
|
|
d67965aae2 | ||
|
|
dd00dba39c | ||
|
|
92c59d67b4 | ||
|
|
9325f8cf61 | ||
|
|
ac8e471aca | ||
|
|
03fcc01d6d | ||
|
|
3ceee48c00 | ||
|
|
19dffbaa49 | ||
|
|
be2226e4ca | ||
|
|
48dcf991dc | ||
|
|
d355cd0640 | ||
|
|
c825c170e7 | ||
|
|
9de7f5456e | ||
|
|
e2fee1d9c5 | ||
|
|
80d238d31d | ||
|
|
526c9029c8 | ||
|
|
b545e5987c | ||
|
|
d85c70a860 | ||
|
|
b76a7d65be | ||
|
|
d998378e12 | ||
|
|
90c1b83a54 | ||
|
|
75f00a6472 | ||
|
|
241dd2861c | ||
|
|
d29a18db86 | ||
|
|
3a148b2985 | ||
|
|
6c8b8025d7 | ||
|
|
209fb7bff3 | ||
|
|
e5ae28f0c7 | ||
|
|
0402f797c0 | ||
|
|
b042266c5b | ||
|
|
5e38dc1eca | ||
|
|
00a315b61c | ||
|
|
35532eceee | ||
|
|
a148961b55 | ||
|
|
6cd22b218d | ||
|
|
3ecd8f4a74 | ||
|
|
06d021807b | ||
|
|
ecde2ba07d | ||
|
|
b9d981d46b | ||
|
|
2b5f2f939c | ||
|
|
7ba336169a | ||
|
|
79f9c8785b | ||
|
|
87f1e9a4eb | ||
|
|
a7a5a914f1 | ||
|
|
920b432491 | ||
|
|
b592aaec2e | ||
|
|
012527ed35 | ||
|
|
9d7a72673d | ||
|
|
5ce18e56a0 | ||
|
|
f045841f1a | ||
|
|
82b8a9c1b2 | ||
|
|
6eafec3fa9 | ||
|
|
321dd3ca43 | ||
|
|
4e586e905b | ||
|
|
bbc24b7c73 | ||
|
|
42dde4b0bc | ||
|
|
3950b5c334 | ||
|
|
a2125374a8 | ||
|
|
6e190665c0 | ||
|
|
f040f579d1 | ||
|
|
e16afdee95 | ||
|
|
e56d8d7f51 | ||
|
|
0398cb3897 | ||
|
|
ee95036666 | ||
|
|
b217ffcdca | ||
|
|
e4ec917a03 | ||
|
|
63f1d7c2f9 | ||
|
|
c767373a8f | ||
|
|
8dd816ee1d | ||
|
|
1c53bcd226 | ||
|
|
f5c514c520 | ||
|
|
f5daab0d44 | ||
|
|
a7b8560f5f | ||
|
|
3354e66d82 | ||
|
|
20a8bf538a | ||
|
|
66e1c8334c | ||
|
|
be22c37196 | ||
|
|
9c61f36db8 | ||
|
|
cc843535f5 | ||
|
|
d08f03ab03 | ||
|
|
6d56572f8b | ||
|
|
618d8fe21c | ||
|
|
6ec4ec6730 | ||
|
|
5fd5550e0d | ||
|
|
13ac45d10d | ||
|
|
0efcd6a6e5 | ||
|
|
08150167a8 | ||
|
|
6c66eb164e | ||
|
|
99e71096ac | ||
|
|
0ab0774e8a | ||
|
|
aa231ea5f8 | ||
|
|
bee2c0d193 | ||
|
|
60efc5d5d3 | ||
|
|
e0ef78925a | ||
|
|
56f379dd0c | ||
|
|
c22c9467f5 | ||
|
|
22a6e1f4e4 | ||
|
|
efbf8f6ac9 | ||
|
|
8cebb8e0b8 | ||
|
|
1c225ec89d | ||
|
|
ad18204eb9 | ||
|
|
9c264d7942 | ||
|
|
aeadaa9919 | ||
|
|
50400ad19c | ||
|
|
edabf70287 | ||
|
|
d676bfadd7 | ||
|
|
eee70e417c | ||
|
|
da35921f58 | ||
|
|
8618141c2f | ||
|
|
af992b5680 | ||
|
|
cc0b217c89 | ||
|
|
727002761d | ||
|
|
a5bf4252b9 | ||
|
|
a6f56f3453 | ||
|
|
c71bb5a11c | ||
|
|
d2bb1e0fa1 | ||
|
|
b8b20dcebe | ||
|
|
a1be31c694 | ||
|
|
06a30ff658 | ||
|
|
97bf67c962 | ||
|
|
f659ec602e | ||
|
|
8c0cbb8ab8 | ||
|
|
75bae974e4 | ||
|
|
d72e28959d | ||
|
|
ce9d836194 | ||
|
|
55e321f266 | ||
|
|
54ec8e1cd1 | ||
|
|
e0dc7f4cee | ||
|
|
f761c7021e | ||
|
|
06255d1e41 | ||
|
|
0927a514d2 | ||
|
|
0c4a2408ee | ||
|
|
a3a2ffcce0 | ||
|
|
7a750e7e81 | ||
|
|
b28ed3e280 | ||
|
|
702ff0e074 | ||
|
|
81bda0cb79 | ||
|
|
02d1e16036 | ||
|
|
3f40caa1cd | ||
|
|
337d31bbe8 | ||
|
|
7e930c77fd | ||
|
|
34bf9aa0dc | ||
|
|
c5c7e42feb | ||
|
|
8453c9c805 | ||
|
|
e8e278be86 | ||
|
|
99bd4749ea | ||
|
|
d20b9f5a20 | ||
|
|
3018d05bc4 | ||
|
|
7a6d804cfa | ||
|
|
a47a7db7dc | ||
|
|
f5c999f633 | ||
|
|
8c82f6a6dd | ||
|
|
28990e66be | ||
|
|
5a24f3b433 | ||
|
|
b2ea79710c | ||
|
|
daace3ff74 | ||
|
|
8ef8981d71 | ||
|
|
b0a4b94d7d | ||
|
|
db31990ebe | ||
|
|
2d3472f5c4 | ||
|
|
f5fdfc6770 | ||
|
|
416b1cefa2 | ||
|
|
842aa0847f | ||
|
|
ea5fba9dc2 | ||
|
|
628a20dbbd | ||
|
|
5025aa09c2 | ||
|
|
34c8a1a62d | ||
|
|
da63c228f5 | ||
|
|
de132a7c04 | ||
|
|
674084c511 | ||
|
|
8b42cf1b90 | ||
|
|
60ad9de191 | ||
|
|
32c742c1ed | ||
|
|
3edfb9dd86 | ||
|
|
8cbbd3548b | ||
|
|
c860e39363 | ||
|
|
eb8a76d8d2 | ||
|
|
d4477f357d | ||
|
|
70fb2219de | ||
|
|
9444945add | ||
|
|
bce0a15772 | ||
|
|
df5206454f | ||
|
|
5d0e3d904a | ||
|
|
500a85465f | ||
|
|
f1dcafd33d | ||
|
|
df827e4344 | ||
|
|
28b10663ee | ||
|
|
5546b7aa73 | ||
|
|
e194e1ad04 | ||
|
|
e6763241d1 | ||
|
|
e521d781e3 | ||
|
|
fac7b87728 | ||
|
|
55ed0d8929 | ||
|
|
776031cb4c | ||
|
|
884d2f4a41 | ||
|
|
1e12783a41 | ||
|
|
38f9822d49 | ||
|
|
0da75fb855 | ||
|
|
4904508d9f | ||
|
|
8f00db9400 | ||
|
|
702c8a2f12 | ||
|
|
e69ddd0d32 | ||
|
|
85b315a0cf | ||
|
|
26fd37e6f9 | ||
|
|
86ba0c2cd0 | ||
|
|
f092b64a33 | ||
|
|
971af93132 | ||
|
|
04eb1e81bd | ||
|
|
68f653eb51 | ||
|
|
57d6241804 | ||
|
|
5dd545ba5c | ||
|
|
468a7c5e12 | ||
|
|
6d542a0acf | ||
|
|
66fc507b1a | ||
|
|
dbdabdce24 | ||
|
|
719bf20c5b | ||
|
|
95a28d5f0f | ||
|
|
a6138d60bc | ||
|
|
818b809614 | ||
|
|
af5c6e725d | ||
|
|
e7f2dfa4d4 | ||
|
|
f005370a1e | ||
|
|
910f61ed33 | ||
|
|
fc14c51348 | ||
|
|
69e76b85ab | ||
|
|
e027b5abc4 | ||
|
|
cf86c585b7 | ||
|
|
98ad5370dc | ||
|
|
be98dbe7ac | ||
|
|
ed37a31654 | ||
|
|
56fd44388b | ||
|
|
c1f9a56404 | ||
|
|
70c92fb33f | ||
|
|
db75ca6deb | ||
|
|
f447db7681 | ||
|
|
f8b0e2de11 | ||
|
|
cf4b8c0e56 | ||
|
|
d5bfb895a7 | ||
|
|
d5ad755695 | ||
|
|
edaddc9da1 | ||
|
|
32c01a4c69 | ||
|
|
1bc44cab13 | ||
|
|
c35e4ca3d2 | ||
|
|
47f873a3e6 | ||
|
|
4849b40594 | ||
|
|
33004fe9b9 | ||
|
|
f73428dcfd | ||
|
|
e7677d1661 | ||
|
|
a9112414c9 | ||
|
|
fdd2c1fbc3 | ||
|
|
c404c168fe | ||
|
|
4e71a13165 | ||
|
|
a328b9ff2d | ||
|
|
2ff5bc09f9 | ||
|
|
aed7a29c1f | ||
|
|
8827ec3804 | ||
|
|
ca3d464761 | ||
|
|
4928e00423 |
No files matched your search
+55
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -236,6 +236,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 +424,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 +456,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 +470,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))
|
||||
|
||||
@@ -1644,8 +1670,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"
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 采集地区 — 下属医院
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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})
|
||||
@@ -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
@@ -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
@@ -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"},
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. 细胞产品价格
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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}%"))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
+1243
-233
File diff suppressed because it is too large.
Load diff
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
)
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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="租户编号")
|
||||
|
||||
@@ -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 847 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user