Compare commits
198
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 |
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` 服务。
|
||||
@@ -86,3 +86,18 @@ SECRET_KEY=
|
||||
# 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
|
||||
@@ -40,7 +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",
|
||||
@@ -84,5 +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",
|
||||
]
|
||||
@@ -51,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")
|
||||
@@ -64,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")
|
||||
@@ -147,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,
|
||||
@@ -176,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"),
|
||||
@@ -447,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,13 +2,14 @@
|
||||
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
|
||||
@@ -21,9 +22,40 @@ from app.models.models import (
|
||||
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")
|
||||
@@ -190,6 +222,25 @@ class ProtocolUpdateBody(BaseModel):
|
||||
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],
|
||||
@@ -234,26 +285,40 @@ def _persist_protocol_sub_resources(
|
||||
sample_payments: Optional[list] = None,
|
||||
storage_cert: Optional[dict],
|
||||
payment_model_version: int = 2,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
"""持久化协议子资源。
|
||||
|
||||
version=1:缴费(含明细校验)须在样本之前,避免校验失败后已占用样本编码。
|
||||
version=2:样本先落库占码,再按 sample_code 挂样本缴费方案。
|
||||
|
||||
默认整段子资源共用一次事务:内部一律 commit=False,成功后再统一 commit,
|
||||
避免样本已提交、缴费校验失败导致「提示失败但部分已保存」。
|
||||
"""
|
||||
if party_customer:
|
||||
_parse_datetime_fields(party_customer, ["newbornDeliveryTime"])
|
||||
party_customer = sync_party_customers(
|
||||
db, party_customer, tenant_no=tenant_no, user_id=user_id
|
||||
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
|
||||
)
|
||||
cp_svc.upsert_party_customer(db, protocol_id, tenant_no, user_id, **party_customer)
|
||||
if collection_info:
|
||||
_parse_datetime_fields(collection_info, ["sampleCollectTime", "maternalBloodCollectTime"])
|
||||
cp_svc.upsert_collection_info(db, protocol_id, tenant_no, user_id, **collection_info)
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -263,25 +328,32 @@ def _persist_protocol_sub_resources(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
sample_payments=sample_payments,
|
||||
commit=False,
|
||||
)
|
||||
else:
|
||||
# 旧模型:缴费先于样本
|
||||
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 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)
|
||||
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")
|
||||
@@ -290,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
|
||||
@@ -311,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,
|
||||
@@ -327,6 +401,7 @@ async def create_collection_protocol(
|
||||
remark=body.remark,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
commit=False,
|
||||
)
|
||||
protocol_id = obj.id
|
||||
_persist_protocol_sub_resources(
|
||||
@@ -344,20 +419,23 @@ async def create_collection_protocol(
|
||||
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))
|
||||
|
||||
|
||||
@@ -368,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):
|
||||
@@ -401,11 +480,15 @@ 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,
|
||||
commit=False,
|
||||
**{
|
||||
k: v
|
||||
for k, v in main_payload.items()
|
||||
@@ -427,9 +510,23 @@ async def update_collection_protocol(
|
||||
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))
|
||||
|
||||
|
||||
@@ -500,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,
|
||||
@@ -516,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})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 收款确认
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1090,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})
|
||||
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")
|
||||
|
||||
+28
-6
@@ -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"],
|
||||
@@ -71,12 +71,23 @@ STATIC_MENU_TREE = [
|
||||
"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",
|
||||
@@ -89,12 +100,23 @@ STATIC_MENU_TREE = [
|
||||
"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"},
|
||||
},
|
||||
# 市场侧入库放行审核(总览)暂下线:改由生产/质检入口审核
|
||||
# {
|
||||
@@ -212,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"},
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -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. 细胞产品价格
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
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
|
||||
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
|
||||
@@ -54,6 +56,20 @@ class PresignOrderBody(BaseModel):
|
||||
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)
|
||||
@@ -109,6 +125,7 @@ async def get_presign_order(
|
||||
):
|
||||
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, "预签约订单不存在")
|
||||
@@ -129,6 +146,7 @@ async def create_presign_order(
|
||||
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,
|
||||
@@ -154,12 +172,14 @@ async def update_presign_order(
|
||||
):
|
||||
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,
|
||||
@@ -184,6 +204,7 @@ async def delete_presign_order(
|
||||
):
|
||||
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
|
||||
@@ -203,9 +224,11 @@ async def convert_presign_order(
|
||||
):
|
||||
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
|
||||
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:
|
||||
@@ -223,9 +246,11 @@ async def create_presign_transfer(
|
||||
):
|
||||
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,
|
||||
@@ -249,6 +274,9 @@ async def update_presign_transfer(
|
||||
):
|
||||
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,
|
||||
@@ -274,6 +302,9 @@ async def submit_presign_transfer_finance(
|
||||
):
|
||||
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
|
||||
@@ -295,6 +326,9 @@ async def withdraw_presign_transfer(
|
||||
):
|
||||
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
|
||||
@@ -369,6 +403,9 @@ async def confirm_presign_transfer(
|
||||
):
|
||||
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,
|
||||
@@ -395,6 +432,9 @@ async def reject_presign_transfer(
|
||||
):
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -182,29 +182,13 @@ def _save_sample_payments(
|
||||
continue
|
||||
if item.schedules is not None:
|
||||
schedule_payloads = [s.model_dump(by_alias=False) for s in item.schedules]
|
||||
created = psp_svc.replace_schedules(
|
||||
psp_svc.sync_schedules_and_transfers(
|
||||
db,
|
||||
sample_payment_id=sp.id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
schedules=schedule_payloads,
|
||||
)
|
||||
# 可选:随 PUT 带入的草稿转账(仅新建 dfj)
|
||||
for sch_body, sch_row in zip(item.schedules, created):
|
||||
for tr in sch_body.transfers or []:
|
||||
psp_svc.add_transfer(
|
||||
db,
|
||||
schedule_id=sch_row.id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
transfer_amount=tr.get("transferAmount") or tr.get("transfer_amount"),
|
||||
transfer_time=tr.get("transferTime") or tr.get("transfer_time"),
|
||||
transfer_type=tr.get("transferType") or tr.get("transfer_type"),
|
||||
transfer_attachments=tr.get("transferAttachments")
|
||||
or tr.get("transfer_attachments"),
|
||||
remark=tr.get("remark"),
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return psp_svc.get_sample_payments_bundle(db, protocol_id, tenant_no)
|
||||
|
||||
@@ -397,15 +381,20 @@ async def update_payment_transfer(
|
||||
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,
|
||||
)
|
||||
if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user):
|
||||
return error_response(403, "无权操作该协议")
|
||||
from app.crud.protocol_sample_payment import _transfer_to_dict
|
||||
|
||||
return success_response(_transfer_to_dict(db, obj, tenant_no), message="保存成功")
|
||||
@@ -426,15 +415,20 @@ async def submit_payment_transfer_finance(
|
||||
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,
|
||||
)
|
||||
if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user):
|
||||
return error_response(403, "无权操作该协议")
|
||||
from app.crud.protocol_sample_payment import _transfer_to_dict
|
||||
|
||||
return success_response(_transfer_to_dict(db, obj, tenant_no), message="已提交财务确认")
|
||||
@@ -453,11 +447,19 @@ async def withdraw_payment_transfer(
|
||||
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
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
locked_transfer=locked_transfer,
|
||||
)
|
||||
if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user):
|
||||
return error_response(403, "无权操作该协议")
|
||||
from app.crud.protocol_sample_payment import _transfer_to_dict
|
||||
|
||||
return success_response(_transfer_to_dict(db, obj, tenant_no), message="已撤回")
|
||||
@@ -589,6 +591,12 @@ async def confirm_payment_transfer(
|
||||
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,
|
||||
@@ -596,9 +604,8 @@ async def confirm_payment_transfer(
|
||||
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,
|
||||
)
|
||||
if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user):
|
||||
return error_response(403, "无权操作该协议")
|
||||
from app.crud.protocol_sample_payment import _transfer_to_dict
|
||||
|
||||
return success_response(_transfer_to_dict(db, obj, tenant_no), message="确认收款成功")
|
||||
@@ -617,11 +624,19 @@ async def reject_payment_transfer(
|
||||
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
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
locked_transfer=locked_transfer,
|
||||
)
|
||||
if not _protocol_accessible(db, obj.collection_protocol_id, tenant_no, user):
|
||||
return error_response(403, "无权操作该协议")
|
||||
from app.crud.protocol_sample_payment import _transfer_to_dict
|
||||
|
||||
return success_response(_transfer_to_dict(db, obj, tenant_no), message="已退回")
|
||||
|
||||
@@ -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}")
|
||||
@@ -12,6 +12,7 @@ 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=["样本运费批次"])
|
||||
@@ -47,7 +48,8 @@ class SampleFreightCreateBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
transport_mode: str = Field(..., alias="transportMode")
|
||||
freight_amount: float = Field(..., alias="freightAmount")
|
||||
freight_amount: Optional[float] = Field(None, alias="freightAmount")
|
||||
waybill_no: Optional[str] = Field(None, alias="waybillNo")
|
||||
items: Optional[List[SampleFreightItemBody]] = None
|
||||
|
||||
|
||||
@@ -56,6 +58,7 @@ class SampleFreightUpdateBody(BaseModel):
|
||||
|
||||
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):
|
||||
@@ -64,6 +67,19 @@ class SampleFreightItemsBody(BaseModel):
|
||||
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),
|
||||
@@ -82,6 +98,7 @@ async def list_sample_freights(
|
||||
items, total = freight_svc.list_freights(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
@@ -114,6 +131,7 @@ async def list_sample_freight_options(
|
||||
items, total = freight_svc.list_sample_options(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
@@ -128,6 +146,115 @@ async def list_sample_freight_options(
|
||||
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,
|
||||
@@ -136,7 +263,7 @@ async def get_sample_freight(
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = freight_svc.get_freight_detail(db, freight_id, tenant_no)
|
||||
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))
|
||||
@@ -157,9 +284,13 @@ async def create_sample_freight(
|
||||
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="创建成功")
|
||||
@@ -183,9 +314,13 @@ async def update_sample_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:
|
||||
@@ -211,6 +346,7 @@ async def link_sample_freight(
|
||||
db,
|
||||
freight_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
user_id=user_id,
|
||||
items=items,
|
||||
)
|
||||
@@ -238,6 +374,7 @@ async def unlink_sample_freight(
|
||||
db,
|
||||
freight_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
user_id=user_id,
|
||||
items=items,
|
||||
)
|
||||
@@ -248,3 +385,28 @@ async def unlink_sample_freight(
|
||||
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)
|
||||
@@ -6,22 +6,22 @@ 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.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 = (
|
||||
"biz:qcWorkOrder:list",
|
||||
"biz:myQcTask:list",
|
||||
)
|
||||
WRITE_MENUS = READ_MENUS
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
@@ -65,7 +65,7 @@ async def get_sample_storage_cert_qc(
|
||||
async def put_sample_storage_cert_qc(
|
||||
sample_code: str,
|
||||
body: UpsertBody,
|
||||
user: dict = Depends(require_any_menu_code(*WRITE_MENUS)),
|
||||
user: dict = Depends(require_strict_any_menu_code(*WRITE_MENUS)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -133,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"
|
||||
@@ -158,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"
|
||||
|
||||
+1068
-230
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()
|
||||
|
||||
|
||||
|
||||
+443
-157
@@ -7,9 +7,12 @@ from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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.biz_no_generator import allocate_next_biz_no
|
||||
from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
BizCellSampleType,
|
||||
@@ -31,20 +34,46 @@ def _now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def assert_presign_data_access(
|
||||
db: Session, record_id: str, tenant_no: str, user_info: dict,
|
||||
*, transfer: bool = False,
|
||||
) -> None:
|
||||
"""Web 单条操作沿用对应订单/财务列表的租户与多角色数据范围。"""
|
||||
from app.core.data_scope import apply_data_scope
|
||||
|
||||
model = BizPresignTransfer if transfer else BizPresignOrder
|
||||
query = db.query(model.id).filter(
|
||||
model.id == record_id,
|
||||
model.tenant_no == tenant_no,
|
||||
model.del_flag == 0,
|
||||
)
|
||||
if apply_data_scope(query, model, user_info, db, tenant_no).first() is None:
|
||||
raise PermissionError("记录不存在或无权访问")
|
||||
|
||||
|
||||
def _dec(v: Any) -> Optional[Decimal]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
|
||||
def _user_nick_name_map(
|
||||
db: Session, user_ids: List[str], tenant_no: str
|
||||
) -> Dict[str, str]:
|
||||
ids = [str(x).strip() for x in user_ids if str(x or "").strip()]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = db.query(SysUser.id, SysUser.nick_name, SysUser.user_no).filter(SysUser.id.in_(ids)).all()
|
||||
rows = (
|
||||
db.query(SysUser.id, SysUser.nick_name)
|
||||
.filter(
|
||||
SysUser.id.in_(ids),
|
||||
SysUser.tenant_no == tenant_no,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
out: Dict[str, str] = {}
|
||||
for u in rows:
|
||||
out[str(u.id)] = (u.nick_name or u.user_no or "").strip()
|
||||
out[str(u.id)] = (u.nick_name or "").strip()
|
||||
return out
|
||||
|
||||
|
||||
@@ -75,22 +104,59 @@ def resolve_convert_staff_fields(
|
||||
|
||||
|
||||
def _get_order_or_raise(
|
||||
db: Session, *, order_id: str, tenant_no: str
|
||||
db: Session, *, order_id: str, tenant_no: str, for_update: bool = False
|
||||
) -> BizPresignOrder:
|
||||
obj = (
|
||||
query = (
|
||||
db.query(BizPresignOrder)
|
||||
.filter(
|
||||
BizPresignOrder.id == order_id,
|
||||
BizPresignOrder.tenant_no == tenant_no,
|
||||
BizPresignOrder.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if for_update:
|
||||
query = query.populate_existing().with_for_update()
|
||||
obj = query.first()
|
||||
if not obj:
|
||||
raise ValueError("预签约订单不存在")
|
||||
return obj
|
||||
|
||||
|
||||
def validate_presign_person_ids(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
collector_user_id: Optional[str],
|
||||
sales_user_id: Optional[str],
|
||||
region_manager_user_id: Optional[str],
|
||||
) -> None:
|
||||
"""所有预签人员必须是当前租户有效且启用的系统用户。"""
|
||||
requested = {
|
||||
str(user_id).strip()
|
||||
for user_id in (
|
||||
collector_user_id,
|
||||
sales_user_id,
|
||||
region_manager_user_id,
|
||||
)
|
||||
if str(user_id or "").strip()
|
||||
}
|
||||
if not requested:
|
||||
return
|
||||
existing = {
|
||||
str(row[0])
|
||||
for row in db.query(SysUser.id)
|
||||
.filter(
|
||||
SysUser.id.in_(requested),
|
||||
SysUser.tenant_no == tenant_no,
|
||||
SysUser.del_flag == 0,
|
||||
SysUser.status == "1",
|
||||
)
|
||||
.all()
|
||||
}
|
||||
if existing != requested:
|
||||
raise ValueError("人员用户不存在、不属于当前租户、已删除或已停用")
|
||||
|
||||
|
||||
def _get_transfer_or_raise(
|
||||
db: Session, *, transfer_id: str, tenant_no: str
|
||||
) -> BizPresignTransfer:
|
||||
@@ -108,6 +174,51 @@ def _get_transfer_or_raise(
|
||||
return obj
|
||||
|
||||
|
||||
def _update_transfer_expected_status(
|
||||
db: Session,
|
||||
*,
|
||||
transfer_id: str,
|
||||
tenant_no: str,
|
||||
expected_status: str,
|
||||
values: Dict[Any, Any],
|
||||
extra_conditions: Tuple[Any, ...] = (),
|
||||
commit: bool = True,
|
||||
) -> BizPresignTransfer:
|
||||
"""以旧状态作为 CAS 条件更新,并返回事务内刷新后的稳定快照。"""
|
||||
changed = (
|
||||
db.query(BizPresignTransfer)
|
||||
.filter(
|
||||
BizPresignTransfer.id == transfer_id,
|
||||
BizPresignTransfer.tenant_no == tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
BizPresignTransfer.pay_status == expected_status,
|
||||
*extra_conditions,
|
||||
)
|
||||
.update(values, synchronize_session=False)
|
||||
)
|
||||
if changed != 1:
|
||||
raise ValueError("转账状态已变化,请刷新后重试")
|
||||
obj = (
|
||||
db.query(BizPresignTransfer)
|
||||
.filter(
|
||||
BizPresignTransfer.id == transfer_id,
|
||||
BizPresignTransfer.tenant_no == tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
)
|
||||
.populate_existing()
|
||||
.first()
|
||||
)
|
||||
if obj is None:
|
||||
raise ValueError("意向金转账笔不存在")
|
||||
db.flush()
|
||||
if commit:
|
||||
# 提交会 expire 会话内 ORM 对象;先分离,调用方序列化时只读取
|
||||
# 本次事务内已刷新的标量快照,不会在提交后重新读取并发新状态。
|
||||
db.expunge(obj)
|
||||
db.commit()
|
||||
return obj
|
||||
|
||||
|
||||
def _transfer_to_dict(
|
||||
db: Session, t: BizPresignTransfer, tenant_no: str
|
||||
) -> Dict[str, Any]:
|
||||
@@ -152,6 +263,31 @@ def confirmed_deposit_amount(
|
||||
return total
|
||||
|
||||
|
||||
def confirmed_deposit_amount_map(
|
||||
db: Session, *, presign_order_ids: List[str], tenant_no: str
|
||||
) -> Dict[str, Decimal]:
|
||||
ids = [str(order_id).strip() for order_id in presign_order_ids if str(order_id or "").strip()]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(
|
||||
BizPresignTransfer.presign_order_id,
|
||||
func.sum(BizPresignTransfer.transfer_amount),
|
||||
)
|
||||
.filter(
|
||||
BizPresignTransfer.presign_order_id.in_(ids),
|
||||
BizPresignTransfer.tenant_no == tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
BizPresignTransfer.pay_status == "yjf",
|
||||
)
|
||||
.group_by(BizPresignTransfer.presign_order_id)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
str(order_id): Decimal(str(amount or 0)) for order_id, amount in rows
|
||||
}
|
||||
|
||||
|
||||
def _sample_type_names(
|
||||
db: Session, *, presign_order_id: str, tenant_no: str
|
||||
) -> str:
|
||||
@@ -199,43 +335,48 @@ def replace_sample_types(
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
sample_type_ids: List[str],
|
||||
create_dept_id: str = "",
|
||||
) -> None:
|
||||
now = _now()
|
||||
existing = (
|
||||
db.query(BizPresignOrderSampleType)
|
||||
.filter(
|
||||
BizPresignOrderSampleType.presign_order_id == presign_order_id,
|
||||
BizPresignOrderSampleType.tenant_no == tenant_no,
|
||||
BizPresignOrderSampleType.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for r in existing:
|
||||
r.del_flag = 1
|
||||
r.update_by = user_id
|
||||
r.update_time = now
|
||||
db.add(r)
|
||||
payload: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
for idx, tid in enumerate(sample_type_ids or []):
|
||||
for tid in sample_type_ids or []:
|
||||
sid = (tid or "").strip()
|
||||
if not sid or sid in seen:
|
||||
continue
|
||||
seen.add(sid)
|
||||
db.add(
|
||||
BizPresignOrderSampleType(
|
||||
id=generate_uuid(),
|
||||
presign_order_id=presign_order_id,
|
||||
sample_type_id=sid,
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
payload.append({"sample_type_id": sid})
|
||||
|
||||
def build_row(row_id: str) -> BizPresignOrderSampleType:
|
||||
return BizPresignOrderSampleType(
|
||||
id=row_id,
|
||||
presign_order_id=presign_order_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row: BizPresignOrderSampleType, item: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
row.sample_type_id = item["sample_type_id"]
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizPresignOrderSampleType,
|
||||
parent_attr="presign_order_id",
|
||||
parent_id=presign_order_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.get("sample_type_id") or "").strip(),
|
||||
row_natural_key=lambda row: (row.sample_type_id or "").strip(),
|
||||
duplicate_message="样本类型编号重复",
|
||||
foreign_message="样本类型不属于当前预签约订单",
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
@@ -256,10 +397,20 @@ def list_transfers(
|
||||
|
||||
|
||||
def order_to_dict(
|
||||
db: Session, o: BizPresignOrder, tenant_no: str, *, with_children: bool = True
|
||||
db: Session,
|
||||
o: BizPresignOrder,
|
||||
tenant_no: str,
|
||||
*,
|
||||
with_children: bool = True,
|
||||
deposit_amount: Optional[Decimal] = None,
|
||||
user_name_map: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
deposit = confirmed_deposit_amount(
|
||||
db, presign_order_id=o.id, tenant_no=tenant_no
|
||||
deposit = (
|
||||
deposit_amount
|
||||
if deposit_amount is not None
|
||||
else confirmed_deposit_amount(
|
||||
db, presign_order_id=o.id, tenant_no=tenant_no
|
||||
)
|
||||
)
|
||||
data: Dict[str, Any] = {
|
||||
"id": o.id,
|
||||
@@ -284,14 +435,17 @@ def order_to_dict(
|
||||
"createTime": o.create_time.strftime("%Y-%m-%d %H:%M:%S") if o.create_time else "",
|
||||
"updateTime": o.update_time.strftime("%Y-%m-%d %H:%M:%S") if o.update_time else "",
|
||||
}
|
||||
name_map = _user_nick_name_map(
|
||||
db,
|
||||
[
|
||||
data["collectorUserId"],
|
||||
data["salesUserId"],
|
||||
data["regionManagerUserId"],
|
||||
],
|
||||
)
|
||||
name_map = user_name_map
|
||||
if name_map is None:
|
||||
name_map = _user_nick_name_map(
|
||||
db,
|
||||
[
|
||||
data["collectorUserId"],
|
||||
data["salesUserId"],
|
||||
data["regionManagerUserId"],
|
||||
],
|
||||
tenant_no,
|
||||
)
|
||||
data["collectorUserName"] = name_map.get(data["collectorUserId"], "")
|
||||
data["salesUserName"] = name_map.get(data["salesUserId"], "")
|
||||
data["regionManagerUserName"] = name_map.get(data["regionManagerUserId"], "")
|
||||
@@ -374,6 +528,8 @@ def create_presign_order(
|
||||
fields: Dict[str, Any],
|
||||
sample_type_ids: Optional[List[str]] = None,
|
||||
transfers: Optional[List[Dict[str, Any]]] = None,
|
||||
create_dept_id: str = "",
|
||||
commit: bool = True,
|
||||
) -> BizPresignOrder:
|
||||
now = _now()
|
||||
sample_collect = fields.get("sample_collect_time") or fields.get("sampleCollectTime")
|
||||
@@ -387,12 +543,15 @@ def create_presign_order(
|
||||
pay_mode = (fields.get("pay_mode") or fields.get("payMode") or "").strip() or None
|
||||
if pay_mode and pay_mode not in ("dj", "fq"):
|
||||
raise ValueError("缴费方式无效")
|
||||
# 协议编号由服务端发号,忽略客户端传入值
|
||||
protocol_no = allocate_next_biz_no(
|
||||
db, tenant_no=tenant_no, kind="collection", user_id=user_id
|
||||
)
|
||||
obj = BizPresignOrder(
|
||||
id=generate_uuid(),
|
||||
order_date=(fields.get("order_date") or fields.get("orderDate") or "").strip()
|
||||
or None,
|
||||
protocol_no=(fields.get("protocol_no") or fields.get("protocolNo") or "").strip()
|
||||
or None,
|
||||
protocol_no=protocol_no,
|
||||
mother_name=(fields.get("mother_name") or fields.get("motherName") or "").strip()
|
||||
or None,
|
||||
expected_delivery_date=(
|
||||
@@ -427,7 +586,7 @@ def create_presign_order(
|
||||
order_num=0,
|
||||
remark=(fields.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
@@ -441,6 +600,7 @@ def create_presign_order(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
sample_type_ids=sample_type_ids or [],
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
for raw in transfers or []:
|
||||
if isinstance(raw, dict):
|
||||
@@ -450,10 +610,14 @@ def create_presign_order(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
fields=raw,
|
||||
create_dept_id=create_dept_id,
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
else:
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
@@ -465,6 +629,8 @@ def update_presign_order(
|
||||
user_id: Optional[str],
|
||||
fields: Dict[str, Any],
|
||||
sample_type_ids: Optional[List[str]] = None,
|
||||
create_dept_id: str = "",
|
||||
commit: bool = True,
|
||||
) -> BizPresignOrder:
|
||||
obj = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no)
|
||||
if (obj.order_status or "") == "converted":
|
||||
@@ -472,8 +638,6 @@ def update_presign_order(
|
||||
mapping = {
|
||||
"order_date": "order_date",
|
||||
"orderDate": "order_date",
|
||||
"protocol_no": "protocol_no",
|
||||
"protocolNo": "protocol_no",
|
||||
"mother_name": "mother_name",
|
||||
"motherName": "mother_name",
|
||||
"expected_delivery_date": "expected_delivery_date",
|
||||
@@ -499,7 +663,6 @@ def update_presign_order(
|
||||
val = fields[k]
|
||||
if attr in (
|
||||
"order_date",
|
||||
"protocol_no",
|
||||
"mother_name",
|
||||
"expected_delivery_date",
|
||||
"region_id",
|
||||
@@ -526,6 +689,11 @@ def update_presign_order(
|
||||
obj.sample_collect_time = parse_datetime(str(v))
|
||||
if obj.pay_mode and obj.pay_mode not in ("dj", "fq"):
|
||||
raise ValueError("缴费方式无效")
|
||||
# 协议编号不可改;历史空号在编辑保存时补发
|
||||
if not (obj.protocol_no or "").strip():
|
||||
obj.protocol_no = allocate_next_biz_no(
|
||||
db, tenant_no=tenant_no, kind="collection", user_id=user_id
|
||||
)
|
||||
obj.update_by = user_id
|
||||
obj.update_time = _now()
|
||||
db.add(obj)
|
||||
@@ -536,9 +704,13 @@ def update_presign_order(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
sample_type_ids=sample_type_ids,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
else:
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
@@ -563,6 +735,7 @@ def add_transfer(
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
fields: Dict[str, Any],
|
||||
create_dept_id: str = "",
|
||||
commit: bool = True,
|
||||
) -> BizPresignTransfer:
|
||||
_get_order_or_raise(db, order_id=presign_order_id, tenant_no=tenant_no)
|
||||
@@ -591,7 +764,7 @@ def add_transfer(
|
||||
order_num=0,
|
||||
remark=(fields.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
@@ -613,76 +786,114 @@ def update_transfer_draft(
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
fields: Dict[str, Any],
|
||||
commit: bool = True,
|
||||
) -> BizPresignTransfer:
|
||||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||||
if t.pay_status != "dfj":
|
||||
raise ValueError("仅已缴费状态可编辑意向金转账笔")
|
||||
raise ValueError("仅已付款状态可编辑意向金转账笔")
|
||||
values: Dict[Any, Any] = {
|
||||
BizPresignTransfer.update_by: user_id,
|
||||
BizPresignTransfer.update_time: _now(),
|
||||
}
|
||||
if "transfer_amount" in fields or "transferAmount" in fields:
|
||||
t.transfer_amount = _dec(
|
||||
values[BizPresignTransfer.transfer_amount] = _dec(
|
||||
fields.get("transfer_amount", fields.get("transferAmount"))
|
||||
)
|
||||
if "transfer_time" in fields or "transferTime" in fields:
|
||||
v = fields.get("transfer_time", fields.get("transferTime"))
|
||||
if v in (None, ""):
|
||||
t.transfer_time = None
|
||||
values[BizPresignTransfer.transfer_time] = None
|
||||
elif isinstance(v, datetime):
|
||||
t.transfer_time = v
|
||||
values[BizPresignTransfer.transfer_time] = v
|
||||
else:
|
||||
t.transfer_time = parse_datetime(str(v))
|
||||
parsed = parse_datetime(str(v))
|
||||
if parsed is None:
|
||||
raise ValueError("转账时间格式无效")
|
||||
values[BizPresignTransfer.transfer_time] = parsed
|
||||
if "transfer_type" in fields or "transferType" in fields:
|
||||
t.transfer_type = (
|
||||
values[BizPresignTransfer.transfer_type] = (
|
||||
str(fields.get("transfer_type", fields.get("transferType")) or "")
|
||||
).strip() or None
|
||||
if "transfer_attachments" in fields or "transferAttachments" in fields:
|
||||
t.transfer_attachments = (
|
||||
values[BizPresignTransfer.transfer_attachments] = (
|
||||
str(
|
||||
fields.get("transfer_attachments", fields.get("transferAttachments"))
|
||||
or ""
|
||||
)
|
||||
).strip() or None
|
||||
if "remark" in fields:
|
||||
t.remark = (fields.get("remark") or "").strip() or None
|
||||
t.update_by = user_id
|
||||
t.update_time = _now()
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
values[BizPresignTransfer.remark] = (
|
||||
fields.get("remark") or ""
|
||||
).strip() or None
|
||||
return _update_transfer_expected_status(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
expected_status="dfj",
|
||||
values=values,
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
|
||||
def submit_receipt_finance(
|
||||
db: Session, *, transfer_id: str, tenant_no: str, user_id: Optional[str]
|
||||
db: Session,
|
||||
*,
|
||||
transfer_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
commit: bool = True,
|
||||
) -> BizPresignTransfer:
|
||||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||||
if not can_submit_receipt(t.pay_status):
|
||||
raise ValueError("仅已缴费状态可提交财务确认")
|
||||
raise ValueError("仅已付款状态可提交财务确认")
|
||||
if not (t.transfer_type or "").strip():
|
||||
raise ValueError("请选择转账方式")
|
||||
if t.transfer_amount is None or Decimal(str(t.transfer_amount)) <= 0:
|
||||
raise ValueError("请填写转账金额")
|
||||
t.pay_status = "jfqrz"
|
||||
t.update_by = user_id
|
||||
t.update_time = _now()
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
return _update_transfer_expected_status(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
expected_status="dfj",
|
||||
values={
|
||||
BizPresignTransfer.pay_status: "jfqrz",
|
||||
BizPresignTransfer.update_by: user_id,
|
||||
BizPresignTransfer.update_time: _now(),
|
||||
},
|
||||
extra_conditions=(
|
||||
BizPresignTransfer.transfer_amount.isnot(None),
|
||||
BizPresignTransfer.transfer_amount > 0,
|
||||
BizPresignTransfer.transfer_type.isnot(None),
|
||||
func.length(func.trim(BizPresignTransfer.transfer_type)) > 0,
|
||||
),
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
|
||||
def withdraw_receipt(
|
||||
db: Session, *, transfer_id: str, tenant_no: str, user_id: Optional[str]
|
||||
db: Session,
|
||||
*,
|
||||
transfer_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
commit: bool = True,
|
||||
) -> BizPresignTransfer:
|
||||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||||
if not can_withdraw_receipt(t.pay_status):
|
||||
raise ValueError("仅缴费确认中可撤回")
|
||||
t.pay_status = "dfj"
|
||||
raise ValueError("仅付款确认中可撤回")
|
||||
# 故意不改 finance_remark / finance_confirm_attachments
|
||||
t.update_by = user_id
|
||||
t.update_time = _now()
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
return _update_transfer_expected_status(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
expected_status="jfqrz",
|
||||
values={
|
||||
BizPresignTransfer.pay_status: "dfj",
|
||||
BizPresignTransfer.update_by: user_id,
|
||||
BizPresignTransfer.update_time: _now(),
|
||||
},
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
|
||||
def confirm_receipt(
|
||||
@@ -693,36 +904,57 @@ def confirm_receipt(
|
||||
user_id: Optional[str],
|
||||
finance_confirm_attachments: Optional[str] = None,
|
||||
finance_remark: Optional[str] = None,
|
||||
commit: bool = True,
|
||||
) -> BizPresignTransfer:
|
||||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||||
if not can_confirm_receipt(t.pay_status):
|
||||
raise ValueError("仅缴费确认中状态可确认收款")
|
||||
t.pay_status = "yjf"
|
||||
raise ValueError("仅付款确认中状态可确认收款")
|
||||
values: Dict[Any, Any] = {
|
||||
BizPresignTransfer.pay_status: "yjf",
|
||||
BizPresignTransfer.update_by: user_id,
|
||||
BizPresignTransfer.update_time: _now(),
|
||||
}
|
||||
if finance_confirm_attachments is not None:
|
||||
t.finance_confirm_attachments = (finance_confirm_attachments or "").strip() or None
|
||||
values[BizPresignTransfer.finance_confirm_attachments] = (
|
||||
finance_confirm_attachments or ""
|
||||
).strip() or None
|
||||
if finance_remark is not None:
|
||||
t.finance_remark = (finance_remark or "").strip() or None
|
||||
t.update_by = user_id
|
||||
t.update_time = _now()
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
values[BizPresignTransfer.finance_remark] = (
|
||||
finance_remark or ""
|
||||
).strip() or None
|
||||
return _update_transfer_expected_status(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
expected_status="jfqrz",
|
||||
values=values,
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
|
||||
def reject_receipt(
|
||||
db: Session, *, transfer_id: str, tenant_no: str, user_id: Optional[str]
|
||||
db: Session,
|
||||
*,
|
||||
transfer_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
commit: bool = True,
|
||||
) -> BizPresignTransfer:
|
||||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||||
if not can_confirm_receipt(t.pay_status):
|
||||
raise ValueError("仅缴费确认中状态可退回")
|
||||
t.pay_status = "dfj"
|
||||
t.update_by = user_id
|
||||
t.update_time = _now()
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
raise ValueError("仅付款确认中状态可退回")
|
||||
return _update_transfer_expected_status(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
expected_status="jfqrz",
|
||||
values={
|
||||
BizPresignTransfer.pay_status: "dfj",
|
||||
BizPresignTransfer.update_by: user_id,
|
||||
BizPresignTransfer.update_time: _now(),
|
||||
},
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
|
||||
def list_presign_transfers_for_finance(
|
||||
@@ -801,6 +1033,7 @@ def list_presign_transfers_for_finance(
|
||||
(order.sales_user_id if order else "") or "",
|
||||
(order.region_manager_user_id if order else "") or "",
|
||||
],
|
||||
tenant_no,
|
||||
)
|
||||
item["collectorUserName"] = name_map.get(
|
||||
(order.collector_user_id if order else "") or "", ""
|
||||
@@ -828,12 +1061,23 @@ def convert_to_protocol(
|
||||
order_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
create_dept_id: str = "",
|
||||
commit: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""转正式协议:创建协议主单 + 当事人/采集信息 + 样本类型行;意向金仅关联预签订单 ID,不拷贝。"""
|
||||
from app.crud import collection_protocol as cp_svc
|
||||
|
||||
order = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no)
|
||||
order = _get_order_or_raise(
|
||||
db, order_id=order_id, tenant_no=tenant_no, for_update=True
|
||||
)
|
||||
assert_presign_convertible(order_status=order.order_status)
|
||||
validate_presign_person_ids(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
collector_user_id=order.collector_user_id or user_id,
|
||||
sales_user_id=order.sales_user_id,
|
||||
region_manager_user_id=order.region_manager_user_id,
|
||||
)
|
||||
|
||||
staff = resolve_convert_staff_fields(
|
||||
collector_user_id=order.collector_user_id,
|
||||
@@ -843,55 +1087,97 @@ def convert_to_protocol(
|
||||
)
|
||||
|
||||
type_ids = list_sample_type_ids(db, presign_order_id=order.id, tenant_no=tenant_no)
|
||||
protocol = cp_svc.create_collection_protocol(
|
||||
db,
|
||||
protocol_no=order.protocol_no or "",
|
||||
protocol_status="dsx",
|
||||
register_date=order.order_date,
|
||||
collector_user_id=staff["collector_user_id"],
|
||||
source_presign_order_id=order.id,
|
||||
payment_model_version=2,
|
||||
protocol_total_amount=None,
|
||||
remark=order.remark,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
party_fields: Dict[str, Any] = {
|
||||
"mother_name": order.mother_name,
|
||||
"expected_delivery_date": order.expected_delivery_date,
|
||||
}
|
||||
cp_svc.upsert_party_customer(
|
||||
db, protocol.id, tenant_no, user_id, **party_fields
|
||||
)
|
||||
collection_fields: Dict[str, Any] = {
|
||||
"region_id": order.region_id,
|
||||
"hospital_id": order.hospital_id,
|
||||
"sales_user_id": staff["sales_user_id"],
|
||||
"region_manager_user_id": staff["region_manager_user_id"],
|
||||
"sample_collect_time": order.sample_collect_time,
|
||||
}
|
||||
cp_svc.upsert_collection_info(
|
||||
db, protocol.id, tenant_no, user_id, **collection_fields
|
||||
)
|
||||
if type_ids:
|
||||
samples = [
|
||||
{
|
||||
"sampleTypeId": tid,
|
||||
"sampleStatus": None,
|
||||
}
|
||||
for tid in type_ids
|
||||
]
|
||||
cp_svc.save_samples(db, protocol.id, tenant_no, user_id, samples)
|
||||
try:
|
||||
protocol = cp_svc.create_collection_protocol(
|
||||
db,
|
||||
protocol_no=order.protocol_no or "",
|
||||
protocol_status="sxz",
|
||||
register_date=order.order_date,
|
||||
collector_user_id=staff["collector_user_id"],
|
||||
source_presign_order_id=order.id,
|
||||
payment_model_version=2,
|
||||
protocol_total_amount=None,
|
||||
remark=order.remark,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
commit=False,
|
||||
exclude_presign_order_id=order.id,
|
||||
)
|
||||
party_fields: Dict[str, Any] = {
|
||||
"mother_name": order.mother_name,
|
||||
"expected_delivery_date": order.expected_delivery_date,
|
||||
}
|
||||
cp_svc.upsert_party_customer(
|
||||
db,
|
||||
protocol.id,
|
||||
tenant_no,
|
||||
user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
commit=False,
|
||||
**party_fields,
|
||||
)
|
||||
collection_fields: Dict[str, Any] = {
|
||||
"region_id": order.region_id,
|
||||
"hospital_id": order.hospital_id,
|
||||
"sales_user_id": staff["sales_user_id"],
|
||||
"region_manager_user_id": staff["region_manager_user_id"],
|
||||
"sample_collect_time": order.sample_collect_time,
|
||||
}
|
||||
cp_svc.upsert_collection_info(
|
||||
db,
|
||||
protocol.id,
|
||||
tenant_no,
|
||||
user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
commit=False,
|
||||
**collection_fields,
|
||||
)
|
||||
if type_ids:
|
||||
samples = [
|
||||
{
|
||||
"sampleTypeId": tid,
|
||||
"sampleStatus": None,
|
||||
}
|
||||
for tid in type_ids
|
||||
]
|
||||
cp_svc.save_samples(
|
||||
db,
|
||||
protocol.id,
|
||||
tenant_no,
|
||||
user_id,
|
||||
samples,
|
||||
create_dept_id=create_dept_id,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
order.order_status = "converted"
|
||||
order.converted_protocol_id = protocol.id
|
||||
if not (order.protocol_no or "").strip():
|
||||
order.protocol_no = protocol.protocol_no
|
||||
order.update_by = user_id
|
||||
order.update_time = _now()
|
||||
db.add(order)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
order.order_status = "converted"
|
||||
order.converted_protocol_id = protocol.id
|
||||
if not (order.protocol_no or "").strip():
|
||||
order.protocol_no = protocol.protocol_no
|
||||
order.update_by = user_id
|
||||
order.update_time = _now()
|
||||
db.add(order)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
else:
|
||||
db.flush()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
message = str(exc).lower()
|
||||
if (
|
||||
"uk_collection_protocol_presign_source" in message
|
||||
or (
|
||||
"unique constraint failed" in message
|
||||
and "source_presign_order_id" in message
|
||||
)
|
||||
):
|
||||
raise ValueError("该预签约订单已转正式协议") from exc
|
||||
raise
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
return {
|
||||
"presignOrder": order_to_dict(db, order, tenant_no, with_children=True),
|
||||
"protocolId": protocol.id,
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import and_, exists, 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.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
BizCellSampleType,
|
||||
@@ -38,7 +39,10 @@ from app.services.protocol_transfer_status import (
|
||||
from app.services.sample_payment_fgd_convert import plan_fq_to_fgd_merge
|
||||
from app.services.sample_payment_period_summary import summarize_period
|
||||
from app.services.sample_payment_schedule_validate import schedule_dues_match_contract
|
||||
from app.services.sample_payment_progress import compute_sample_payment_progress
|
||||
from app.services.sample_payment_progress import (
|
||||
compute_sample_payment_progress,
|
||||
is_schedule_effectively_cleared,
|
||||
)
|
||||
|
||||
|
||||
def _fmt_ymd8(v: Any) -> str:
|
||||
@@ -127,6 +131,108 @@ def _get_sample_payment_or_raise(
|
||||
return obj
|
||||
|
||||
|
||||
def _lock_sample_payment_graph(
|
||||
db: Session,
|
||||
*,
|
||||
sample_payment_id: str,
|
||||
tenant_no: str,
|
||||
) -> Tuple[
|
||||
BizProtocolSamplePayment,
|
||||
List[BizProtocolPaymentSchedule],
|
||||
List[BizProtocolPaymentTransfer],
|
||||
]:
|
||||
"""按方案→批次→转账顺序加行锁,供普通方案保存做最终一致性校验。"""
|
||||
sp = (
|
||||
db.query(BizProtocolSamplePayment)
|
||||
.filter(
|
||||
BizProtocolSamplePayment.id == sample_payment_id,
|
||||
BizProtocolSamplePayment.tenant_no == tenant_no,
|
||||
BizProtocolSamplePayment.del_flag == 0,
|
||||
)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if not sp:
|
||||
raise ValueError("样本缴费方案不存在")
|
||||
schedules = (
|
||||
db.query(BizProtocolPaymentSchedule)
|
||||
.filter(
|
||||
BizProtocolPaymentSchedule.sample_payment_id == sample_payment_id,
|
||||
BizProtocolPaymentSchedule.tenant_no == tenant_no,
|
||||
BizProtocolPaymentSchedule.del_flag == 0,
|
||||
)
|
||||
.order_by(BizProtocolPaymentSchedule.schedule_no.asc())
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
schedule_ids = [row.id for row in schedules]
|
||||
transfers: List[BizProtocolPaymentTransfer] = []
|
||||
if schedule_ids:
|
||||
transfers = (
|
||||
db.query(BizProtocolPaymentTransfer)
|
||||
.filter(
|
||||
BizProtocolPaymentTransfer.schedule_id.in_(schedule_ids),
|
||||
BizProtocolPaymentTransfer.tenant_no == tenant_no,
|
||||
BizProtocolPaymentTransfer.del_flag == 0,
|
||||
)
|
||||
.order_by(BizProtocolPaymentTransfer.id.asc())
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
return sp, schedules, transfers
|
||||
|
||||
|
||||
def _locked_schedule_ids(
|
||||
schedules: List[BizProtocolPaymentSchedule],
|
||||
transfers: List[BizProtocolPaymentTransfer],
|
||||
) -> set:
|
||||
locked = {
|
||||
row.id
|
||||
for row in schedules
|
||||
if (row.schedule_status or "").strip() != "open"
|
||||
}
|
||||
locked.update(
|
||||
row.schedule_id
|
||||
for row in transfers
|
||||
if (row.pay_status or "").strip() != "dfj"
|
||||
)
|
||||
return locked
|
||||
|
||||
|
||||
def _assert_locked_schedule_structure(
|
||||
schedules: List[BizProtocolPaymentSchedule],
|
||||
transfers: List[BizProtocolPaymentTransfer],
|
||||
payload: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
locked_ids = _locked_schedule_ids(schedules, transfers)
|
||||
if not locked_ids:
|
||||
return
|
||||
current_nos = {int(row.schedule_no or 0) for row in schedules}
|
||||
submitted_no_list = [
|
||||
_schedule_no_of(raw, idx + 1)
|
||||
for idx, raw in enumerate(payload or [])
|
||||
if isinstance(raw, dict)
|
||||
]
|
||||
if len(submitted_no_list) != len(set(submitted_no_list)):
|
||||
raise ValueError("应缴批次期号重复")
|
||||
submitted_nos = set(submitted_no_list)
|
||||
locked_nos = {
|
||||
int(row.schedule_no or 0) for row in schedules if row.id in locked_ids
|
||||
}
|
||||
if submitted_nos != current_nos or not locked_nos.issubset(submitted_nos):
|
||||
raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
|
||||
by_id = {row.id: int(row.schedule_no or 0) for row in schedules}
|
||||
for idx, raw in enumerate(payload or []):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
schedule_id = str(raw.get("id") or raw.get("scheduleId") or "").strip()
|
||||
if schedule_id in locked_ids and _schedule_no_of(raw, idx + 1) != by_id[schedule_id]:
|
||||
raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
|
||||
|
||||
|
||||
def _get_schedule_or_raise(
|
||||
db: Session, *, schedule_id: str, tenant_no: str
|
||||
) -> BizProtocolPaymentSchedule:
|
||||
@@ -161,6 +267,102 @@ def _get_transfer_or_raise(
|
||||
return obj
|
||||
|
||||
|
||||
def _get_transfer_with_schedule_lock(
|
||||
db: Session,
|
||||
*,
|
||||
transfer_id: str,
|
||||
tenant_no: str,
|
||||
user_info: Optional[dict] = None,
|
||||
) -> BizProtocolPaymentTransfer:
|
||||
"""Authorize the complete parent chain, then lock schedule before transfer."""
|
||||
parent = (
|
||||
db.query(
|
||||
BizProtocolPaymentTransfer.schedule_id,
|
||||
BizProtocolPaymentTransfer.sample_payment_id,
|
||||
BizProtocolPaymentTransfer.collection_protocol_id,
|
||||
)
|
||||
.filter(
|
||||
BizProtocolPaymentTransfer.id == transfer_id,
|
||||
BizProtocolPaymentTransfer.tenant_no == tenant_no,
|
||||
BizProtocolPaymentTransfer.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not parent:
|
||||
if user_info is not None:
|
||||
raise PermissionError("无权操作该协议")
|
||||
raise ValueError("转账笔不存在")
|
||||
schedule_id = parent.schedule_id
|
||||
schedule_query = (
|
||||
db.query(BizProtocolPaymentSchedule)
|
||||
.join(
|
||||
BizProtocolSamplePayment,
|
||||
and_(
|
||||
BizProtocolSamplePayment.id == parent.sample_payment_id,
|
||||
BizProtocolSamplePayment.id
|
||||
== BizProtocolPaymentSchedule.sample_payment_id,
|
||||
BizProtocolSamplePayment.collection_protocol_id
|
||||
== parent.collection_protocol_id,
|
||||
BizProtocolSamplePayment.tenant_no == tenant_no,
|
||||
BizProtocolSamplePayment.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.join(
|
||||
BizCollectionProtocol,
|
||||
and_(
|
||||
BizCollectionProtocol.id == parent.collection_protocol_id,
|
||||
BizCollectionProtocol.id
|
||||
== BizProtocolPaymentSchedule.collection_protocol_id,
|
||||
BizCollectionProtocol.tenant_no == tenant_no,
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
BizProtocolPaymentSchedule.id == schedule_id,
|
||||
BizProtocolPaymentSchedule.sample_payment_id
|
||||
== parent.sample_payment_id,
|
||||
BizProtocolPaymentSchedule.collection_protocol_id
|
||||
== parent.collection_protocol_id,
|
||||
BizProtocolPaymentSchedule.tenant_no == tenant_no,
|
||||
BizProtocolPaymentSchedule.del_flag == 0,
|
||||
)
|
||||
)
|
||||
if user_info is not None:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
|
||||
schedule_query = apply_data_scope(
|
||||
schedule_query,
|
||||
BizCollectionProtocol,
|
||||
user_info,
|
||||
db,
|
||||
tenant_no,
|
||||
)
|
||||
schedule = schedule_query.populate_existing().with_for_update().first()
|
||||
if not schedule:
|
||||
if user_info is not None:
|
||||
raise PermissionError("无权操作该协议")
|
||||
raise ValueError("应缴批次不存在")
|
||||
transfer = (
|
||||
db.query(BizProtocolPaymentTransfer)
|
||||
.filter(
|
||||
BizProtocolPaymentTransfer.id == transfer_id,
|
||||
BizProtocolPaymentTransfer.schedule_id == schedule_id,
|
||||
BizProtocolPaymentTransfer.sample_payment_id
|
||||
== parent.sample_payment_id,
|
||||
BizProtocolPaymentTransfer.collection_protocol_id
|
||||
== parent.collection_protocol_id,
|
||||
BizProtocolPaymentTransfer.tenant_no == tenant_no,
|
||||
BizProtocolPaymentTransfer.del_flag == 0,
|
||||
)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if not transfer:
|
||||
raise ValueError("转账笔不存在")
|
||||
return transfer
|
||||
|
||||
|
||||
def has_blocking_transfer_for_sample_payment(
|
||||
db: Session, *, sample_payment_id: str, tenant_no: str
|
||||
) -> bool:
|
||||
@@ -180,7 +382,7 @@ def has_blocking_transfer_for_sample_payment(
|
||||
def assert_sample_payment_structure_editable(
|
||||
db: Session, *, sample_payment_id: str, tenant_no: str
|
||||
) -> None:
|
||||
"""已废弃:缴费确认中不再阻断协议保存/改方案。保留空实现以兼容旧调用。"""
|
||||
"""已废弃:付款确认中不再阻断协议保存/改方案。保留空实现以兼容旧调用。"""
|
||||
return
|
||||
|
||||
|
||||
@@ -197,15 +399,21 @@ def find_active_sample_payment(
|
||||
sample_code: Optional[str] = None,
|
||||
) -> Optional[BizProtocolSamplePayment]:
|
||||
if sample_payment_id:
|
||||
return (
|
||||
db.query(BizProtocolSamplePayment)
|
||||
.filter(
|
||||
BizProtocolSamplePayment.id == sample_payment_id,
|
||||
BizProtocolSamplePayment.tenant_no == tenant_no,
|
||||
BizProtocolSamplePayment.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
query = db.query(BizProtocolSamplePayment).filter(
|
||||
BizProtocolSamplePayment.id == sample_payment_id,
|
||||
BizProtocolSamplePayment.tenant_no == tenant_no,
|
||||
BizProtocolSamplePayment.del_flag == 0,
|
||||
)
|
||||
if collection_protocol_id:
|
||||
query = query.filter(
|
||||
BizProtocolSamplePayment.collection_protocol_id
|
||||
== collection_protocol_id
|
||||
)
|
||||
if sample_code:
|
||||
query = query.filter(
|
||||
BizProtocolSamplePayment.sample_code == sample_code.strip()
|
||||
)
|
||||
return query.first()
|
||||
code = (sample_code or "").strip()
|
||||
pid = (collection_protocol_id or "").strip()
|
||||
if not code or not pid:
|
||||
@@ -357,9 +565,15 @@ def upsert_sample_payment(
|
||||
|
||||
obj: Optional[BizProtocolSamplePayment] = None
|
||||
if sample_payment_id:
|
||||
obj = _get_sample_payment_or_raise(
|
||||
db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
|
||||
obj = find_active_sample_payment(
|
||||
db,
|
||||
sample_payment_id=sample_payment_id,
|
||||
collection_protocol_id=collection_protocol_id,
|
||||
sample_code=code,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
if obj is None:
|
||||
raise ValueError("样本缴费方案不存在或不属于当前协议样本")
|
||||
else:
|
||||
obj = (
|
||||
db.query(BizProtocolSamplePayment)
|
||||
@@ -475,6 +689,9 @@ def _sync_transfers_for_schedule(
|
||||
BizProtocolPaymentTransfer.tenant_no == tenant_no,
|
||||
BizProtocolPaymentTransfer.del_flag == 0,
|
||||
)
|
||||
.order_by(BizProtocolPaymentTransfer.id.asc())
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
by_id = {t.id: t for t in existing}
|
||||
@@ -486,14 +703,16 @@ def _sync_transfers_for_schedule(
|
||||
if tid and tid in by_id:
|
||||
t = by_id[tid]
|
||||
keep_ids.add(tid)
|
||||
if not can_edit_transfer(t.pay_status):
|
||||
# 已确认收款:保留挂接,不改内容
|
||||
if (t.pay_status or "").strip() != "dfj":
|
||||
# 非草稿转账由专用状态流转接口维护,普通方案保存整行只读。
|
||||
continue
|
||||
_apply_transfer_editable_fields(t, raw)
|
||||
t.update_by = user_id
|
||||
t.update_time = now
|
||||
db.add(t)
|
||||
continue
|
||||
if tid:
|
||||
raise ValueError("转账笔不存在或不属于当前批次")
|
||||
# 新增转账笔(始终以待缴费入库;提交财务走独立接口)
|
||||
add_transfer(
|
||||
db,
|
||||
@@ -512,10 +731,7 @@ def _sync_transfers_for_schedule(
|
||||
for tid, t in by_id.items():
|
||||
if tid in keep_ids:
|
||||
continue
|
||||
if t.pay_status == "yjf":
|
||||
# 已确认笔不随表单漏传而删除
|
||||
continue
|
||||
if not can_edit_transfer(t.pay_status):
|
||||
if (t.pay_status or "").strip() != "dfj":
|
||||
continue
|
||||
t.del_flag = 1
|
||||
t.update_by = user_id
|
||||
@@ -531,89 +747,83 @@ def replace_schedules(
|
||||
user_id: Optional[str],
|
||||
schedules: List[Dict[str, Any]],
|
||||
) -> List[BizProtocolPaymentSchedule]:
|
||||
"""软删原批次(及其下待缴费转账),重建批次;确认中/已确认笔按期号挂回新批次。"""
|
||||
sp = _get_sample_payment_or_raise(
|
||||
db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
|
||||
"""仅在全部批次为 open、全部转账为 dfj 时按期次更新批次。去掉的期次连同草稿转账一起软删。"""
|
||||
sp, old_schedules, transfers = _lock_sample_payment_graph(
|
||||
db,
|
||||
sample_payment_id=sample_payment_id,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
if _locked_schedule_ids(old_schedules, transfers):
|
||||
raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
|
||||
now = _now()
|
||||
old_schedules = (
|
||||
db.query(BizProtocolPaymentSchedule)
|
||||
.filter(
|
||||
BizProtocolPaymentSchedule.sample_payment_id == sample_payment_id,
|
||||
BizProtocolPaymentSchedule.tenant_no == tenant_no,
|
||||
BizProtocolPaymentSchedule.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
old_no_by_id = {s.id: int(s.schedule_no or 0) for s in old_schedules}
|
||||
old_ids = list(old_no_by_id.keys())
|
||||
preserve: List[Tuple[BizProtocolPaymentTransfer, int]] = []
|
||||
if old_ids:
|
||||
transfers = (
|
||||
db.query(BizProtocolPaymentTransfer)
|
||||
.filter(
|
||||
BizProtocolPaymentTransfer.schedule_id.in_(old_ids),
|
||||
BizProtocolPaymentTransfer.tenant_no == tenant_no,
|
||||
BizProtocolPaymentTransfer.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for t in transfers:
|
||||
if t.pay_status in ("jfqrz", "yjf"):
|
||||
preserve.append((t, old_no_by_id.get(t.schedule_id, 0)))
|
||||
elif t.pay_status == "dfj":
|
||||
t.del_flag = 1
|
||||
t.update_by = user_id
|
||||
t.update_time = now
|
||||
db.add(t)
|
||||
for s in old_schedules:
|
||||
s.del_flag = 1
|
||||
s.update_by = user_id
|
||||
s.update_time = now
|
||||
db.add(s)
|
||||
|
||||
created: List[BizProtocolPaymentSchedule] = []
|
||||
payload = []
|
||||
for idx, raw in enumerate(schedules or []):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
schedule_no = _schedule_no_of(raw, idx + 1)
|
||||
due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
|
||||
schedule_status = (
|
||||
raw.get("schedule_status") or raw.get("scheduleStatus") or "open"
|
||||
payload.append(
|
||||
{
|
||||
"id": (raw.get("id") or "").strip(),
|
||||
"schedule_no": schedule_no,
|
||||
"due_amount": _dec(
|
||||
raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")
|
||||
),
|
||||
"due_date": due_date,
|
||||
"remark": (raw.get("remark") or "").strip() or None,
|
||||
"order_num": idx,
|
||||
}
|
||||
)
|
||||
obj = BizProtocolPaymentSchedule(
|
||||
id=generate_uuid(),
|
||||
|
||||
def build_row(row_id):
|
||||
return BizProtocolPaymentSchedule(
|
||||
id=row_id,
|
||||
sample_payment_id=sp.id,
|
||||
collection_protocol_id=sp.collection_protocol_id,
|
||||
sample_code=sp.sample_code,
|
||||
schedule_no=_schedule_no_of(raw, idx + 1),
|
||||
due_amount=_dec(raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")),
|
||||
due_date=due_date,
|
||||
schedule_status=(str(schedule_status).strip() or "open"),
|
||||
schedule_status="open",
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
remark=(raw.get("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)
|
||||
created.append(obj)
|
||||
db.flush()
|
||||
|
||||
created_by_no = {int(s.schedule_no or 0): s for s in created}
|
||||
fallback = created[0] if created else None
|
||||
for t, old_no in preserve:
|
||||
target = created_by_no.get(old_no) or fallback
|
||||
if not target:
|
||||
def apply_row(row, item, _idx, _now):
|
||||
row.collection_protocol_id = sp.collection_protocol_id
|
||||
row.sample_code = sp.sample_code
|
||||
row.schedule_no = item["schedule_no"]
|
||||
row.due_amount = item["due_amount"]
|
||||
row.due_date = item["due_date"]
|
||||
row.schedule_status = row.schedule_status or "open"
|
||||
row.remark = item["remark"]
|
||||
row.order_num = item["order_num"]
|
||||
|
||||
created = sync_child_rows(
|
||||
db,
|
||||
BizProtocolPaymentSchedule,
|
||||
parent_attr="sample_payment_id",
|
||||
parent_id=sp.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: str(item["schedule_no"]),
|
||||
row_natural_key=lambda row: str(row.schedule_no or ""),
|
||||
duplicate_message="应缴批次期次重复",
|
||||
foreign_message="应缴批次不属于当前缴费方案",
|
||||
reuse_deleted=True,
|
||||
)
|
||||
kept_ids = {row.id for row in created}
|
||||
for transfer in transfers:
|
||||
if transfer.schedule_id in kept_ids:
|
||||
continue
|
||||
t.schedule_id = target.id
|
||||
t.sample_payment_id = sp.id
|
||||
t.update_by = user_id
|
||||
t.update_time = now
|
||||
db.add(t)
|
||||
if preserve:
|
||||
db.flush()
|
||||
transfer.del_flag = 1
|
||||
transfer.update_by = user_id
|
||||
transfer.update_time = now
|
||||
db.add(transfer)
|
||||
db.flush()
|
||||
return created
|
||||
|
||||
|
||||
@@ -627,25 +837,18 @@ def sync_schedules_and_transfers(
|
||||
) -> List[BizProtocolPaymentSchedule]:
|
||||
"""
|
||||
同步应缴批次与转账笔:
|
||||
- 批次期号集合不变:就地更新批次字段,并按笔同步转账(dfj/jfqrz 可增改删;yjf 只读保留)
|
||||
- 批次结构变化:重建批次,确认中/已确认笔按期号挂回
|
||||
- 有非 open 批次或非 dfj 转账:结构固定,状态与非草稿转账只读保留
|
||||
- 全部为 open + dfj:允许重建批次,旧草稿转账随旧批次软删
|
||||
"""
|
||||
_get_sample_payment_or_raise(
|
||||
db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
|
||||
_, old_schedules, locked_transfers = _lock_sample_payment_graph(
|
||||
db,
|
||||
sample_payment_id=sample_payment_id,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
now = _now()
|
||||
old_schedules = (
|
||||
db.query(BizProtocolPaymentSchedule)
|
||||
.filter(
|
||||
BizProtocolPaymentSchedule.sample_payment_id == sample_payment_id,
|
||||
BizProtocolPaymentSchedule.tenant_no == tenant_no,
|
||||
BizProtocolPaymentSchedule.del_flag == 0,
|
||||
)
|
||||
.order_by(BizProtocolPaymentSchedule.schedule_no.asc())
|
||||
.all()
|
||||
)
|
||||
by_no = {int(s.schedule_no or 0): s for s in old_schedules}
|
||||
payload = list(schedules or [])
|
||||
_assert_locked_schedule_structure(old_schedules, locked_transfers, payload)
|
||||
new_nos: List[int] = []
|
||||
for idx, raw in enumerate(payload):
|
||||
if not isinstance(raw, dict):
|
||||
@@ -684,15 +887,10 @@ def sync_schedules_and_transfers(
|
||||
if not sch:
|
||||
raise ValueError("应缴批次数据不一致,请刷新后重试")
|
||||
due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
|
||||
schedule_status = (
|
||||
raw.get("schedule_status") or raw.get("scheduleStatus") or sch.schedule_status
|
||||
or "open"
|
||||
)
|
||||
sch.due_amount = _dec(
|
||||
raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")
|
||||
)
|
||||
sch.due_date = due_date
|
||||
sch.schedule_status = (str(schedule_status).strip() or "open")
|
||||
sch.order_num = idx
|
||||
if "remark" in raw:
|
||||
sch.remark = (raw.get("remark") or "").strip() or None
|
||||
@@ -722,6 +920,7 @@ def add_transfer(
|
||||
transfer_type: Optional[str] = None,
|
||||
transfer_attachments: Optional[str] = None,
|
||||
remark: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
commit: bool = True,
|
||||
) -> BizProtocolPaymentTransfer:
|
||||
sch = _get_schedule_or_raise(db, schedule_id=schedule_id, tenant_no=tenant_no)
|
||||
@@ -748,7 +947,7 @@ def add_transfer(
|
||||
order_num=0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_dept_id=(create_dept_id or "").strip(),
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
@@ -770,8 +969,13 @@ def update_transfer_draft(
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
fields: Dict[str, Any],
|
||||
locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
|
||||
) -> BizProtocolPaymentTransfer:
|
||||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||||
t = locked_transfer or _get_transfer_or_raise(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no
|
||||
)
|
||||
if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
|
||||
raise ValueError("转账笔不存在")
|
||||
if not can_edit_transfer(t.pay_status):
|
||||
raise ValueError("财务已确认收款的转账笔不可编辑")
|
||||
_apply_transfer_editable_fields(t, fields)
|
||||
@@ -797,11 +1001,16 @@ def submit_receipt_finance(
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
draft_fields: Optional[Dict[str, Any]] = None,
|
||||
locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
|
||||
) -> BizProtocolPaymentTransfer:
|
||||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||||
t = locked_transfer or _get_transfer_or_raise(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no
|
||||
)
|
||||
if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
|
||||
raise ValueError("转账笔不存在")
|
||||
if draft_fields:
|
||||
if t.pay_status != "dfj":
|
||||
raise ValueError("仅已缴费状态可编辑转账笔")
|
||||
raise ValueError("仅已付款状态可编辑转账笔")
|
||||
# 就地改草稿字段(不单独 commit)
|
||||
if "transfer_type" in draft_fields or "transferType" in draft_fields:
|
||||
v = draft_fields.get("transfer_type", draft_fields.get("transferType"))
|
||||
@@ -824,7 +1033,7 @@ def submit_receipt_finance(
|
||||
)
|
||||
t.transfer_attachments = (str(v) if v is not None else "").strip() or None
|
||||
if not can_submit_receipt(t.pay_status):
|
||||
raise ValueError("仅已缴费状态可提交财务确认")
|
||||
raise ValueError("仅已付款状态可提交财务确认")
|
||||
_validate_transfer_submit_ready(t)
|
||||
t.pay_status = "jfqrz"
|
||||
t.update_by = user_id
|
||||
@@ -843,8 +1052,13 @@ def confirm_receipt(
|
||||
user_id: Optional[str],
|
||||
finance_confirm_attachments: Optional[str] = None,
|
||||
finance_remark: Optional[str] = None,
|
||||
locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
|
||||
) -> BizProtocolPaymentTransfer:
|
||||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||||
t = locked_transfer or _get_transfer_with_schedule_lock(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no
|
||||
)
|
||||
if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
|
||||
raise ValueError("转账笔不存在")
|
||||
if t.pay_status == "yjf":
|
||||
raise ValueError("该转账笔已确认收款")
|
||||
if not can_confirm_receipt(t.pay_status):
|
||||
@@ -857,19 +1071,73 @@ def confirm_receipt(
|
||||
t.update_by = user_id
|
||||
t.update_time = _now()
|
||||
db.add(t)
|
||||
# 批次已在 _get_transfer_with_schedule_lock 中加锁,此处不再二次 for_update
|
||||
schedule = (
|
||||
db.query(BizProtocolPaymentSchedule)
|
||||
.filter(
|
||||
BizProtocolPaymentSchedule.id == t.schedule_id,
|
||||
BizProtocolPaymentSchedule.tenant_no == tenant_no,
|
||||
BizProtocolPaymentSchedule.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
_sync_schedule_cleared_after_confirm(
|
||||
db,
|
||||
schedule=schedule,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
def _sync_schedule_cleared_after_confirm(
|
||||
db: Session,
|
||||
*,
|
||||
schedule: Optional[BizProtocolPaymentSchedule],
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
) -> None:
|
||||
"""财务确认后:实缴覆盖应缴则回写 schedule_status=cleared(调用方须已持有批次锁)。"""
|
||||
if not schedule:
|
||||
return
|
||||
status = (schedule.schedule_status or "").strip()
|
||||
if status in ("cleared", "void"):
|
||||
return
|
||||
confirmed = (
|
||||
db.query(func.coalesce(func.sum(BizProtocolPaymentTransfer.transfer_amount), 0))
|
||||
.filter(
|
||||
BizProtocolPaymentTransfer.schedule_id == schedule.id,
|
||||
BizProtocolPaymentTransfer.tenant_no == tenant_no,
|
||||
BizProtocolPaymentTransfer.del_flag == 0,
|
||||
BizProtocolPaymentTransfer.pay_status == "yjf",
|
||||
)
|
||||
.scalar()
|
||||
or Decimal("0")
|
||||
)
|
||||
due = Decimal(str(schedule.due_amount or 0))
|
||||
if Decimal(str(confirmed)) < due:
|
||||
return
|
||||
schedule.schedule_status = "cleared"
|
||||
schedule.update_by = user_id
|
||||
schedule.update_time = _now()
|
||||
db.add(schedule)
|
||||
|
||||
|
||||
def reject_receipt(
|
||||
db: Session,
|
||||
*,
|
||||
transfer_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
|
||||
) -> BizProtocolPaymentTransfer:
|
||||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||||
t = locked_transfer or _get_transfer_with_schedule_lock(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no
|
||||
)
|
||||
if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
|
||||
raise ValueError("转账笔不存在")
|
||||
if t.pay_status != "jfqrz":
|
||||
raise ValueError("当前状态不可退回")
|
||||
t.pay_status = "dfj"
|
||||
@@ -887,11 +1155,16 @@ def withdraw_receipt(
|
||||
transfer_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
|
||||
) -> BizProtocolPaymentTransfer:
|
||||
"""业务撤回:jfqrz → dfj,保留 finance_remark / finance_confirm_attachments。"""
|
||||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||||
t = locked_transfer or _get_transfer_with_schedule_lock(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no
|
||||
)
|
||||
if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
|
||||
raise ValueError("转账笔不存在")
|
||||
if not can_withdraw_receipt(t.pay_status):
|
||||
raise ValueError("仅缴费确认中可撤回")
|
||||
raise ValueError("仅付款确认中可撤回")
|
||||
t.pay_status = "dfj"
|
||||
t.update_by = user_id
|
||||
t.update_time = _now()
|
||||
@@ -910,7 +1183,7 @@ def soft_delete_payment_transfer(
|
||||
) -> None:
|
||||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||||
if t.pay_status != "dfj":
|
||||
raise ValueError("仅待缴费(已缴费草稿)转账笔可删除")
|
||||
raise ValueError("仅已付款状态转账笔可删除")
|
||||
t.del_flag = 1
|
||||
t.update_by = user_id
|
||||
t.update_time = _now()
|
||||
@@ -918,8 +1191,18 @@ def soft_delete_payment_transfer(
|
||||
db.commit()
|
||||
|
||||
|
||||
def _money_value(value: Any, as_string: bool = False) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
amount = Decimal(str(value)).quantize(Decimal('0.01'))
|
||||
return format(amount, 'f') if as_string else float(amount)
|
||||
|
||||
|
||||
def _transfer_to_dict(
|
||||
db: Session, t: BizProtocolPaymentTransfer, tenant_no: str
|
||||
db: Session,
|
||||
t: BizProtocolPaymentTransfer,
|
||||
tenant_no: str,
|
||||
money_as_string: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": t.id,
|
||||
@@ -927,7 +1210,7 @@ def _transfer_to_dict(
|
||||
"samplePaymentId": t.sample_payment_id,
|
||||
"collectionProtocolId": t.collection_protocol_id,
|
||||
"sampleCode": t.sample_code or "",
|
||||
"transferAmount": float(t.transfer_amount) if t.transfer_amount is not None else None,
|
||||
"transferAmount": _money_value(t.transfer_amount, money_as_string),
|
||||
"transferTime": t.transfer_time.strftime("%Y-%m-%d %H:%M:%S") if t.transfer_time else "",
|
||||
"transferType": t.transfer_type or "",
|
||||
"transferAttachments": t.transfer_attachments or "",
|
||||
@@ -950,8 +1233,9 @@ def _schedule_to_dict(
|
||||
s: BizProtocolPaymentSchedule,
|
||||
transfers: List[BizProtocolPaymentTransfer],
|
||||
tenant_no: str,
|
||||
money_as_string: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
# 展示口径与缴费信息期次一致:已缴 = dfj + jfqrz + yjf
|
||||
# 展示口径:已缴仅计 yjf(与 summarize_period / chase_period_money 一致)
|
||||
paid, arrears = chase_period_money(
|
||||
due_amount=s.due_amount,
|
||||
transfers=[
|
||||
@@ -966,19 +1250,27 @@ def _schedule_to_dict(
|
||||
for t in transfers
|
||||
],
|
||||
)
|
||||
schedule_status = s.schedule_status or ""
|
||||
if schedule_status != "void" and is_schedule_effectively_cleared(
|
||||
{"scheduleStatus": schedule_status, "dueAmount": s.due_amount},
|
||||
confirmed_paid=Decimal(str(paid or 0)),
|
||||
):
|
||||
schedule_status = "cleared"
|
||||
return {
|
||||
"id": s.id,
|
||||
"samplePaymentId": s.sample_payment_id,
|
||||
"collectionProtocolId": s.collection_protocol_id,
|
||||
"sampleCode": s.sample_code or "",
|
||||
"scheduleNo": s.schedule_no,
|
||||
"dueAmount": float(s.due_amount) if s.due_amount is not None else None,
|
||||
"dueAmount": _money_value(s.due_amount, money_as_string),
|
||||
"dueDate": s.due_date or "",
|
||||
"scheduleStatus": s.schedule_status or "",
|
||||
"paidAmount": float(paid),
|
||||
"arrearsAmount": float(arrears),
|
||||
"scheduleStatus": schedule_status,
|
||||
"paidAmount": _money_value(paid, money_as_string),
|
||||
"arrearsAmount": _money_value(arrears, money_as_string),
|
||||
"remark": s.remark or "",
|
||||
"transfers": [_transfer_to_dict(db, t, tenant_no) for t in transfers],
|
||||
"transfers": [
|
||||
_transfer_to_dict(db, t, tenant_no, money_as_string) for t in transfers
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -988,6 +1280,7 @@ def _sample_payment_to_dict(
|
||||
schedules: List[BizProtocolPaymentSchedule],
|
||||
transfers_by_schedule: Dict[str, List[BizProtocolPaymentTransfer]],
|
||||
tenant_no: str,
|
||||
money_as_string: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": p.id,
|
||||
@@ -995,7 +1288,7 @@ def _sample_payment_to_dict(
|
||||
"protocolSampleId": p.protocol_sample_id or "",
|
||||
"sampleCode": p.sample_code or "",
|
||||
"payMode": p.pay_mode or "",
|
||||
"contractAmount": float(p.contract_amount) if p.contract_amount is not None else None,
|
||||
"contractAmount": _money_value(p.contract_amount, money_as_string),
|
||||
"installmentCount": p.installment_count,
|
||||
"installmentIntervalMonths": float(p.installment_interval_months)
|
||||
if p.installment_interval_months is not None
|
||||
@@ -1007,7 +1300,7 @@ def _sample_payment_to_dict(
|
||||
"remark": p.remark or "",
|
||||
"schedules": [
|
||||
_schedule_to_dict(
|
||||
db, s, transfers_by_schedule.get(s.id, []), tenant_no
|
||||
db, s, transfers_by_schedule.get(s.id, []), tenant_no, money_as_string
|
||||
)
|
||||
for s in schedules
|
||||
],
|
||||
@@ -1018,7 +1311,19 @@ def _normalize_installment_ratios(v: Any) -> Optional[str]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, (list, dict)):
|
||||
return json.dumps(v, ensure_ascii=False)
|
||||
def normalize_decimal(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return format(value.quantize(Decimal("0.01")), "f")
|
||||
if isinstance(value, list):
|
||||
return [normalize_decimal(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: normalize_decimal(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return value
|
||||
|
||||
return json.dumps(normalize_decimal(v), ensure_ascii=False)
|
||||
return str(v).strip() or None
|
||||
|
||||
|
||||
@@ -1075,8 +1380,12 @@ def save_sample_payments_for_protocol(
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
sample_payments: List[Dict[str, Any]],
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
"""按样本保存缴费方案:upsert 方案 → 同步批次与转账笔(确认中可改,已确认只读)。"""
|
||||
"""按样本保存缴费方案;锁定批次/转账所在方案禁止结构性变更。
|
||||
|
||||
commit=False 时仅 flush,由调用方统一提交,避免协议其它子资源已提交后缴费校验失败导致半成功。
|
||||
"""
|
||||
existing = (
|
||||
db.query(BizProtocolSamplePayment)
|
||||
.filter(
|
||||
@@ -1084,10 +1393,44 @@ def save_sample_payments_for_protocol(
|
||||
BizProtocolSamplePayment.tenant_no == tenant_no,
|
||||
BizProtocolSamplePayment.del_flag == 0,
|
||||
)
|
||||
.order_by(BizProtocolSamplePayment.id.asc())
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
payload_rows = [raw for raw in (sample_payments or []) if isinstance(raw, dict)]
|
||||
payload_by_id = {
|
||||
str(raw.get("id") or raw.get("samplePaymentId") or "").strip(): raw
|
||||
for raw in payload_rows
|
||||
if str(raw.get("id") or raw.get("samplePaymentId") or "").strip()
|
||||
}
|
||||
payload_by_code = {
|
||||
str(raw.get("sample_code") or raw.get("sampleCode") or "").strip(): raw
|
||||
for raw in payload_rows
|
||||
if str(raw.get("sample_code") or raw.get("sampleCode") or "").strip()
|
||||
}
|
||||
for current in existing:
|
||||
_, schedules, transfers = _lock_sample_payment_graph(
|
||||
db,
|
||||
sample_payment_id=current.id,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
if not _locked_schedule_ids(schedules, transfers):
|
||||
continue
|
||||
raw = payload_by_id.get(current.id) or payload_by_code.get(current.sample_code)
|
||||
if raw is None:
|
||||
raise ValueError("存在锁定缴费方案,不能省略或删除")
|
||||
submitted_mode = str(raw.get("pay_mode") or raw.get("payMode") or "").strip()
|
||||
if submitted_mode and submitted_mode != (current.pay_mode or "").strip():
|
||||
raise ValueError("存在锁定缴费方案,不能变更缴费模式或重建")
|
||||
_assert_locked_schedule_structure(
|
||||
schedules,
|
||||
transfers,
|
||||
list(raw.get("schedules") or []),
|
||||
)
|
||||
|
||||
keep_ids: set = set()
|
||||
for raw in sample_payments or []:
|
||||
for raw in payload_rows:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
sample_code = (raw.get("sample_code") or raw.get("sampleCode") or "").strip()
|
||||
@@ -1168,11 +1511,18 @@ def save_sample_payments_for_protocol(
|
||||
_soft_delete_sample_payment(
|
||||
db, sp=sp, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
db.commit()
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
|
||||
|
||||
def list_deposit_transfers_for_protocol(
|
||||
db: Session, *, source_presign_order_id: Optional[str], tenant_no: str
|
||||
db: Session,
|
||||
*,
|
||||
source_presign_order_id: Optional[str],
|
||||
tenant_no: str,
|
||||
money_as_string: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""正式协议意向金列表:始终读预签订单 biz_presign_transfer,保证财务审批两边一致。"""
|
||||
pid = (source_presign_order_id or "").strip()
|
||||
@@ -1180,11 +1530,29 @@ def list_deposit_transfers_for_protocol(
|
||||
return []
|
||||
from app.crud.presign_order import list_transfers
|
||||
|
||||
return list_transfers(db, presign_order_id=pid, tenant_no=tenant_no)
|
||||
result = list_transfers(db, presign_order_id=pid, tenant_no=tenant_no)
|
||||
if money_as_string and result:
|
||||
rows = (
|
||||
db.query(BizPresignTransfer)
|
||||
.filter(
|
||||
BizPresignTransfer.id.in_([item.get('id') for item in result]),
|
||||
BizPresignTransfer.tenant_no == tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
amount_by_id = {str(row.id): _money_value(row.transfer_amount, True) for row in rows}
|
||||
for item in result:
|
||||
item['transferAmount'] = amount_by_id.get(str(item.get('id') or ''))
|
||||
return result
|
||||
|
||||
|
||||
def get_presign_deposit_hint(
|
||||
db: Session, *, source_presign_order_id: Optional[str], tenant_no: str
|
||||
db: Session,
|
||||
*,
|
||||
source_presign_order_id: Optional[str],
|
||||
tenant_no: str,
|
||||
money_as_string: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
pid = (source_presign_order_id or "").strip()
|
||||
if not pid:
|
||||
@@ -1205,13 +1573,16 @@ def get_presign_deposit_hint(
|
||||
total += Decimal(str(r.transfer_amount))
|
||||
return {
|
||||
"presignOrderId": pid,
|
||||
"confirmedDepositAmount": float(total),
|
||||
"confirmedDepositAmount": _money_value(total, money_as_string),
|
||||
"confirmedTransferCount": len(rows),
|
||||
}
|
||||
|
||||
|
||||
def get_sample_payments_bundle(
|
||||
db: Session, collection_protocol_id: str, tenant_no: str
|
||||
db: Session,
|
||||
collection_protocol_id: str,
|
||||
tenant_no: str,
|
||||
money_as_string: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
payments = (
|
||||
db.query(BizProtocolSamplePayment)
|
||||
@@ -1268,6 +1639,7 @@ def get_sample_payments_bundle(
|
||||
schedules_by_payment.get(p.id, []),
|
||||
by_schedule,
|
||||
tenant_no,
|
||||
money_as_string,
|
||||
)
|
||||
for p in payments
|
||||
]
|
||||
@@ -1473,6 +1845,7 @@ def _compute_progress_for_payment(
|
||||
) -> Dict[str, Any]:
|
||||
sch_dicts = [
|
||||
{
|
||||
"id": s.id,
|
||||
"scheduleStatus": s.schedule_status or "",
|
||||
"dueAmount": s.due_amount,
|
||||
}
|
||||
@@ -1480,6 +1853,7 @@ def _compute_progress_for_payment(
|
||||
]
|
||||
tr_dicts = [
|
||||
{
|
||||
"scheduleId": t.schedule_id,
|
||||
"payStatus": t.pay_status or "",
|
||||
"transferAmount": t.transfer_amount,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -9,17 +9,29 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from sqlalchemy import and_, exists, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.data_scope import apply_data_scope, get_user_dept_id
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
BizCellSampleType,
|
||||
BizCollectionProtocol,
|
||||
BizProtocolPartyCustomer,
|
||||
BizProtocolSample,
|
||||
BizSampleFreight,
|
||||
BizSampleFreightItem,
|
||||
SysDictData,
|
||||
SysUser,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonation,
|
||||
BizResearchDonationParty,
|
||||
BizResearchDonationSample,
|
||||
)
|
||||
from app.models.research_donation import BizResearchDonation, BizResearchDonationSample
|
||||
from app.services.sample_transport_mode import DICT_TYPE, resolve_transport_mode_value
|
||||
from app.services.sf_express_settlement import (
|
||||
assert_freight_mutable,
|
||||
freight_amount_label,
|
||||
resolve_header_for_save,
|
||||
)
|
||||
|
||||
SAMPLE_SOURCE_CELL = "cell_storage"
|
||||
SAMPLE_SOURCE_DONATION = "research_donation"
|
||||
@@ -40,6 +52,8 @@ def _parse_amount(amount: Any) -> Decimal:
|
||||
val = Decimal(str(amount))
|
||||
except Exception as exc:
|
||||
raise ValueError("运费格式无效") from exc
|
||||
if not val.is_finite():
|
||||
raise ValueError("运费格式无效")
|
||||
if val < 0:
|
||||
raise ValueError("运费不能为负数")
|
||||
return val
|
||||
@@ -103,22 +117,67 @@ def _linked_count(db: Session, freight_id: str, tenant_no: str) -> int:
|
||||
)
|
||||
|
||||
|
||||
def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
|
||||
ids = list({x for x in user_ids if x})
|
||||
if not ids:
|
||||
return {}
|
||||
rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
|
||||
return {u.id: (u.nick_name or "").strip() for u in rows}
|
||||
|
||||
|
||||
def _generate_freight_no(
|
||||
db: Session, tenant_no: str, when: Optional[datetime] = None
|
||||
) -> str:
|
||||
"""生成批次号:YF + YYYYMMDD + 001~999(按租户、按日)。"""
|
||||
day = (when or _now()).strftime("%Y%m%d")
|
||||
prefix = f"YF{day}"
|
||||
existing = (
|
||||
db.query(BizSampleFreight.freight_no)
|
||||
.filter(
|
||||
BizSampleFreight.tenant_no == tenant_no,
|
||||
BizSampleFreight.freight_no.like(f"{prefix}%"),
|
||||
)
|
||||
.order_by(BizSampleFreight.freight_no.desc())
|
||||
.first()
|
||||
)
|
||||
seq = 1
|
||||
if existing and existing[0]:
|
||||
try:
|
||||
seq = int(str(existing[0])[-3:]) + 1
|
||||
except ValueError:
|
||||
seq = 1
|
||||
if seq > 999:
|
||||
raise ValueError("当日运费批次号已达上限(999)")
|
||||
return f"{prefix}{seq:03d}"
|
||||
|
||||
|
||||
def _row_to_dict(
|
||||
obj: BizSampleFreight,
|
||||
*,
|
||||
linked_sample_count: int = 0,
|
||||
transport_mode_label: str = "",
|
||||
create_by_name: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
amount = obj.freight_amount
|
||||
create_by = obj.create_by or ""
|
||||
return {
|
||||
"id": obj.id,
|
||||
"freightNo": getattr(obj, "freight_no", None) or "",
|
||||
"transportMode": obj.transport_mode or "",
|
||||
"transportModeLabel": transport_mode_label or (obj.transport_mode or ""),
|
||||
"freightAmount": float(amount) if amount is not None else 0.0,
|
||||
"freightAmount": float(amount) if amount is not None else None,
|
||||
"freightAmountLabel": freight_amount_label(obj.transport_mode, amount),
|
||||
"waybillNo": getattr(obj, "waybill_no", None) or "",
|
||||
"settleStatus": getattr(obj, "settle_status", None) or "",
|
||||
"settleTime": _fmt_dt(getattr(obj, "settle_time", None))
|
||||
if getattr(obj, "settle_time", None)
|
||||
else "",
|
||||
"linkedSampleCount": int(linked_sample_count or 0),
|
||||
"tenantNo": obj.tenant_no,
|
||||
"orderNum": obj.order_num if obj.order_num is not None else 0,
|
||||
"remark": obj.remark or "",
|
||||
"createBy": create_by,
|
||||
"createByName": create_by_name or create_by,
|
||||
"createTime": _fmt_dt(obj.create_time),
|
||||
"updateTime": _fmt_dt(obj.update_time),
|
||||
}
|
||||
@@ -140,10 +199,14 @@ def _item_to_dict_enriched(
|
||||
*,
|
||||
protocol_no: str = "",
|
||||
sample_type_name: str = "",
|
||||
mother_name: str = "",
|
||||
newborn_name: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
data = _item_to_dict(item)
|
||||
data["protocolNo"] = protocol_no or ""
|
||||
data["sampleTypeName"] = sample_type_name or ""
|
||||
data["motherName"] = mother_name or ""
|
||||
data["newbornName"] = newborn_name or ""
|
||||
return data
|
||||
|
||||
|
||||
@@ -169,12 +232,11 @@ def _enrich_items_maps(
|
||||
db: Session,
|
||||
items: List[BizSampleFreightItem],
|
||||
tenant_no: str,
|
||||
) -> Tuple[Dict[str, str], Dict[str, str]]:
|
||||
"""批量解析 protocolNo / sampleTypeName,key = sample_source|sample_id。"""
|
||||
protocol_no_map: Dict[str, str] = {}
|
||||
type_name_map: Dict[str, str] = {}
|
||||
) -> Dict[str, Dict[str, str]]:
|
||||
"""批量解析协议号/样本类型/母亲子女姓名,key = sample_source|sample_id。"""
|
||||
out: Dict[str, Dict[str, str]] = {}
|
||||
if not items:
|
||||
return protocol_no_map, type_name_map
|
||||
return out
|
||||
|
||||
cell_ids = [
|
||||
i.sample_id
|
||||
@@ -193,6 +255,8 @@ def _enrich_items_maps(
|
||||
BizProtocolSample.id,
|
||||
BizCollectionProtocol.protocol_no,
|
||||
BizCellSampleType.type_name,
|
||||
BizProtocolPartyCustomer.mother_name,
|
||||
BizProtocolPartyCustomer.newborn_name,
|
||||
)
|
||||
.outerjoin(
|
||||
BizCollectionProtocol,
|
||||
@@ -208,6 +272,14 @@ def _enrich_items_maps(
|
||||
BizCellSampleType.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
BizProtocolPartyCustomer,
|
||||
and_(
|
||||
BizProtocolPartyCustomer.collection_protocol_id
|
||||
== BizProtocolSample.collection_protocol_id,
|
||||
BizProtocolPartyCustomer.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
BizProtocolSample.id.in_(cell_ids),
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
@@ -215,10 +287,13 @@ def _enrich_items_maps(
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for sid, pno, tname in rows:
|
||||
key = f"{SAMPLE_SOURCE_CELL}|{sid}"
|
||||
protocol_no_map[key] = pno or ""
|
||||
type_name_map[key] = tname or ""
|
||||
for sid, pno, tname, mname, nname in rows:
|
||||
out[f"{SAMPLE_SOURCE_CELL}|{sid}"] = {
|
||||
"protocolNo": pno or "",
|
||||
"sampleTypeName": tname or "",
|
||||
"motherName": mname or "",
|
||||
"newbornName": nname or "",
|
||||
}
|
||||
|
||||
if donation_ids:
|
||||
rows = (
|
||||
@@ -226,6 +301,8 @@ def _enrich_items_maps(
|
||||
BizResearchDonationSample.id,
|
||||
BizResearchDonation.donation_no,
|
||||
BizCellSampleType.type_name,
|
||||
BizResearchDonationParty.mother_name,
|
||||
BizResearchDonationParty.newborn_name,
|
||||
)
|
||||
.outerjoin(
|
||||
BizResearchDonation,
|
||||
@@ -242,6 +319,14 @@ def _enrich_items_maps(
|
||||
BizCellSampleType.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
BizResearchDonationParty,
|
||||
and_(
|
||||
BizResearchDonationParty.research_donation_id
|
||||
== BizResearchDonationSample.research_donation_id,
|
||||
BizResearchDonationParty.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
BizResearchDonationSample.id.in_(donation_ids),
|
||||
BizResearchDonationSample.tenant_no == tenant_no,
|
||||
@@ -249,24 +334,69 @@ def _enrich_items_maps(
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for sid, dno, tname in rows:
|
||||
key = f"{SAMPLE_SOURCE_DONATION}|{sid}"
|
||||
protocol_no_map[key] = dno or ""
|
||||
type_name_map[key] = tname or ""
|
||||
for sid, dno, tname, mname, nname in rows:
|
||||
out[f"{SAMPLE_SOURCE_DONATION}|{sid}"] = {
|
||||
"protocolNo": dno or "",
|
||||
"sampleTypeName": tname or "",
|
||||
"motherName": mname or "",
|
||||
"newbornName": nname or "",
|
||||
}
|
||||
|
||||
return protocol_no_map, type_name_map
|
||||
return out
|
||||
|
||||
|
||||
def get_freight(db: Session, freight_id: str, tenant_no: str) -> Optional[BizSampleFreight]:
|
||||
return (
|
||||
db.query(BizSampleFreight)
|
||||
.filter(
|
||||
BizSampleFreight.id == freight_id,
|
||||
BizSampleFreight.tenant_no == tenant_no,
|
||||
BizSampleFreight.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
def get_freight(
|
||||
db: Session, freight_id: str, tenant_no: str, user_info: Optional[dict] = None
|
||||
) -> Optional[BizSampleFreight]:
|
||||
query = db.query(BizSampleFreight).filter(
|
||||
BizSampleFreight.id == freight_id,
|
||||
BizSampleFreight.tenant_no == tenant_no,
|
||||
BizSampleFreight.del_flag == 0,
|
||||
)
|
||||
return apply_data_scope(query, BizSampleFreight, user_info, db, tenant_no).first()
|
||||
|
||||
|
||||
def _validated_items(
|
||||
db: Session, items: List[Dict[str, Any]], tenant_no: str, user_info: dict
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""写入前校验全部样本的租户和数据范围,快照只从数据库派生。"""
|
||||
validated = []
|
||||
for raw in items:
|
||||
item = _normalize_item(raw)
|
||||
if item["sample_source"] == SAMPLE_SOURCE_CELL:
|
||||
sample_model, parent_model = BizProtocolSample, BizCollectionProtocol
|
||||
parent_key = BizProtocolSample.collection_protocol_id
|
||||
else:
|
||||
sample_model, parent_model = BizResearchDonationSample, BizResearchDonation
|
||||
parent_key = BizResearchDonationSample.research_donation_id
|
||||
query = db.query(sample_model, parent_model).join(
|
||||
parent_model, parent_model.id == parent_key
|
||||
).filter(
|
||||
sample_model.id == item["sample_id"],
|
||||
sample_model.tenant_no == tenant_no,
|
||||
sample_model.del_flag == 0,
|
||||
parent_model.tenant_no == tenant_no,
|
||||
parent_model.del_flag == 0,
|
||||
)
|
||||
row = apply_data_scope(query, parent_model, user_info, db, tenant_no).first()
|
||||
if not row:
|
||||
raise PermissionError("样本不存在或无权访问")
|
||||
existing_links = db.query(BizSampleFreightItem).filter(
|
||||
BizSampleFreightItem.tenant_no == tenant_no,
|
||||
BizSampleFreightItem.sample_source == item["sample_source"],
|
||||
BizSampleFreightItem.sample_id == item["sample_id"],
|
||||
BizSampleFreightItem.del_flag == 0,
|
||||
).all()
|
||||
if any(
|
||||
not get_freight(db, link.freight_id, tenant_no, user_info)
|
||||
for link in existing_links
|
||||
):
|
||||
raise PermissionError("无权修改样本当前关联的运费批次")
|
||||
sample, parent = row
|
||||
item["sample_code"] = sample.sample_code
|
||||
item["protocol_id"] = parent.id
|
||||
validated.append(item)
|
||||
return validated
|
||||
|
||||
|
||||
def list_freights(
|
||||
@@ -281,6 +411,7 @@ def list_freights(
|
||||
create_time_from: Optional[str] = None,
|
||||
create_time_to: Optional[str] = None,
|
||||
include_items: bool = False,
|
||||
user_info: Optional[dict] = 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)))
|
||||
@@ -289,6 +420,8 @@ def list_freights(
|
||||
BizSampleFreight.del_flag == 0,
|
||||
)
|
||||
|
||||
q = apply_data_scope(q, BizSampleFreight, user_info, db, tenant_no)
|
||||
|
||||
mode = (transport_mode or "").strip()
|
||||
if mode:
|
||||
q = q.filter(BizSampleFreight.transport_mode == mode)
|
||||
@@ -332,7 +465,10 @@ def list_freights(
|
||||
)
|
||||
.all()
|
||||
]
|
||||
conds = [BizSampleFreight.transport_mode.like(like)]
|
||||
conds = [
|
||||
BizSampleFreight.transport_mode.like(like),
|
||||
BizSampleFreight.freight_no.like(like),
|
||||
]
|
||||
if label_values:
|
||||
conds.append(BizSampleFreight.transport_mode.in_(label_values))
|
||||
|
||||
@@ -346,34 +482,6 @@ def list_freights(
|
||||
)
|
||||
conds.append(sample_code_exists)
|
||||
|
||||
cell_proto_exists = exists().where(
|
||||
and_(
|
||||
BizSampleFreightItem.freight_id == BizSampleFreight.id,
|
||||
BizSampleFreightItem.tenant_no == tenant_no,
|
||||
BizSampleFreightItem.del_flag == 0,
|
||||
BizSampleFreightItem.sample_source == SAMPLE_SOURCE_CELL,
|
||||
BizSampleFreightItem.protocol_id == BizCollectionProtocol.id,
|
||||
BizCollectionProtocol.tenant_no == tenant_no,
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
BizCollectionProtocol.protocol_no.like(like),
|
||||
)
|
||||
)
|
||||
conds.append(cell_proto_exists)
|
||||
|
||||
donation_proto_exists = exists().where(
|
||||
and_(
|
||||
BizSampleFreightItem.freight_id == BizSampleFreight.id,
|
||||
BizSampleFreightItem.tenant_no == tenant_no,
|
||||
BizSampleFreightItem.del_flag == 0,
|
||||
BizSampleFreightItem.sample_source == SAMPLE_SOURCE_DONATION,
|
||||
BizSampleFreightItem.protocol_id == BizResearchDonation.id,
|
||||
BizResearchDonation.tenant_no == tenant_no,
|
||||
BizResearchDonation.del_flag == 0,
|
||||
BizResearchDonation.donation_no.like(like),
|
||||
)
|
||||
)
|
||||
conds.append(donation_proto_exists)
|
||||
|
||||
q = q.filter(or_(*conds))
|
||||
|
||||
total = q.count()
|
||||
@@ -384,6 +492,7 @@ def list_freights(
|
||||
.all()
|
||||
)
|
||||
labels = _label_map(db, tenant_no, [r.transport_mode for r in rows])
|
||||
name_map = _user_nick_name_map(db, [r.create_by for r in rows if r.create_by])
|
||||
|
||||
items_by_freight: Dict[str, List[BizSampleFreightItem]] = {}
|
||||
if include_items and rows:
|
||||
@@ -403,9 +512,9 @@ def list_freights(
|
||||
)
|
||||
for it in all_items:
|
||||
items_by_freight.setdefault(it.freight_id, []).append(it)
|
||||
protocol_no_map, type_name_map = _enrich_items_maps(db, all_items, tenant_no)
|
||||
enrich_map = _enrich_items_maps(db, all_items, tenant_no)
|
||||
else:
|
||||
protocol_no_map, type_name_map = {}, {}
|
||||
enrich_map = {}
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for r in rows:
|
||||
@@ -418,16 +527,20 @@ def list_freights(
|
||||
r,
|
||||
linked_sample_count=linked,
|
||||
transport_mode_label=labels.get(r.transport_mode or "", r.transport_mode or ""),
|
||||
create_by_name=name_map.get(r.create_by or "", ""),
|
||||
)
|
||||
if include_items:
|
||||
enriched = []
|
||||
for it in items_by_freight.get(r.id, []):
|
||||
key = f"{it.sample_source}|{it.sample_id}"
|
||||
info = enrich_map.get(key, {})
|
||||
enriched.append(
|
||||
_item_to_dict_enriched(
|
||||
it,
|
||||
protocol_no=protocol_no_map.get(key, ""),
|
||||
sample_type_name=type_name_map.get(key, ""),
|
||||
protocol_no=info.get("protocolNo", ""),
|
||||
sample_type_name=info.get("sampleTypeName", ""),
|
||||
mother_name=info.get("motherName", ""),
|
||||
newborn_name=info.get("newbornName", ""),
|
||||
)
|
||||
)
|
||||
data["items"] = enriched
|
||||
@@ -435,8 +548,10 @@ def list_freights(
|
||||
return out, total
|
||||
|
||||
|
||||
def get_freight_detail(db: Session, freight_id: str, tenant_no: str) -> Dict[str, Any]:
|
||||
obj = get_freight(db, freight_id, tenant_no)
|
||||
def get_freight_detail(
|
||||
db: Session, freight_id: str, tenant_no: str, user_info: Optional[dict] = None
|
||||
) -> Dict[str, Any]:
|
||||
obj = get_freight(db, freight_id, tenant_no, user_info)
|
||||
if not obj:
|
||||
raise ValueError("运费批次不存在")
|
||||
items = (
|
||||
@@ -450,20 +565,25 @@ def get_freight_detail(db: Session, freight_id: str, tenant_no: str) -> Dict[str
|
||||
.all()
|
||||
)
|
||||
labels = _label_map(db, tenant_no, [obj.transport_mode])
|
||||
name_map = _user_nick_name_map(db, [obj.create_by] if obj.create_by else [])
|
||||
data = _row_to_dict(
|
||||
obj,
|
||||
linked_sample_count=len(items),
|
||||
transport_mode_label=labels.get(obj.transport_mode or "", obj.transport_mode or ""),
|
||||
create_by_name=name_map.get(obj.create_by or "", ""),
|
||||
)
|
||||
protocol_no_map, type_name_map = _enrich_items_maps(db, items, tenant_no)
|
||||
enrich_map = _enrich_items_maps(db, items, tenant_no)
|
||||
enriched = []
|
||||
for i in items:
|
||||
key = f"{i.sample_source}|{i.sample_id}"
|
||||
info = enrich_map.get(key, {})
|
||||
enriched.append(
|
||||
_item_to_dict_enriched(
|
||||
i,
|
||||
protocol_no=protocol_no_map.get(key, ""),
|
||||
sample_type_name=type_name_map.get(key, ""),
|
||||
protocol_no=info.get("protocolNo", ""),
|
||||
sample_type_name=info.get("sampleTypeName", ""),
|
||||
mother_name=info.get("motherName", ""),
|
||||
newborn_name=info.get("newbornName", ""),
|
||||
)
|
||||
)
|
||||
data["items"] = enriched
|
||||
@@ -478,6 +598,7 @@ def list_sample_options(
|
||||
page_size: int = 20,
|
||||
keyword: Optional[str] = None,
|
||||
sample_source: Optional[str] = None,
|
||||
user_info: Optional[dict] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""可选样本列表(配置运费弹窗),支持细胞存储 / 科研捐赠。"""
|
||||
page = max(1, int(page or 1))
|
||||
@@ -499,6 +620,8 @@ def list_sample_options(
|
||||
BizProtocolSample.create_time,
|
||||
BizCollectionProtocol.protocol_no,
|
||||
BizCellSampleType.type_name,
|
||||
BizProtocolPartyCustomer.mother_name,
|
||||
BizProtocolPartyCustomer.newborn_name,
|
||||
)
|
||||
.outerjoin(
|
||||
BizCollectionProtocol,
|
||||
@@ -515,19 +638,33 @@ def list_sample_options(
|
||||
BizCellSampleType.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
BizProtocolPartyCustomer,
|
||||
and_(
|
||||
BizProtocolPartyCustomer.collection_protocol_id
|
||||
== BizProtocolSample.collection_protocol_id,
|
||||
BizProtocolPartyCustomer.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
)
|
||||
)
|
||||
cq = cq.filter(
|
||||
BizCollectionProtocol.tenant_no == tenant_no,
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
)
|
||||
cq = apply_data_scope(cq, BizCollectionProtocol, user_info, db, tenant_no)
|
||||
if like:
|
||||
cq = cq.filter(
|
||||
or_(
|
||||
BizProtocolSample.sample_code.like(like),
|
||||
BizCollectionProtocol.protocol_no.like(like),
|
||||
BizProtocolPartyCustomer.mother_name.like(like),
|
||||
BizProtocolPartyCustomer.newborn_name.like(like),
|
||||
)
|
||||
)
|
||||
for sid, scode, pid, ctime, pno, tname in cq.all():
|
||||
for sid, scode, pid, ctime, pno, tname, mname, nname in cq.all():
|
||||
rows.append(
|
||||
{
|
||||
"sampleSource": SAMPLE_SOURCE_CELL,
|
||||
@@ -536,6 +673,8 @@ def list_sample_options(
|
||||
"protocolId": pid or "",
|
||||
"protocolNo": pno or "",
|
||||
"sampleTypeName": tname or "",
|
||||
"motherName": mname or "",
|
||||
"newbornName": nname or "",
|
||||
"createTime": ctime,
|
||||
"freightId": "",
|
||||
}
|
||||
@@ -550,6 +689,8 @@ def list_sample_options(
|
||||
BizResearchDonationSample.create_time,
|
||||
BizResearchDonation.donation_no,
|
||||
BizCellSampleType.type_name,
|
||||
BizResearchDonationParty.mother_name,
|
||||
BizResearchDonationParty.newborn_name,
|
||||
)
|
||||
.outerjoin(
|
||||
BizResearchDonation,
|
||||
@@ -566,19 +707,33 @@ def list_sample_options(
|
||||
BizCellSampleType.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
BizResearchDonationParty,
|
||||
and_(
|
||||
BizResearchDonationParty.research_donation_id
|
||||
== BizResearchDonationSample.research_donation_id,
|
||||
BizResearchDonationParty.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
BizResearchDonationSample.tenant_no == tenant_no,
|
||||
BizResearchDonationSample.del_flag == 0,
|
||||
)
|
||||
)
|
||||
dq = dq.filter(
|
||||
BizResearchDonation.tenant_no == tenant_no,
|
||||
BizResearchDonation.del_flag == 0,
|
||||
)
|
||||
dq = apply_data_scope(dq, BizResearchDonation, user_info, db, tenant_no)
|
||||
if like:
|
||||
dq = dq.filter(
|
||||
or_(
|
||||
BizResearchDonationSample.sample_code.like(like),
|
||||
BizResearchDonation.donation_no.like(like),
|
||||
BizResearchDonationParty.mother_name.like(like),
|
||||
BizResearchDonationParty.newborn_name.like(like),
|
||||
)
|
||||
)
|
||||
for sid, scode, pid, ctime, dno, tname in dq.all():
|
||||
for sid, scode, pid, ctime, dno, tname, mname, nname in dq.all():
|
||||
rows.append(
|
||||
{
|
||||
"sampleSource": SAMPLE_SOURCE_DONATION,
|
||||
@@ -587,6 +742,8 @@ def list_sample_options(
|
||||
"protocolId": pid or "",
|
||||
"protocolNo": dno or "",
|
||||
"sampleTypeName": tname or "",
|
||||
"motherName": mname or "",
|
||||
"newbornName": nname or "",
|
||||
"createTime": ctime,
|
||||
"freightId": "",
|
||||
}
|
||||
@@ -685,21 +842,40 @@ def create_freight(
|
||||
freight_amount: Any,
|
||||
items: Optional[List[Dict[str, Any]]] = None,
|
||||
commit: bool = True,
|
||||
user_info: Optional[dict] = None,
|
||||
waybill_no: Any = None,
|
||||
waybill_supplied: bool = True,
|
||||
amount_supplied: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
if user_info is not None:
|
||||
items = _validated_items(db, items or [], tenant_no, user_info)
|
||||
mode = resolve_transport_mode_value(
|
||||
db, tenant_no=tenant_no, raw=transport_mode_raw, user_id=user_id
|
||||
)
|
||||
amount = _parse_amount(freight_amount)
|
||||
plan = resolve_header_for_save(
|
||||
transport_mode=mode,
|
||||
existing_waybill=None,
|
||||
existing_amount=None,
|
||||
existing_settle_status=None,
|
||||
waybill_supplied=waybill_supplied,
|
||||
waybill_no=waybill_no,
|
||||
amount_supplied=amount_supplied,
|
||||
freight_amount=freight_amount,
|
||||
)
|
||||
now = _now()
|
||||
obj = BizSampleFreight(
|
||||
id=generate_uuid(),
|
||||
transport_mode=mode,
|
||||
freight_amount=amount,
|
||||
freight_no=_generate_freight_no(db, tenant_no, when=now),
|
||||
transport_mode=plan.transport_mode,
|
||||
freight_amount=plan.freight_amount,
|
||||
waybill_no=plan.waybill_no,
|
||||
settle_status=plan.settle_status,
|
||||
settle_time=None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_dept_id=(get_user_dept_id(db, user_id) or "") if user_info is not None else "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
@@ -725,16 +901,35 @@ def update_freight(
|
||||
transport_mode_raw: Optional[str] = None,
|
||||
freight_amount: Any = None,
|
||||
commit: bool = True,
|
||||
user_info: Optional[dict] = None,
|
||||
waybill_no: Any = None,
|
||||
waybill_supplied: bool = False,
|
||||
amount_supplied: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
obj = get_freight(db, freight_id, tenant_no)
|
||||
obj = get_freight(db, freight_id, tenant_no, user_info)
|
||||
if not obj:
|
||||
raise ValueError("运费批次不存在")
|
||||
mode = obj.transport_mode or ""
|
||||
if transport_mode_raw is not None and str(transport_mode_raw).strip() != "":
|
||||
obj.transport_mode = resolve_transport_mode_value(
|
||||
mode = resolve_transport_mode_value(
|
||||
db, tenant_no=tenant_no, raw=str(transport_mode_raw), user_id=user_id
|
||||
)
|
||||
if freight_amount is not None and freight_amount != "":
|
||||
obj.freight_amount = _parse_amount(freight_amount)
|
||||
plan = resolve_header_for_save(
|
||||
transport_mode=mode,
|
||||
existing_waybill=obj.waybill_no,
|
||||
existing_amount=obj.freight_amount,
|
||||
existing_settle_status=obj.settle_status,
|
||||
waybill_supplied=waybill_supplied,
|
||||
waybill_no=waybill_no,
|
||||
amount_supplied=amount_supplied,
|
||||
freight_amount=freight_amount,
|
||||
)
|
||||
obj.transport_mode = plan.transport_mode
|
||||
obj.waybill_no = plan.waybill_no
|
||||
obj.freight_amount = plan.freight_amount
|
||||
obj.settle_status = plan.settle_status
|
||||
if plan.clear_settle_time:
|
||||
obj.settle_time = None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = _now()
|
||||
db.add(obj)
|
||||
@@ -753,12 +948,18 @@ def link_samples(
|
||||
user_id: Optional[str],
|
||||
items: List[Dict[str, Any]],
|
||||
commit: bool = True,
|
||||
user_info: Optional[dict] = None,
|
||||
allow_settled_relink: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
obj = get_freight(db, freight_id, tenant_no)
|
||||
if not obj:
|
||||
header = get_freight(db, freight_id, tenant_no, user_info)
|
||||
if not header:
|
||||
raise ValueError("运费批次不存在")
|
||||
if not allow_settled_relink:
|
||||
assert_freight_mutable(header.settle_status, "items")
|
||||
if not items:
|
||||
raise ValueError("请选择要关联的样本")
|
||||
if user_info is not None:
|
||||
items = _validated_items(db, items, tenant_no, user_info)
|
||||
for it in items:
|
||||
_upsert_item(db, freight_id=freight_id, tenant_no=tenant_no, user_id=user_id, item=it)
|
||||
if commit:
|
||||
@@ -776,10 +977,13 @@ def unlink_samples(
|
||||
user_id: Optional[str],
|
||||
items: List[Dict[str, Any]],
|
||||
commit: bool = True,
|
||||
user_info: Optional[dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
obj = get_freight(db, freight_id, tenant_no)
|
||||
if not obj:
|
||||
header = get_freight(db, freight_id, tenant_no, user_info)
|
||||
if not header:
|
||||
raise ValueError("运费批次不存在")
|
||||
assert_freight_mutable(header.settle_status, "items")
|
||||
obj = header
|
||||
now = _now()
|
||||
for it in items or []:
|
||||
norm = _normalize_item(it)
|
||||
@@ -820,12 +1024,85 @@ def unlink_samples(
|
||||
return get_freight_detail(db, freight_id, tenant_no)
|
||||
|
||||
|
||||
def delete_freight(
|
||||
db: Session,
|
||||
freight_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
commit: bool = True,
|
||||
user_info: Optional[dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""级联软删运费批次及其全部有效关联样本。"""
|
||||
header = get_freight(db, freight_id, tenant_no, user_info)
|
||||
if not header:
|
||||
raise ValueError("运费批次不存在")
|
||||
assert_freight_mutable(header.settle_status, "delete")
|
||||
obj = (
|
||||
db.query(BizSampleFreight)
|
||||
.filter(
|
||||
BizSampleFreight.id == freight_id,
|
||||
BizSampleFreight.tenant_no == tenant_no,
|
||||
BizSampleFreight.del_flag == 0,
|
||||
)
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise ValueError("运费批次不存在")
|
||||
now = _now()
|
||||
items = (
|
||||
db.query(BizSampleFreightItem)
|
||||
.filter(
|
||||
BizSampleFreightItem.freight_id == freight_id,
|
||||
BizSampleFreightItem.tenant_no == tenant_no,
|
||||
BizSampleFreightItem.del_flag == 0,
|
||||
)
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
for row in items:
|
||||
row.del_flag = 1
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
obj.del_flag = 1
|
||||
obj.update_by = user_id
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return {
|
||||
"id": freight_id,
|
||||
"deleted": True,
|
||||
"linkedSampleCount": 0,
|
||||
}
|
||||
|
||||
|
||||
def freight_amount_payload(
|
||||
amount: Any, transport_mode: Optional[str], money_as_string: bool
|
||||
) -> Dict[str, Any]:
|
||||
if amount is None:
|
||||
freight_amount: Any = None
|
||||
elif money_as_string:
|
||||
freight_amount = format(Decimal(str(amount)).quantize(Decimal("0.01")), "f")
|
||||
else:
|
||||
freight_amount = float(amount)
|
||||
return {
|
||||
"freightAmount": freight_amount,
|
||||
"freightAmountLabel": freight_amount_label(transport_mode, amount),
|
||||
}
|
||||
|
||||
|
||||
def freight_map_for_samples(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
sample_source: str,
|
||||
sample_ids: List[str],
|
||||
money_as_string: bool = False,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
ids = [str(x).strip() for x in sample_ids if str(x or "").strip()]
|
||||
if not ids:
|
||||
@@ -871,14 +1148,26 @@ def freight_map_for_samples(
|
||||
"transportModeLabel": labels.get(
|
||||
freight.transport_mode or "", freight.transport_mode or ""
|
||||
),
|
||||
"freightAmount": float(freight.freight_amount)
|
||||
if freight.freight_amount is not None
|
||||
else 0.0,
|
||||
**freight_amount_payload(
|
||||
freight.freight_amount, freight.transport_mode, money_as_string
|
||||
),
|
||||
"waybillNo": getattr(freight, "waybill_no", None) or "",
|
||||
"settleStatus": getattr(freight, "settle_status", None) or "",
|
||||
"linkedSampleCount": count_map.get(freight.id, 0),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _input_freight_id(raw: Any) -> str:
|
||||
if not isinstance(raw, dict):
|
||||
return ""
|
||||
nested = raw.get("freight") if isinstance(raw.get("freight"), dict) else {}
|
||||
return (
|
||||
str(raw.get("freightId") or raw.get("freight_id") or "").strip()
|
||||
or str(nested.get("id") or "").strip()
|
||||
)
|
||||
|
||||
|
||||
def apply_freight_on_protocol_save(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -888,15 +1177,96 @@ def apply_freight_on_protocol_save(
|
||||
protocol_id: str,
|
||||
saved_samples: list,
|
||||
input_samples: list,
|
||||
replaced_sample_ids: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""协议保存挂钩:按 freightId / freightDraft 关联运费(flush,不单独 commit)。"""
|
||||
old_ids = [
|
||||
str(sample_id).strip()
|
||||
for sample_id in (replaced_sample_ids or [])
|
||||
if str(sample_id or "").strip()
|
||||
]
|
||||
stale_filters = [BizSampleFreightItem.protocol_id == protocol_id]
|
||||
if old_ids:
|
||||
stale_filters.append(BizSampleFreightItem.sample_id.in_(old_ids))
|
||||
stale_items = (
|
||||
db.query(BizSampleFreightItem)
|
||||
.filter(
|
||||
BizSampleFreightItem.tenant_no == tenant_no,
|
||||
BizSampleFreightItem.sample_source == sample_source,
|
||||
BizSampleFreightItem.del_flag == 0,
|
||||
or_(*stale_filters),
|
||||
)
|
||||
.order_by(BizSampleFreightItem.id.asc())
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
now = _now()
|
||||
affected_freight_ids = {item.freight_id for item in stale_items}
|
||||
stale_by_freight: Dict[str, List[BizSampleFreightItem]] = {}
|
||||
for item in stale_items:
|
||||
stale_by_freight.setdefault(item.freight_id, []).append(item)
|
||||
settled_relink_ids = set()
|
||||
if stale_by_freight:
|
||||
headers = (
|
||||
db.query(BizSampleFreight)
|
||||
.filter(
|
||||
BizSampleFreight.id.in_(list(stale_by_freight.keys())),
|
||||
BizSampleFreight.tenant_no == tenant_no,
|
||||
BizSampleFreight.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
header_by_id = {row.id: row for row in headers}
|
||||
for freight_id, grouped in stale_by_freight.items():
|
||||
header = header_by_id.get(freight_id)
|
||||
if header is None or (header.settle_status or "") != "yjs":
|
||||
continue
|
||||
stale_count = len(grouped)
|
||||
keep_count = sum(
|
||||
1
|
||||
for raw in (input_samples or [])
|
||||
if _input_freight_id(raw) == freight_id
|
||||
)
|
||||
if keep_count != stale_count:
|
||||
raise ValueError("已结算的运费不能调整样本")
|
||||
settled_relink_ids.add(freight_id)
|
||||
keep_sample_ids = set()
|
||||
for idx, sample_obj in enumerate(saved_samples or []):
|
||||
if idx >= len(input_samples or []):
|
||||
break
|
||||
raw = input_samples[idx] if isinstance(input_samples[idx], dict) else {}
|
||||
sample_id = str(getattr(sample_obj, "id", None) or "").strip()
|
||||
if not sample_id:
|
||||
continue
|
||||
draft = raw.get("freightDraft")
|
||||
if draft is None:
|
||||
draft = raw.get("freight_draft")
|
||||
if _input_freight_id(raw) or isinstance(draft, dict):
|
||||
keep_sample_ids.add(sample_id)
|
||||
removed_freight_item = False
|
||||
for stale in stale_items:
|
||||
if stale.sample_id in keep_sample_ids:
|
||||
continue
|
||||
stale.del_flag = 1
|
||||
stale.update_by = user_id
|
||||
stale.update_time = now
|
||||
db.add(stale)
|
||||
removed_freight_item = True
|
||||
if removed_freight_item:
|
||||
db.flush()
|
||||
|
||||
draft_groups: Dict[str, Dict[str, Any]] = {}
|
||||
for idx, sample_obj in enumerate(saved_samples or []):
|
||||
if idx >= len(input_samples or []):
|
||||
break
|
||||
raw = input_samples[idx] if isinstance(input_samples[idx], dict) else {}
|
||||
sample_id = getattr(sample_obj, "id", None) or ""
|
||||
sample_code = getattr(sample_obj, "sample_code", None) or raw.get("sampleCode") or ""
|
||||
sample_code = (
|
||||
getattr(sample_obj, "sample_code", None)
|
||||
or raw.get("sampleCode")
|
||||
or raw.get("sample_code")
|
||||
or ""
|
||||
)
|
||||
if not sample_id:
|
||||
continue
|
||||
item = {
|
||||
@@ -905,11 +1275,10 @@ def apply_freight_on_protocol_save(
|
||||
"protocolId": protocol_id,
|
||||
"sampleCode": sample_code,
|
||||
}
|
||||
freight_id = (
|
||||
str(raw.get("freightId") or "").strip()
|
||||
or str((raw.get("freight") or {}).get("id") or "").strip()
|
||||
)
|
||||
freight_id = _input_freight_id(raw)
|
||||
draft = raw.get("freightDraft")
|
||||
if draft is None:
|
||||
draft = raw.get("freight_draft")
|
||||
if freight_id:
|
||||
link_samples(
|
||||
db,
|
||||
@@ -918,6 +1287,7 @@ def apply_freight_on_protocol_save(
|
||||
user_id=user_id,
|
||||
items=[item],
|
||||
commit=False,
|
||||
allow_settled_relink=freight_id in settled_relink_ids,
|
||||
)
|
||||
continue
|
||||
if isinstance(draft, dict):
|
||||
@@ -931,6 +1301,9 @@ def apply_freight_on_protocol_save(
|
||||
"freightAmount": draft.get("freightAmount")
|
||||
if draft.get("freightAmount") is not None
|
||||
else draft.get("freight_amount"),
|
||||
"waybillNo": draft.get("waybillNo")
|
||||
if draft.get("waybillNo") is not None
|
||||
else draft.get("waybill_no"),
|
||||
"items": [item],
|
||||
}
|
||||
else:
|
||||
@@ -943,7 +1316,29 @@ def apply_freight_on_protocol_save(
|
||||
user_id=user_id,
|
||||
transport_mode_raw=str(group.get("transportMode") or ""),
|
||||
freight_amount=group.get("freightAmount"),
|
||||
waybill_no=group.get("waybillNo"),
|
||||
waybill_supplied=True,
|
||||
amount_supplied=True,
|
||||
items=group["items"],
|
||||
commit=False,
|
||||
)
|
||||
for freight_id in sorted(affected_freight_ids):
|
||||
if _linked_count(db, freight_id, tenant_no) != 0:
|
||||
continue
|
||||
freight = (
|
||||
db.query(BizSampleFreight)
|
||||
.filter(
|
||||
BizSampleFreight.id == freight_id,
|
||||
BizSampleFreight.tenant_no == tenant_no,
|
||||
BizSampleFreight.del_flag == 0,
|
||||
)
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if freight is not None:
|
||||
assert_freight_mutable(freight.settle_status, "delete")
|
||||
freight.del_flag = 1
|
||||
freight.update_by = user_id
|
||||
freight.update_time = now
|
||||
db.add(freight)
|
||||
db.flush()
|
||||
@@ -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),
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -21,11 +22,13 @@ from app.services.sample_storage_cert_qc import (
|
||||
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 = "",
|
||||
@@ -33,7 +36,7 @@ def _row_to_dict(
|
||||
return {
|
||||
"id": row.id,
|
||||
"sampleCode": row.sample_code or "",
|
||||
"labFlowCode": row.sample_code or "",
|
||||
"labFlowCode": lab_flow_code or "",
|
||||
"protocolCode": protocol_code or "",
|
||||
"motherName": mother_name or "",
|
||||
"sampleTypeName": sample_type_name or "",
|
||||
@@ -99,15 +102,30 @@ def _load_sample_context(db: Session, tenant_no: str, sample_code: str):
|
||||
)
|
||||
if pc:
|
||||
mother_name = pc.mother_name or ""
|
||||
return sample, type_name, protocol_code, mother_name
|
||||
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。"""
|
||||
"""样本不存在返回 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 = ctx
|
||||
sample, type_name, protocol_code, mother_name, lab_flow_code = ctx
|
||||
key = resolve_sample_type_key(type_name)
|
||||
row = (
|
||||
db.query(BizSampleStorageCertQc)
|
||||
@@ -119,20 +137,24 @@ def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[s
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
return empty_detail(
|
||||
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 _row_to_dict(
|
||||
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(
|
||||
@@ -147,7 +169,7 @@ def upsert(
|
||||
ctx = _load_sample_context(db, tenant_no, sample_code)
|
||||
if not ctx:
|
||||
raise ValueError("样本不存在")
|
||||
sample, type_name, protocol_code, mother_name = ctx
|
||||
sample, type_name, protocol_code, mother_name, lab_flow_code = ctx
|
||||
key = resolve_sample_type_key(type_name)
|
||||
if not key:
|
||||
raise ValueError("该样本类型不支持填写储存证质检信息")
|
||||
@@ -193,6 +215,7 @@ def upsert(
|
||||
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
|
||||
+5
-1
@@ -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, 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, sample_storage_cert_qc_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
|
||||
@@ -152,7 +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:
|
||||
|
||||
@@ -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")
|
||||
@@ -1110,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"""
|
||||
|
||||
@@ -1496,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="租户编号")
|
||||
@@ -1577,8 +1615,12 @@ class BizSampleFreight(Base):
|
||||
__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=False, default=0, 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="备注")
|
||||
@@ -1659,6 +1701,31 @@ class BizProtocolStorageCert(Base):
|
||||
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"""
|
||||
|
||||
@@ -1682,6 +1749,76 @@ class BizSampleStorageCertQc(Base):
|
||||
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="备注")
|
||||
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 BizSampleReceptionCheckItem(Base):
|
||||
"""样本接收检查项配置 biz_sample_reception_check_item"""
|
||||
|
||||
@@ -2395,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"""
|
||||
|
||||
@@ -3711,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"""
|
||||
|
||||
@@ -3932,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)")
|
||||
@@ -3964,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="份数")
|
||||
@@ -3979,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"""
|
||||
|
||||
@@ -116,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')
|
||||
@@ -63,6 +63,7 @@ class StepReorderBody(BaseModel):
|
||||
# Step sub-table items
|
||||
class StepReagentItem(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
|
||||
@@ -75,6 +76,7 @@ class StepReagentItem(BaseModel):
|
||||
|
||||
class StepConsumableItem(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")
|
||||
@@ -87,6 +89,7 @@ class StepConsumableItem(BaseModel):
|
||||
|
||||
class StepOutputItem(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")
|
||||
@@ -96,6 +99,7 @@ class StepOutputItem(BaseModel):
|
||||
|
||||
class StepDataCollectionItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
collection_name: Optional[str] = Field(None, alias="collectionName")
|
||||
unit: Optional[str] = None
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
|
||||
@@ -88,6 +88,7 @@ class TaskStepUpdate(BaseModel):
|
||||
|
||||
class TaskStepMaterialItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
material_code: Optional[str] = None
|
||||
material_name: Optional[str] = None
|
||||
spec: Optional[str] = None
|
||||
@@ -102,6 +103,7 @@ class TaskStepMaterialItem(BaseModel):
|
||||
|
||||
|
||||
class TaskStepOutputItem(BaseModel):
|
||||
id: Optional[str] = None
|
||||
output_type_id: Optional[str] = None
|
||||
output_name: Optional[str] = None
|
||||
output_barcode: Optional[str] = None
|
||||
@@ -109,6 +111,7 @@ class TaskStepOutputItem(BaseModel):
|
||||
|
||||
|
||||
class TaskStepDataCollectionItem(BaseModel):
|
||||
id: Optional[str] = None
|
||||
collection_name: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
collection_value: Optional[str] = None
|
||||
@@ -117,6 +120,7 @@ class TaskStepDataCollectionItem(BaseModel):
|
||||
|
||||
class TaskInputItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
input_type: str = Field(..., alias="inputType")
|
||||
input_id: Optional[str] = Field(None, alias="inputId")
|
||||
input_code: Optional[str] = Field(None, alias="inputCode")
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""基础实验交付日前一天:服务号模板提醒。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import (
|
||||
BizPilotApplication,
|
||||
BizPilotInfusionPlan,
|
||||
BizPilotInfusionSchedule,
|
||||
BizPilotInfusionScheduleProduct,
|
||||
BizWxNotifyLog,
|
||||
SysUser,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NOTIFY_BIZ_TYPE = "basic_experiment_delivery_remind"
|
||||
CANCELLED_SCHEDULE_STATUS = "yqx"
|
||||
|
||||
|
||||
def format_delivery_product_name(
|
||||
*,
|
||||
project_name: str = "",
|
||||
spec_text: str = "",
|
||||
package_text: str = "",
|
||||
) -> str:
|
||||
"""产品名称拼接:名称(规格、包装);空段省略。"""
|
||||
name = (project_name or "").strip()
|
||||
spec = (spec_text or "").strip()
|
||||
package = (package_text or "").strip()
|
||||
parts = [p for p in (spec, package) if p]
|
||||
if not name and not parts:
|
||||
return ""
|
||||
if not parts:
|
||||
return name
|
||||
suffix = "、".join(parts)
|
||||
if name:
|
||||
return f"{name}({suffix})"
|
||||
return f"({suffix})"
|
||||
|
||||
|
||||
def _notify_enabled() -> bool:
|
||||
if not bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False)):
|
||||
return False
|
||||
tid = (getattr(settings, "WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY", "") or "").strip()
|
||||
return bool(tid)
|
||||
|
||||
|
||||
def _already_success(db: Session, *, tenant_no: str, schedule_id: str, notify_date: str) -> bool:
|
||||
row = (
|
||||
db.query(BizWxNotifyLog)
|
||||
.filter(
|
||||
BizWxNotifyLog.tenant_no == tenant_no,
|
||||
BizWxNotifyLog.biz_type == NOTIFY_BIZ_TYPE,
|
||||
BizWxNotifyLog.biz_id == schedule_id,
|
||||
BizWxNotifyLog.notify_date == notify_date,
|
||||
BizWxNotifyLog.status == "success",
|
||||
BizWxNotifyLog.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
def _write_log(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
schedule_id: str,
|
||||
notify_date: str,
|
||||
user_id: str,
|
||||
status: str,
|
||||
errcode: str = "",
|
||||
errmsg: str = "",
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
db.add(
|
||||
BizWxNotifyLog(
|
||||
id=str(uuid.uuid4()).replace("-", "").upper(),
|
||||
biz_type=NOTIFY_BIZ_TYPE,
|
||||
biz_id=schedule_id,
|
||||
notify_date=notify_date,
|
||||
user_id=user_id or "",
|
||||
status=status,
|
||||
errcode=(errcode or "")[:64] or None,
|
||||
errmsg=(errmsg or "")[:512] or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark="",
|
||||
create_by=user_id or "",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id or "",
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _iter_candidate_schedules(db: Session, *, delivery_day: date) -> List[Any]:
|
||||
"""返回 (schedule, application, product) 元组列表。"""
|
||||
day_start = datetime.combine(delivery_day, datetime.min.time())
|
||||
day_end = datetime.combine(delivery_day, datetime.max.time()).replace(microsecond=0)
|
||||
rows = (
|
||||
db.query(BizPilotInfusionSchedule, BizPilotApplication, BizPilotInfusionScheduleProduct)
|
||||
.join(
|
||||
BizPilotInfusionPlan,
|
||||
BizPilotInfusionPlan.id == BizPilotInfusionSchedule.infusion_plan_id,
|
||||
)
|
||||
.join(
|
||||
BizPilotApplication,
|
||||
BizPilotApplication.id == BizPilotInfusionPlan.pilot_application_id,
|
||||
)
|
||||
.outerjoin(
|
||||
BizPilotInfusionScheduleProduct,
|
||||
(
|
||||
BizPilotInfusionScheduleProduct.infusion_schedule_id
|
||||
== BizPilotInfusionSchedule.id
|
||||
)
|
||||
& (BizPilotInfusionScheduleProduct.del_flag == 0),
|
||||
)
|
||||
.filter(
|
||||
BizPilotApplication.biz_type == "basic_experiment",
|
||||
BizPilotApplication.del_flag == 0,
|
||||
BizPilotInfusionPlan.del_flag == 0,
|
||||
BizPilotInfusionSchedule.del_flag == 0,
|
||||
BizPilotInfusionSchedule.infusion_time >= day_start,
|
||||
BizPilotInfusionSchedule.infusion_time <= day_end,
|
||||
BizPilotInfusionSchedule.schedule_status != CANCELLED_SCHEDULE_STATUS,
|
||||
)
|
||||
.order_by(BizPilotInfusionSchedule.infusion_time.asc())
|
||||
.all()
|
||||
)
|
||||
# 一计划多产品时取首条产品行(基础实验通常一行)
|
||||
seen: set = set()
|
||||
out = []
|
||||
for sched, app, prod in rows:
|
||||
if sched.id in seen:
|
||||
continue
|
||||
seen.add(sched.id)
|
||||
out.append((sched, app, prod))
|
||||
return out
|
||||
|
||||
|
||||
def run_delivery_remind_for_date(
|
||||
db: Session,
|
||||
*,
|
||||
today: Optional[date] = None,
|
||||
) -> Dict[str, int]:
|
||||
"""扫描「明天」交付计划并发送提醒。返回计数统计。"""
|
||||
stats = {"candidates": 0, "sent": 0, "skipped": 0, "failed": 0}
|
||||
if not _notify_enabled():
|
||||
logger.info("basic experiment delivery remind skipped: notify disabled or no template")
|
||||
return stats
|
||||
|
||||
base = today or date.today()
|
||||
delivery_day = base + timedelta(days=1)
|
||||
notify_date = base.strftime("%Y-%m-%d")
|
||||
|
||||
from app.services.wechat_mp_message import send_basic_experiment_delivery_remind
|
||||
|
||||
candidates = _iter_candidate_schedules(db, delivery_day=delivery_day)
|
||||
stats["candidates"] = len(candidates)
|
||||
|
||||
for sched, app, prod in candidates:
|
||||
tenant_no = (app.tenant_no or sched.tenant_no or "").strip() or "zkxw"
|
||||
schedule_id = sched.id
|
||||
if _already_success(db, tenant_no=tenant_no, schedule_id=schedule_id, notify_date=notify_date):
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
creator_id = (app.create_by or "").strip()
|
||||
if not creator_id:
|
||||
logger.info(
|
||||
"delivery remind skip: no create_by schedule=%s app=%s",
|
||||
schedule_id,
|
||||
app.id,
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
user = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == creator_id, SysUser.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
mp = (getattr(user, "wx_mp_openid", None) or "").strip() if user else ""
|
||||
if not mp:
|
||||
logger.info(
|
||||
"delivery remind skip: no mp_openid user=%s schedule=%s",
|
||||
creator_id,
|
||||
schedule_id,
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
product_name = format_delivery_product_name(
|
||||
project_name=(prod.project_name if prod else "") or "",
|
||||
spec_text=(prod.spec_text if prod else "") or (sched.spec_text or ""),
|
||||
package_text=(prod.package_text if prod else "") or (sched.package_text or ""),
|
||||
)
|
||||
qty = 1
|
||||
if prod is not None and prod.quantity is not None:
|
||||
qty = int(prod.quantity)
|
||||
time_text = ""
|
||||
if sched.infusion_time:
|
||||
time_text = sched.infusion_time.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
result = send_basic_experiment_delivery_remind(
|
||||
mp_openid=mp,
|
||||
customer_name=app.customer_name or "",
|
||||
product_name=product_name,
|
||||
quantity=str(qty),
|
||||
contact_name=app.customer_contact or "",
|
||||
time_text=time_text,
|
||||
)
|
||||
if result.get("success"):
|
||||
_write_log(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
schedule_id=schedule_id,
|
||||
notify_date=notify_date,
|
||||
user_id=creator_id,
|
||||
status="success",
|
||||
)
|
||||
stats["sent"] += 1
|
||||
else:
|
||||
logger.error(
|
||||
"delivery remind send failed schedule=%s user=%s err=%s",
|
||||
schedule_id,
|
||||
creator_id,
|
||||
result,
|
||||
)
|
||||
stats["failed"] += 1
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("delivery remind commit failed")
|
||||
raise
|
||||
return stats
|
||||
|
||||
|
||||
def run_delivery_remind_job(get_db_session) -> None:
|
||||
"""APScheduler 入口。"""
|
||||
db = get_db_session()
|
||||
try:
|
||||
stats = run_delivery_remind_for_date(db)
|
||||
logger.info("basic experiment delivery remind done: %s", stats)
|
||||
except Exception:
|
||||
logger.exception("basic experiment delivery remind job failed")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -32,23 +32,35 @@ def _avatar_url_field(user: SysUser) -> Optional[str]:
|
||||
|
||||
def resolve_user_org_labels(db: Session, user: SysUser) -> Tuple[Optional[str], List[str]]:
|
||||
"""解析用户所属部门名称与职位名称列表(保序)。"""
|
||||
tenant_no = (getattr(user, "tenant_no", None) or "").strip()
|
||||
dept_name: Optional[str] = None
|
||||
dept_id = (getattr(user, "dept_id", None) or "").strip()
|
||||
if dept_id:
|
||||
dept = (
|
||||
db.query(SysDept)
|
||||
.filter(SysDept.id == dept_id, SysDept.del_flag == 0)
|
||||
.filter(
|
||||
SysDept.id == dept_id,
|
||||
SysDept.tenant_no == tenant_no,
|
||||
SysDept.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if dept and (dept.dept_name or "").strip():
|
||||
dept_name = str(dept.dept_name).strip()
|
||||
|
||||
post_names: List[str] = []
|
||||
post_ids = list_post_ids_for_user(db, user.id)
|
||||
post_ids = list_post_ids_for_user(
|
||||
db, user.id, tenant_no=tenant_no
|
||||
)
|
||||
if post_ids:
|
||||
posts = (
|
||||
db.query(SysPost)
|
||||
.filter(SysPost.id.in_(post_ids), SysPost.del_flag == 0)
|
||||
.filter(
|
||||
SysPost.id.in_(post_ids),
|
||||
SysPost.tenant_no == tenant_no,
|
||||
SysPost.status == "1",
|
||||
SysPost.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
name_map = {
|
||||
@@ -109,11 +121,14 @@ async def issue_tokens_for_user(db: Session, user: SysUser) -> dict:
|
||||
"""校验停用/角色后签发 token。失败抛 ValueError(中文 message)。"""
|
||||
if user.status != "1":
|
||||
raise ValueError("账号已被停用,请联系管理员")
|
||||
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):
|
||||
raise ValueError("账号未分配角色,请联系管理员")
|
||||
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""移动市场专员/地区经理的现场字段范围。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.data_scope import apply_data_scope
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizCollectionRegionStaff,
|
||||
BizPresignOrder,
|
||||
BizProtocolCollectionInfo,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonation,
|
||||
BizResearchDonationCollection,
|
||||
)
|
||||
|
||||
ScopeKind = Literal["specialist", "manager"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarketFieldContext:
|
||||
"""由固定移动路由创建的现场范围上下文。"""
|
||||
|
||||
tenant_no: str
|
||||
user_id: str
|
||||
scope_kind: ScopeKind
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
tenant_no = (self.tenant_no or "").strip()
|
||||
user_id = (self.user_id or "").strip()
|
||||
if not tenant_no or not user_id:
|
||||
raise ValueError("现场范围要求明确的租户和用户")
|
||||
if self.scope_kind not in ("specialist", "manager"):
|
||||
raise ValueError("不支持的现场范围类型")
|
||||
object.__setattr__(self, "tenant_no", tenant_no)
|
||||
object.__setattr__(self, "user_id", user_id)
|
||||
|
||||
|
||||
def leader_region_ids_query(db: Session, ctx: MarketFieldContext):
|
||||
"""返回当前经理在当前租户有效负责的地区 ID 查询。"""
|
||||
return db.query(BizCollectionRegionStaff.region_id).filter(
|
||||
BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
|
||||
BizCollectionRegionStaff.del_flag == 0,
|
||||
BizCollectionRegionStaff.user_id == ctx.user_id,
|
||||
BizCollectionRegionStaff.is_leader == 1,
|
||||
)
|
||||
|
||||
|
||||
def _collection_scope_condition(model, db: Session, ctx: MarketFieldContext):
|
||||
if ctx.scope_kind == "specialist":
|
||||
return model.sales_user_id == ctx.user_id
|
||||
return or_(
|
||||
model.region_id.in_(leader_region_ids_query(db, ctx)),
|
||||
model.region_manager_user_id == ctx.user_id,
|
||||
)
|
||||
|
||||
|
||||
def apply_market_data_scope(
|
||||
query,
|
||||
model,
|
||||
db: Session,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
):
|
||||
"""组合租户、软删与通用多角色数据权限。"""
|
||||
user_id = (
|
||||
str(user_info.get("user_id") or "").strip()
|
||||
if isinstance(user_info, dict)
|
||||
else ""
|
||||
)
|
||||
if not user_id or user_id != ctx.user_id:
|
||||
raise PermissionError("用户身份与现场范围上下文不一致")
|
||||
query = query.filter(
|
||||
model.tenant_no == ctx.tenant_no,
|
||||
model.del_flag == 0,
|
||||
)
|
||||
return apply_data_scope(
|
||||
query,
|
||||
model,
|
||||
user_info,
|
||||
db,
|
||||
tenant_no=ctx.tenant_no,
|
||||
)
|
||||
|
||||
|
||||
def apply_presign_scope(
|
||||
query,
|
||||
db: Session,
|
||||
ctx: MarketFieldContext,
|
||||
*,
|
||||
user_info: dict,
|
||||
):
|
||||
"""组合租户、软删、通用数据权限与预签现场范围。"""
|
||||
query = apply_market_data_scope(
|
||||
query, BizPresignOrder, db, ctx, user_info
|
||||
)
|
||||
return query.filter(_collection_scope_condition(BizPresignOrder, db, ctx))
|
||||
|
||||
|
||||
def apply_cell_protocol_scope(
|
||||
query,
|
||||
db: Session,
|
||||
ctx: MarketFieldContext,
|
||||
*,
|
||||
user_info: dict,
|
||||
):
|
||||
"""按细胞协议的有效采集信息施加现场范围。"""
|
||||
query = apply_market_data_scope(
|
||||
query, BizCollectionProtocol, db, ctx, user_info
|
||||
)
|
||||
collection_exists = (
|
||||
db.query(BizProtocolCollectionInfo.id)
|
||||
.filter(
|
||||
BizProtocolCollectionInfo.collection_protocol_id
|
||||
== BizCollectionProtocol.id,
|
||||
BizProtocolCollectionInfo.tenant_no == ctx.tenant_no,
|
||||
BizProtocolCollectionInfo.del_flag == 0,
|
||||
_collection_scope_condition(BizProtocolCollectionInfo, db, ctx),
|
||||
)
|
||||
.exists()
|
||||
.correlate(BizCollectionProtocol)
|
||||
)
|
||||
return query.filter(collection_exists)
|
||||
|
||||
|
||||
def apply_donation_scope(
|
||||
query,
|
||||
db: Session,
|
||||
ctx: MarketFieldContext,
|
||||
*,
|
||||
user_info: dict,
|
||||
):
|
||||
"""按科研捐赠的有效采集信息施加现场范围。"""
|
||||
query = apply_market_data_scope(
|
||||
query, BizResearchDonation, db, ctx, user_info
|
||||
)
|
||||
collection_exists = (
|
||||
db.query(BizResearchDonationCollection.id)
|
||||
.filter(
|
||||
BizResearchDonationCollection.research_donation_id
|
||||
== BizResearchDonation.id,
|
||||
BizResearchDonationCollection.tenant_no == ctx.tenant_no,
|
||||
BizResearchDonationCollection.del_flag == 0,
|
||||
_collection_scope_condition(
|
||||
BizResearchDonationCollection, db, ctx
|
||||
),
|
||||
)
|
||||
.exists()
|
||||
.correlate(BizResearchDonation)
|
||||
)
|
||||
return query.filter(collection_exists)
|
||||
|
||||
|
||||
def _assert_access(query, record_id: str):
|
||||
row = query.filter_by(id=record_id).first()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="数据不存在或无权访问")
|
||||
return row
|
||||
|
||||
|
||||
def assert_presign_access(
|
||||
db: Session,
|
||||
record_id: str,
|
||||
ctx: MarketFieldContext,
|
||||
*,
|
||||
user_info: dict,
|
||||
):
|
||||
return _assert_access(
|
||||
apply_presign_scope(
|
||||
db.query(BizPresignOrder), db, ctx, user_info=user_info
|
||||
),
|
||||
record_id,
|
||||
)
|
||||
|
||||
|
||||
def assert_cell_protocol_access(
|
||||
db: Session,
|
||||
record_id: str,
|
||||
ctx: MarketFieldContext,
|
||||
*,
|
||||
user_info: dict,
|
||||
):
|
||||
return _assert_access(
|
||||
apply_cell_protocol_scope(
|
||||
db.query(BizCollectionProtocol), db, ctx, user_info=user_info
|
||||
),
|
||||
record_id,
|
||||
)
|
||||
|
||||
|
||||
def assert_donation_access(
|
||||
db: Session,
|
||||
record_id: str,
|
||||
ctx: MarketFieldContext,
|
||||
*,
|
||||
user_info: dict,
|
||||
):
|
||||
return _assert_access(
|
||||
apply_donation_scope(
|
||||
db.query(BizResearchDonation), db, ctx, user_info=user_info
|
||||
),
|
||||
record_id,
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 796 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user