Compare commits
195
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
@@ -220,3 +220,4 @@ 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.
|
||||
- **进程重启**:修改 bug 时,如果前端或后端需要重启,Agent 只告知用户,由用户自行重启。Agent 不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。
|
||||
@@ -86,3 +86,17 @@ 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_6yH4lD-RCNmM_OiwyTgrJfU-_A
|
||||
# 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
|
||||
@@ -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, SysUser.del_flag == 0).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
+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,20 +2,24 @@
|
||||
|
||||
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.menu_guard import require_admin_or_super_admin
|
||||
from app.core.data_scope import get_user_dept_id
|
||||
from app.core.menu_guard import require_admin_or_super_admin, require_strict_menu_code
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import presign_order as po_svc
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["细胞存储-预签约订单"])
|
||||
|
||||
PAYMENT_CONFIRM_MENU = "biz:cellStoragePaymentConfirm:list"
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
@@ -54,6 +58,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 +127,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 +148,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 +174,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 +206,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 +226,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 +248,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 +276,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 +304,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 +328,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
|
||||
@@ -364,11 +400,14 @@ async def list_presign_receipt_confirmed(
|
||||
async def confirm_presign_transfer(
|
||||
transfer_id: str,
|
||||
body: Optional[FinanceConfirmBody] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(
|
||||
db, transfer_id, tenant_no, user, transfer=True
|
||||
)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = po_svc.confirm_receipt(
|
||||
db,
|
||||
@@ -390,11 +429,14 @@ async def confirm_presign_transfer(
|
||||
@router.post("/presign-transfers/{transfer_id}/receipt-reject")
|
||||
async def reject_presign_transfer(
|
||||
transfer_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
po_svc.assert_presign_data_access(
|
||||
db, transfer_id, tenant_no, user, transfer=True
|
||||
)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = po_svc.reject_receipt(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -9,13 +9,15 @@ 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.menu_guard import require_admin_or_super_admin, require_strict_menu_code
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import protocol_sample_payment as psp_svc
|
||||
from app.models.models import BizCollectionProtocol, SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["细胞存储-按样本缴费"])
|
||||
|
||||
PAYMENT_CONFIRM_MENU = "biz:cellStoragePaymentConfirm:list"
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
@@ -182,29 +184,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 +383,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 +417,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 +449,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="已撤回")
|
||||
@@ -583,12 +587,18 @@ async def list_receipt_confirmed(
|
||||
async def confirm_payment_transfer(
|
||||
transfer_id: str,
|
||||
body: Optional[FinanceConfirmBody] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
)
|
||||
obj = psp_svc.confirm_receipt(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
@@ -596,9 +606,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="确认收款成功")
|
||||
@@ -611,17 +620,25 @@ async def confirm_payment_transfer(
|
||||
@router.post("/payment-transfers/{transfer_id}/receipt-reject")
|
||||
async def reject_payment_transfer(
|
||||
transfer_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
locked_transfer = psp_svc._get_transfer_with_schedule_lock(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=tenant_no,
|
||||
user_info=user,
|
||||
)
|
||||
obj = psp_svc.reject_receipt(
|
||||
db, transfer_id=transfer_id, tenant_no=tenant_no, user_id=user_id
|
||||
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,
|
||||
|
||||
@@ -6,14 +6,27 @@ 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.menu_guard import (
|
||||
assert_strict_menu_code,
|
||||
require_admin_or_super_admin,
|
||||
require_strict_menu_code,
|
||||
)
|
||||
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=["科研捐赠管理"])
|
||||
|
||||
PROTOCOL_LIST_MENU = "biz:cellStorageProtocol:list"
|
||||
PROTOCOL_AUDIT_MENU = "biz:cellStorageProtocolAudit:list"
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
@@ -119,6 +132,33 @@ class ConvertFromStorageBody(BaseModel):
|
||||
protocol_id: str = Field(..., alias="protocolId")
|
||||
|
||||
|
||||
def assert_donation_sensitive_fields(
|
||||
db: Session,
|
||||
user: dict,
|
||||
body: BaseModel,
|
||||
*,
|
||||
creating: bool,
|
||||
) -> None:
|
||||
"""防止通过普通保存接口注入状态或协议互转来源。"""
|
||||
submitted = set(body.model_fields_set)
|
||||
if creating:
|
||||
status_value = str(getattr(body, "donation_status", None) or "dsx").strip()
|
||||
source_value = str(getattr(body, "source_type", None) or "direct").strip()
|
||||
requires_audit = (
|
||||
status_value != "dsx"
|
||||
or source_value != "direct"
|
||||
or bool(getattr(body, "source_protocol_id", None))
|
||||
)
|
||||
else:
|
||||
requires_audit = bool(
|
||||
submitted.intersection(
|
||||
{"donation_status", "source_type", "source_protocol_id"}
|
||||
)
|
||||
)
|
||||
if requires_audit:
|
||||
assert_strict_menu_code(db, user, PROTOCOL_AUDIT_MENU)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 列表
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -226,9 +266,10 @@ async def get_research_donation_detail(
|
||||
@router.post("/research-donations")
|
||||
async def create_research_donation(
|
||||
body: DonationCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)),
|
||||
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 +283,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="创建成功",
|
||||
@@ -259,13 +311,18 @@ async def create_research_donation(
|
||||
async def update_research_donation(
|
||||
donation_id: str,
|
||||
body: DonationUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)),
|
||||
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 +330,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 +465,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_any_menu_code, require_strict_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_strict_menu_code("cell-storage:storage-cert:list")
|
||||
|
||||
|
||||
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,22 @@ 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"
|
||||
)
|
||||
# 协议新增样本采集通知(工单类模板)
|
||||
WX_MP_TEMPLATE_SAMPLE_COLLECT: str = (
|
||||
"iOnQ9k2mavAOwFbp_6yH4lD-RCNmM_OiwyTgrJfU-_A"
|
||||
)
|
||||
WX_MP_SAMPLE_COLLECT_ROLE_NAME: str = "样本采集通知人员"
|
||||
# 审批模板:申请事项/操作人(申请人)/当前环节/操作时间/申请类型(流程名)
|
||||
# 生产字段:thing12 / thing18 / thing7 / time17 / thing8
|
||||
WX_MP_TPL_FIELD_TITLE: str = "thing12"
|
||||
@@ -158,6 +167,17 @@ 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"
|
||||
|
||||
# 与 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}%"))
|
||||
|
||||
@@ -405,7 +405,7 @@ def submit_refund(
|
||||
) -> BizPilotPayment:
|
||||
row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no)
|
||||
if row.pay_status != "yjf":
|
||||
raise ValueError("仅已缴费状态可发起退费")
|
||||
raise ValueError("仅付款完成状态可发起退费")
|
||||
if refund_amount is None or Decimal(str(refund_amount)) <= 0:
|
||||
raise ValueError("请填写有效的退费金额")
|
||||
row.pay_status = "tfqrz"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -1153,6 +1161,8 @@ def update_infusion_schedule_mgmt(
|
||||
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 "",
|
||||
package_id=item.get("packageId") or item.get("package_id") or "",
|
||||
package_text=item.get("packageText") or item.get("package_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
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
BizCellProductType,
|
||||
BizCellProductSpec,
|
||||
BizCellProductPackage,
|
||||
BizCellProductPrice,
|
||||
BizCellProductProjectSystem,
|
||||
BizCellProductProjectSystemItem,
|
||||
@@ -281,6 +282,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 细胞产品价格
|
||||
# ────────────────────────────────────────────
|
||||
|
||||
+410
-129
@@ -7,9 +7,11 @@ 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.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 +33,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 +103,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 +173,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 +262,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,6 +334,7 @@ def replace_sample_types(
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
sample_type_ids: List[str],
|
||||
create_dept_id: str = "",
|
||||
) -> None:
|
||||
now = _now()
|
||||
existing = (
|
||||
@@ -229,7 +365,7 @@ def replace_sample_types(
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
@@ -256,10 +392,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 +430,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 +523,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 +538,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 +581,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 +595,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 +605,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 +624,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 +633,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 +658,6 @@ def update_presign_order(
|
||||
val = fields[k]
|
||||
if attr in (
|
||||
"order_date",
|
||||
"protocol_no",
|
||||
"mother_name",
|
||||
"expected_delivery_date",
|
||||
"region_id",
|
||||
@@ -526,6 +684,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 +699,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 +730,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 +759,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 +781,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 +899,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 +1028,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 +1056,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 +1082,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,
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
BizProcessRoute,
|
||||
BizProcessRouteStep,
|
||||
BizProductionTask,
|
||||
BizProductionTaskSet,
|
||||
BizProductionTaskSetItem,
|
||||
@@ -110,6 +111,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
|
||||
@@ -664,12 +698,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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -38,7 +38,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 +130,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 +266,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 +381,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 +398,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 +564,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 +688,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 +702,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 +730,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,41 +746,21 @@ 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()
|
||||
)
|
||||
if old_schedules:
|
||||
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)
|
||||
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
|
||||
@@ -575,18 +770,16 @@ def replace_schedules(
|
||||
created: List[BizProtocolPaymentSchedule] = []
|
||||
for idx, raw in enumerate(schedules or []):
|
||||
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"
|
||||
)
|
||||
schedule_no = _schedule_no_of(raw, idx + 1)
|
||||
obj = BizProtocolPaymentSchedule(
|
||||
id=generate_uuid(),
|
||||
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),
|
||||
schedule_no=schedule_no,
|
||||
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,
|
||||
@@ -601,19 +794,6 @@ def replace_schedules(
|
||||
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:
|
||||
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()
|
||||
return created
|
||||
|
||||
|
||||
@@ -627,25 +807,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 +857,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 +890,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 +917,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 +939,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 +971,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 +1003,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 +1022,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 +1041,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 +1125,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 +1153,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 +1161,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 +1180,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 +1203,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 +1220,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 +1250,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 +1258,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 +1270,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 +1281,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 +1350,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 +1363,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 +1481,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 +1500,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 +1543,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 +1609,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 +1815,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 +1823,7 @@ def _compute_progress_for_payment(
|
||||
]
|
||||
tr_dicts = [
|
||||
{
|
||||
"scheduleId": t.schedule_id,
|
||||
"payStatus": t.pay_status or "",
|
||||
"transferAmount": t.transfer_amount,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -298,6 +306,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 +391,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 +1578,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 +1636,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 +1767,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 +1780,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 +2007,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 +2066,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 +3287,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 +3494,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 +3584,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,
|
||||
|
||||
@@ -9,14 +9,18 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.protocol_status_display import assert_protocol_status_transition
|
||||
from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date
|
||||
from app.crud.base import camel_to_snake
|
||||
from app.crud.biz_no_generator import allocate_next_biz_no
|
||||
from app.crud.collection_protocol import (
|
||||
append_protocol_status_change,
|
||||
attachments_to_urls,
|
||||
urls_to_attachment_list,
|
||||
validate_persisted_sample_codes_immutable,
|
||||
validate_sample_codes_not_used,
|
||||
_format_sample_collect_summary,
|
||||
_load_express_name_map,
|
||||
_load_sample_type_name_map,
|
||||
_load_site_name_map,
|
||||
)
|
||||
@@ -46,6 +50,7 @@ from app.models.models import (
|
||||
SysUser,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizDonationStatusChange,
|
||||
BizResearchDonation,
|
||||
BizResearchDonationAudit,
|
||||
BizResearchDonationCollection,
|
||||
@@ -66,6 +71,110 @@ def total_pages(total: int, page_size: int) -> int:
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
|
||||
|
||||
_DONATION_STATUS_CHANGE_TYPE = {
|
||||
"sxz": "activate",
|
||||
"ygq": "expire",
|
||||
"yzf": "void",
|
||||
"zckc": "to_storage",
|
||||
}
|
||||
|
||||
|
||||
def _donation_status_change_to_dict(
|
||||
row: BizDonationStatusChange,
|
||||
*,
|
||||
operator_user_name: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"researchDonationId": row.research_donation_id,
|
||||
"changeType": row.change_type or "",
|
||||
"fromStatus": row.from_status or "",
|
||||
"toStatus": row.to_status or "",
|
||||
"changeTime": _fmt_dt(row.change_time),
|
||||
"changeDesc": row.change_desc or "",
|
||||
"voidType": row.void_type or "",
|
||||
"operatorUserId": row.operator_user_id or "",
|
||||
"operatorUserName": operator_user_name or "",
|
||||
"tenantNo": row.tenant_no,
|
||||
"createTime": _fmt_dt(row.create_time),
|
||||
"updateTime": _fmt_dt(row.update_time),
|
||||
}
|
||||
|
||||
|
||||
def append_donation_status_change(
|
||||
db: Session,
|
||||
*,
|
||||
research_donation_id: str,
|
||||
change_type: str,
|
||||
from_status: Optional[str],
|
||||
to_status: str,
|
||||
change_time: datetime,
|
||||
change_desc: Optional[str] = None,
|
||||
void_type: Optional[str] = None,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizDonationStatusChange:
|
||||
"""追加一条捐赠状态变更历史(不 commit)。"""
|
||||
now = datetime.now()
|
||||
obj = BizDonationStatusChange(
|
||||
id=generate_uuid(),
|
||||
research_donation_id=research_donation_id,
|
||||
change_type=(change_type or "").strip(),
|
||||
from_status=(from_status or "").strip() or None,
|
||||
to_status=(to_status or "").strip(),
|
||||
change_time=change_time,
|
||||
change_desc=(change_desc or "").strip() or None,
|
||||
void_type=(void_type or "").strip() or None,
|
||||
operator_user_id=user_id,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def list_donation_status_changes(
|
||||
db: Session,
|
||||
*,
|
||||
research_donation_id: str,
|
||||
tenant_no: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
did = (research_donation_id or "").strip()
|
||||
if not did:
|
||||
return []
|
||||
rows = (
|
||||
db.query(BizDonationStatusChange)
|
||||
.filter(
|
||||
BizDonationStatusChange.research_donation_id == did,
|
||||
BizDonationStatusChange.tenant_no == tenant_no,
|
||||
BizDonationStatusChange.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizDonationStatusChange.change_time.asc(),
|
||||
BizDonationStatusChange.create_time.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
op_ids = list({r.operator_user_id for r in rows if r.operator_user_id})
|
||||
name_map: Dict[str, str] = {}
|
||||
if op_ids:
|
||||
for u in db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(op_ids)).all():
|
||||
name_map[u.id] = u.nick_name or ""
|
||||
return [
|
||||
_donation_status_change_to_dict(
|
||||
r, operator_user_name=name_map.get(r.operator_user_id or "", "")
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
_DONATION_SAMPLE_CODE_PREFIX = "KYJZ"
|
||||
_DONATION_SAMPLE_CODE_SEQ_LEN = 4
|
||||
|
||||
@@ -143,6 +252,38 @@ def next_donation_sample_code(
|
||||
return f"{prefix}{str(max_seq + 1).zfill(_DONATION_SAMPLE_CODE_SEQ_LEN)}"
|
||||
|
||||
|
||||
def allocate_monthly_verify_no(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
create_time: datetime,
|
||||
) -> str:
|
||||
"""按租户 + create_time 年月分配 YYYYMM###;含软删占用,不回收。"""
|
||||
ym = create_time.strftime("%Y%m")
|
||||
prefix_len = len(ym)
|
||||
row = (
|
||||
db.query(BizResearchDonation.monthly_verify_no)
|
||||
.filter(
|
||||
BizResearchDonation.tenant_no == tenant_no,
|
||||
BizResearchDonation.monthly_verify_no.isnot(None),
|
||||
BizResearchDonation.monthly_verify_no.like(f"{ym}%"),
|
||||
)
|
||||
.order_by(BizResearchDonation.monthly_verify_no.desc())
|
||||
.first()
|
||||
)
|
||||
max_no = (row[0] if row else None) or ""
|
||||
seq = 0
|
||||
if max_no.startswith(ym) and len(max_no) >= prefix_len + 3:
|
||||
try:
|
||||
seq = int(max_no[prefix_len : prefix_len + 3])
|
||||
except ValueError:
|
||||
seq = 0
|
||||
nxt = seq + 1
|
||||
if nxt > 999:
|
||||
raise ValueError(f"当月核量号已用尽({ym})")
|
||||
return f"{ym}{nxt:03d}"
|
||||
|
||||
|
||||
def _fmt_dt(t: Optional[datetime]) -> str:
|
||||
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
|
||||
|
||||
@@ -264,6 +405,7 @@ def _donation_to_dict(
|
||||
return {
|
||||
"id": d.id,
|
||||
"donationNo": d.donation_no or "",
|
||||
"monthlyVerifyNo": d.monthly_verify_no or "",
|
||||
"donationStatus": d.donation_status or "",
|
||||
"donationDate": (d.donation_date or "").strip() if d.donation_date else "",
|
||||
"sourceType": d.source_type or "direct",
|
||||
@@ -360,6 +502,7 @@ def _collection_to_dict(c: BizResearchDonationCollection) -> Dict[str, Any]:
|
||||
"hospitalId": c.hospital_id or "",
|
||||
"medicalStaff": c.medical_staff or "",
|
||||
"salesUserId": c.sales_user_id or "",
|
||||
"regionManagerUserId": c.region_manager_user_id or "",
|
||||
"sampleCollectTime": _fmt_dt(c.sample_collect_time),
|
||||
"maternalBloodCollectTime": _fmt_dt(c.maternal_blood_collect_time),
|
||||
"tenantNo": c.tenant_no,
|
||||
@@ -638,12 +781,21 @@ def list_donations(
|
||||
):
|
||||
hospital_name_map[hosp.id] = hosp.hospital_name or ""
|
||||
|
||||
# 批量解析采集专员 / 销售人员名称
|
||||
# 批量解析采集专员 / 市场专员 / 地区经理名称
|
||||
sales_user_ids = list(
|
||||
{collection_map.get(did, {}).get("salesUserId", "") for did in donation_ids} - {""}
|
||||
)
|
||||
region_manager_user_ids = list(
|
||||
{
|
||||
collection_map.get(did, {}).get("regionManagerUserId", "")
|
||||
for did in donation_ids
|
||||
}
|
||||
- {""}
|
||||
)
|
||||
collector_ids = list({r.collector_user_id for r in rows if r.collector_user_id})
|
||||
user_name_map = _user_nick_name_map(db, list(set(collector_ids + sales_user_ids)))
|
||||
user_name_map = _user_nick_name_map(
|
||||
db, list(set(collector_ids + sales_user_ids + region_manager_user_ids))
|
||||
)
|
||||
|
||||
# 批量加载捐赠样本(列表展示样本类型、样本编码、采集情况)
|
||||
sample_map: Dict[str, List[BizResearchDonationSample]] = {}
|
||||
@@ -693,6 +845,30 @@ def list_donations(
|
||||
db, tenant_no, all_sample_codes, reception_source="research_donation"
|
||||
)
|
||||
|
||||
transport_by_sample_id: Dict[str, BizResearchDonationSampleTransport] = {}
|
||||
if donation_ids:
|
||||
for t in (
|
||||
db.query(BizResearchDonationSampleTransport)
|
||||
.filter(
|
||||
BizResearchDonationSampleTransport.research_donation_id.in_(donation_ids),
|
||||
BizResearchDonationSampleTransport.tenant_no == tenant_no,
|
||||
BizResearchDonationSampleTransport.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
):
|
||||
transport_by_sample_id[t.sample_id] = t
|
||||
site_name_map = _load_site_name_map(
|
||||
db, [t.dest_site_id for t in transport_by_sample_id.values() if t.dest_site_id]
|
||||
)
|
||||
express_name_map = _load_express_name_map(
|
||||
db,
|
||||
[
|
||||
t.express_company_id
|
||||
for t in transport_by_sample_id.values()
|
||||
if t.express_company_id
|
||||
],
|
||||
)
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
d = _donation_to_dict(r, db, tenant_no)
|
||||
@@ -709,6 +885,7 @@ def list_donations(
|
||||
d["newbornSex"] = pc.get("newbornSex", "")
|
||||
d["deliveryMode"] = pc.get("deliveryMode", "")
|
||||
d["newbornWeightGram"] = pc.get("newbornWeightGram")
|
||||
d["birthOrder"] = pc.get("birthOrder")
|
||||
else:
|
||||
d["motherName"] = ""
|
||||
d["motherPhone"] = ""
|
||||
@@ -720,17 +897,24 @@ def list_donations(
|
||||
d["newbornSex"] = ""
|
||||
d["deliveryMode"] = ""
|
||||
d["newbornWeightGram"] = None
|
||||
d["birthOrder"] = None
|
||||
ci = collection_map.get(r.id)
|
||||
if ci:
|
||||
d["regionName"] = region_name_map.get(ci.get("regionId", ""), "")
|
||||
d["hospitalName"] = hospital_name_map.get(ci.get("hospitalId", ""), "")
|
||||
d["salesUserName"] = user_name_map.get(ci.get("salesUserId", ""), "")
|
||||
d["regionManagerUserName"] = user_name_map.get(
|
||||
ci.get("regionManagerUserId", ""), ""
|
||||
)
|
||||
d["sampleCollectTime"] = ci.get("sampleCollectTime", "")
|
||||
d["maternalBloodCollectTime"] = ci.get("maternalBloodCollectTime", "")
|
||||
else:
|
||||
d["regionName"] = ""
|
||||
d["hospitalName"] = ""
|
||||
d["salesUserName"] = ""
|
||||
d["regionManagerUserName"] = ""
|
||||
d["sampleCollectTime"] = ""
|
||||
d["maternalBloodCollectTime"] = ""
|
||||
donation_samples = sample_map.get(r.id, [])
|
||||
type_names: List[str] = []
|
||||
codes: List[str] = []
|
||||
@@ -742,12 +926,31 @@ def list_donations(
|
||||
code = (s.sample_code or "").strip()
|
||||
if code:
|
||||
codes.append(code)
|
||||
transport_obj = transport_by_sample_id.get(s.id)
|
||||
transport_dict: Optional[Dict[str, Any]] = None
|
||||
if transport_obj and (
|
||||
(transport_obj.transport_type or "").strip() or transport_obj.eta_time
|
||||
):
|
||||
dest_site_id = transport_obj.dest_site_id or ""
|
||||
express_company_id = transport_obj.express_company_id or ""
|
||||
transport_dict = {
|
||||
"transportType": transport_obj.transport_type or "",
|
||||
"destSiteId": dest_site_id,
|
||||
"destSiteName": site_name_map.get(dest_site_id, ""),
|
||||
"expressCompanyId": express_company_id,
|
||||
"expressCompanyName": express_name_map.get(express_company_id, ""),
|
||||
"waybillNo": transport_obj.waybill_no or "",
|
||||
"carrierName": transport_obj.carrier_name or "",
|
||||
"carrierPhone": transport_obj.carrier_phone or "",
|
||||
"etaTime": _fmt_dt(transport_obj.eta_time),
|
||||
}
|
||||
sample_items.append(
|
||||
{
|
||||
"sampleTypeName": type_name,
|
||||
"sampleCode": code,
|
||||
"sampleStatus": (s.sample_status or "").strip(),
|
||||
"labStorageCode": lab_code_map.get(code, "") if code else "",
|
||||
"transport": transport_dict,
|
||||
**sample_special_to_dict(s),
|
||||
**build_sample_item_reception_qc_fields(
|
||||
reception_summary_map.get(code) if code else None,
|
||||
@@ -814,6 +1017,8 @@ def get_donation_detail(
|
||||
coll_dict = _collection_to_dict(coll) if coll else None
|
||||
if coll_dict and coll_dict.get("salesUserId"):
|
||||
user_ids.append(coll_dict["salesUserId"])
|
||||
if coll_dict and coll_dict.get("regionManagerUserId"):
|
||||
user_ids.append(coll_dict["regionManagerUserId"])
|
||||
if coll_dict:
|
||||
region_id = str(coll_dict.get("regionId") or "")
|
||||
if region_id:
|
||||
@@ -905,6 +1110,10 @@ def get_donation_detail(
|
||||
coll_dict["salesUserName"] = user_name_map.get(
|
||||
coll_dict["salesUserId"], ""
|
||||
)
|
||||
if coll_dict:
|
||||
coll_dict["regionManagerUserName"] = user_name_map.get(
|
||||
coll_dict.get("regionManagerUserId") or "", ""
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
@@ -920,6 +1129,9 @@ def create_donation(
|
||||
data: dict,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
commit: bool = True,
|
||||
sync_customers: bool = True,
|
||||
) -> BizResearchDonation:
|
||||
"""创建科研捐赠单(含 party / collection / samples + transports)。"""
|
||||
raw = _normalize_fields(data)
|
||||
@@ -937,9 +1149,13 @@ def create_donation(
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
monthly_verify_no = allocate_monthly_verify_no(
|
||||
db, tenant_no=tenant_no, create_time=now
|
||||
)
|
||||
obj = BizResearchDonation(
|
||||
id=generate_uuid(),
|
||||
donation_no=donation_no,
|
||||
monthly_verify_no=monthly_verify_no,
|
||||
donation_status=(raw.get("donation_status") or "dsx").strip(),
|
||||
donation_date=(raw.get("donation_date") or "").strip() or None,
|
||||
source_type=(raw.get("source_type") or "direct").strip(),
|
||||
@@ -954,7 +1170,7 @@ def create_donation(
|
||||
order_num=0,
|
||||
remark=(raw.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,
|
||||
@@ -969,19 +1185,34 @@ def create_donation(
|
||||
from app.services.party_customer_sync import sync_party_customers
|
||||
|
||||
party_raw = _normalize_fields(raw.get("party") or {})
|
||||
party_raw = sync_party_customers(db, party_raw, tenant_no=tenant_no, user_id=user_id or "")
|
||||
_create_party(db, donation_id, tenant_no, user_id, party_raw, now)
|
||||
if sync_customers:
|
||||
party_raw = sync_party_customers(
|
||||
db,
|
||||
party_raw,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id or "",
|
||||
commit=commit,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
_create_party(
|
||||
db, donation_id, tenant_no, user_id, party_raw, now,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
|
||||
# collection
|
||||
coll_raw = _normalize_fields(raw.get("collection") or {})
|
||||
_create_collection(db, donation_id, tenant_no, user_id, coll_raw, now)
|
||||
_create_collection(
|
||||
db, donation_id, tenant_no, user_id, coll_raw, now,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
|
||||
# samples(运费批次在 commit 后挂钩;停写旧运输依赖)
|
||||
samples_raw = raw.get("samples") or []
|
||||
new_samples = _create_samples(db, donation_id, tenant_no, user_id, samples_raw, now)
|
||||
new_samples = _create_samples(
|
||||
db, donation_id, tenant_no, user_id, samples_raw, now,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
from app.crud import sample_freight as sample_freight_svc
|
||||
|
||||
sample_freight_svc.apply_freight_on_protocol_save(
|
||||
@@ -993,7 +1224,11 @@ def create_donation(
|
||||
saved_samples=new_samples,
|
||||
input_samples=samples_raw,
|
||||
)
|
||||
db.commit()
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
else:
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
@@ -1009,6 +1244,9 @@ def update_donation(
|
||||
data: dict,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
commit: bool = True,
|
||||
sync_customers: bool = True,
|
||||
) -> BizResearchDonation:
|
||||
"""更新科研捐赠单:主表字段 + 子资源 upsert(软删旧 + 创建新)。"""
|
||||
obj = (
|
||||
@@ -1069,18 +1307,34 @@ def update_donation(
|
||||
|
||||
_soft_delete_party(db, donation_id, tenant_no, user_id)
|
||||
party_raw = _normalize_fields(raw["party"] or {})
|
||||
party_raw = sync_party_customers(db, party_raw, tenant_no=tenant_no, user_id=user_id or "")
|
||||
_create_party(db, donation_id, tenant_no, user_id, party_raw, now)
|
||||
if sync_customers:
|
||||
party_raw = sync_party_customers(
|
||||
db,
|
||||
party_raw,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id or "",
|
||||
commit=commit,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
_create_party(
|
||||
db, donation_id, tenant_no, user_id, party_raw, now,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
|
||||
# collection upsert
|
||||
if "collection" in raw:
|
||||
_soft_delete_collection(db, donation_id, tenant_no, user_id)
|
||||
coll_raw = _normalize_fields(raw["collection"] or {})
|
||||
_create_collection(db, donation_id, tenant_no, user_id, coll_raw, now)
|
||||
_create_collection(
|
||||
db, donation_id, tenant_no, user_id, coll_raw, now,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
|
||||
# samples upsert
|
||||
if "samples" in raw:
|
||||
existing_samples = list_samples(db, donation_id, tenant_no)
|
||||
samples_raw = raw["samples"] or []
|
||||
validate_persisted_sample_codes_immutable(existing_samples, samples_raw)
|
||||
old_id_to_code = {
|
||||
s.id: (s.sample_code or "").strip()
|
||||
for s in existing_samples
|
||||
@@ -1088,8 +1342,10 @@ def update_donation(
|
||||
}
|
||||
_soft_delete_samples(db, donation_id, tenant_no, user_id)
|
||||
_soft_delete_transports(db, donation_id, tenant_no, user_id)
|
||||
samples_raw = raw["samples"] or []
|
||||
new_samples = _create_samples(db, donation_id, tenant_no, user_id, samples_raw, now)
|
||||
new_samples = _create_samples(
|
||||
db, donation_id, tenant_no, user_id, samples_raw, now,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
new_code_to_id = {
|
||||
(s.sample_code or "").strip(): s.id
|
||||
for s in new_samples
|
||||
@@ -1106,8 +1362,6 @@ def update_donation(
|
||||
samples_raw = None
|
||||
new_samples = None
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
if new_samples is not None and samples_raw is not None:
|
||||
from app.crud import sample_freight as sample_freight_svc
|
||||
|
||||
@@ -1120,7 +1374,11 @@ def update_donation(
|
||||
saved_samples=new_samples,
|
||||
input_samples=samples_raw,
|
||||
)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
else:
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
@@ -1336,13 +1594,6 @@ def submit_audit(
|
||||
# 8. change_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 合法状态转换: {当前状态: {可转状态列表}}
|
||||
_VALID_TRANSITIONS: Dict[str, List[str]] = {
|
||||
"dsx": ["sxz", "yzf", "sdfq"],
|
||||
"sxz": ["ygq", "yzf", "zckc", "sdfq"],
|
||||
}
|
||||
|
||||
|
||||
def change_status(
|
||||
db: Session,
|
||||
donation_id: str,
|
||||
@@ -1371,15 +1622,14 @@ def change_status(
|
||||
|
||||
current = obj.donation_status or ""
|
||||
ns = (new_status or "").strip()
|
||||
if current != ns:
|
||||
allowed = _VALID_TRANSITIONS.get(current, [])
|
||||
if ns not in allowed:
|
||||
raise ValueError(f"状态 {current} 不可转为 {ns}")
|
||||
assert_protocol_status_transition(current, ns, kind="donation")
|
||||
|
||||
now = datetime.now()
|
||||
if isinstance(time_val, str):
|
||||
time_val = parse_datetime(time_val)
|
||||
effective_time = time_val or now
|
||||
change_desc = (desc or "").strip() or None
|
||||
normalized_void_type: Optional[str] = None
|
||||
|
||||
if current != ns:
|
||||
obj.donation_status = ns
|
||||
@@ -1389,19 +1639,35 @@ def change_status(
|
||||
# 更新对应的时间/描述字段
|
||||
if ns == "sxz":
|
||||
obj.effective_time = effective_time
|
||||
obj.effective_desc = (desc or "").strip() or None
|
||||
obj.effective_desc = change_desc
|
||||
elif ns == "yzf":
|
||||
obj.void_time = effective_time
|
||||
obj.void_desc = (desc or "").strip() or None
|
||||
obj.void_desc = change_desc
|
||||
from app.crud.dict_data import normalize_void_type
|
||||
|
||||
obj.void_type = normalize_void_type(db, value=void_type or "", tenant_no=tenant_no)
|
||||
normalized_void_type = normalize_void_type(db, value=void_type or "", tenant_no=tenant_no)
|
||||
obj.void_type = normalized_void_type
|
||||
elif ns == "ygq":
|
||||
obj.expire_time = effective_time
|
||||
obj.expire_desc = (desc or "").strip() or None
|
||||
obj.expire_desc = change_desc
|
||||
elif ns == "zckc":
|
||||
obj.to_storage_time = effective_time
|
||||
obj.to_storage_desc = (desc or "").strip() or None
|
||||
obj.to_storage_desc = change_desc
|
||||
|
||||
change_type = _DONATION_STATUS_CHANGE_TYPE.get(ns)
|
||||
if change_type:
|
||||
append_donation_status_change(
|
||||
db,
|
||||
research_donation_id=obj.id,
|
||||
change_type=change_type,
|
||||
from_status=current,
|
||||
to_status=ns,
|
||||
change_time=effective_time,
|
||||
change_desc=change_desc,
|
||||
void_type=normalized_void_type if ns == "yzf" else None,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
@@ -1494,11 +1760,15 @@ def convert_from_storage(
|
||||
|
||||
now = datetime.now()
|
||||
donation_no = pn_src
|
||||
monthly_verify_no = allocate_monthly_verify_no(
|
||||
db, tenant_no=tenant_no, create_time=now
|
||||
)
|
||||
|
||||
# 创建主记录
|
||||
donation = BizResearchDonation(
|
||||
id=generate_uuid(),
|
||||
donation_no=donation_no,
|
||||
monthly_verify_no=monthly_verify_no,
|
||||
donation_status="dsx",
|
||||
donation_date=now.strftime("%Y%m%d"),
|
||||
source_type="from_storage",
|
||||
@@ -1640,12 +1910,25 @@ def convert_from_storage(
|
||||
db.add(new_t)
|
||||
|
||||
# 更新源协议状态为 zky(转科研)
|
||||
from_status = (proto.protocol_status or "").strip()
|
||||
to_research_desc = f"已转入科研捐赠 {donation_no}"
|
||||
proto.protocol_status = "zky"
|
||||
proto.to_research_time = now
|
||||
proto.to_research_desc = f"已转入科研捐赠 {donation_no}"
|
||||
proto.to_research_desc = to_research_desc
|
||||
proto.update_by = user_id
|
||||
proto.update_time = now
|
||||
db.add(proto)
|
||||
append_protocol_status_change(
|
||||
db,
|
||||
collection_protocol_id=protocol_id,
|
||||
change_type="to_research",
|
||||
from_status=from_status,
|
||||
to_status="zky",
|
||||
change_time=now,
|
||||
change_desc=to_research_desc,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
create_convert_link(
|
||||
db,
|
||||
@@ -1886,12 +2169,25 @@ def convert_to_storage(
|
||||
db.add(new_t)
|
||||
|
||||
# 更新捐赠单状态为 zckc
|
||||
from_status = (donation.donation_status or "").strip()
|
||||
to_storage_desc = f"已转细胞存储 {protocol_no}"
|
||||
donation.donation_status = "zckc"
|
||||
donation.to_storage_time = now
|
||||
donation.to_storage_desc = f"已转细胞存储 {protocol_no}"
|
||||
donation.to_storage_desc = to_storage_desc
|
||||
donation.update_by = user_id
|
||||
donation.update_time = now
|
||||
db.add(donation)
|
||||
append_donation_status_change(
|
||||
db,
|
||||
research_donation_id=donation_id,
|
||||
change_type="to_storage",
|
||||
from_status=from_status,
|
||||
to_status="zckc",
|
||||
change_time=now,
|
||||
change_desc=to_storage_desc,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
upsert_convert_link_for_to_storage(
|
||||
db,
|
||||
@@ -1933,6 +2229,7 @@ def _create_party(
|
||||
user_id: Optional[str],
|
||||
raw: dict,
|
||||
now: datetime,
|
||||
create_dept_id: str = "",
|
||||
) -> BizResearchDonationParty:
|
||||
obj = BizResearchDonationParty(
|
||||
id=generate_uuid(),
|
||||
@@ -1941,7 +2238,7 @@ def _create_party(
|
||||
order_num=0,
|
||||
remark=(raw.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,
|
||||
@@ -1970,7 +2267,13 @@ def _create_party(
|
||||
return obj
|
||||
|
||||
|
||||
_COLL_STR_FIELDS = ["region_id", "hospital_id", "medical_staff", "sales_user_id"]
|
||||
_COLL_STR_FIELDS = [
|
||||
"region_id",
|
||||
"hospital_id",
|
||||
"medical_staff",
|
||||
"sales_user_id",
|
||||
"region_manager_user_id",
|
||||
]
|
||||
_COLL_DT_FIELDS = ["sample_collect_time", "maternal_blood_collect_time"]
|
||||
|
||||
|
||||
@@ -1981,6 +2284,7 @@ def _create_collection(
|
||||
user_id: Optional[str],
|
||||
raw: dict,
|
||||
now: datetime,
|
||||
create_dept_id: str = "",
|
||||
) -> BizResearchDonationCollection:
|
||||
obj = BizResearchDonationCollection(
|
||||
id=generate_uuid(),
|
||||
@@ -1989,7 +2293,7 @@ def _create_collection(
|
||||
order_num=0,
|
||||
remark=(raw.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,
|
||||
@@ -2081,6 +2385,7 @@ def _create_samples(
|
||||
user_id: Optional[str],
|
||||
samples: list,
|
||||
now: datetime,
|
||||
create_dept_id: str = "",
|
||||
) -> List[BizResearchDonationSample]:
|
||||
validate_sample_codes_not_used(
|
||||
db,
|
||||
@@ -2110,7 +2415,7 @@ def _create_samples(
|
||||
order_num=idx,
|
||||
remark=(raw.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,
|
||||
@@ -2139,7 +2444,26 @@ def _create_samples(
|
||||
db.flush()
|
||||
result.append(obj)
|
||||
|
||||
# 本阶段不再写入旧运输明细;运费由 apply_freight_on_protocol_save 挂钩
|
||||
# 预计到达等仍写运输表;运费由 apply_freight_on_protocol_save 挂钩
|
||||
transport_raw = raw.get("transport") if isinstance(raw.get("transport"), dict) else {}
|
||||
if not transport_raw and raw.get("eta_time"):
|
||||
transport_raw = {"eta_time": raw.get("eta_time")}
|
||||
elif isinstance(transport_raw, dict) and raw.get("eta_time") and not (
|
||||
transport_raw.get("eta_time") or transport_raw.get("etaTime")
|
||||
):
|
||||
transport_raw = {**transport_raw, "eta_time": raw.get("eta_time")}
|
||||
if transport_raw:
|
||||
_create_transport(
|
||||
db,
|
||||
donation_id,
|
||||
obj.id,
|
||||
tenant_no,
|
||||
user_id,
|
||||
transport_raw,
|
||||
idx,
|
||||
now,
|
||||
sample_code=obj.sample_code,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -2155,14 +2479,14 @@ def _create_transport(
|
||||
now: datetime,
|
||||
sample_code: Optional[str] = None,
|
||||
) -> Optional[BizResearchDonationSampleTransport]:
|
||||
"""写回捐赠样本运输;可仅有预计到达时间(运费批次改造后)。"""
|
||||
tnorm = _normalize_fields(raw)
|
||||
transport_type = (tnorm.get("transport_type") or "").strip()
|
||||
if not transport_type:
|
||||
return None
|
||||
|
||||
eta_time = tnorm.get("eta_time")
|
||||
if isinstance(eta_time, str):
|
||||
eta_time = parse_datetime(eta_time)
|
||||
if not transport_type and not eta_time:
|
||||
return None
|
||||
|
||||
express_fee = tnorm.get("express_fee")
|
||||
if express_fee is not None and express_fee != "":
|
||||
|
||||
@@ -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
|
||||
@@ -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,79 @@ 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)
|
||||
for stale in stale_items:
|
||||
stale.del_flag = 1
|
||||
stale.update_by = user_id
|
||||
stale.update_time = now
|
||||
db.add(stale)
|
||||
if stale_items:
|
||||
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 +1258,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 +1270,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 +1284,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 +1299,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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -26,6 +27,7 @@ from app.services.sample_storage_cert_qc import (
|
||||
def _row_to_dict(
|
||||
row: BizSampleStorageCertQc,
|
||||
*,
|
||||
lab_flow_code: str = "",
|
||||
protocol_code: str = "",
|
||||
mother_name: str = "",
|
||||
sample_type_name: str = "",
|
||||
@@ -33,7 +35,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,7 +101,12 @@ 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 get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -107,7 +114,7 @@ def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[s
|
||||
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)
|
||||
@@ -121,6 +128,7 @@ def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[s
|
||||
if not row:
|
||||
return 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,
|
||||
@@ -129,6 +137,7 @@ def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[s
|
||||
)
|
||||
return _row_to_dict(
|
||||
row,
|
||||
lab_flow_code=lab_flow_code,
|
||||
protocol_code=protocol_code,
|
||||
mother_name=mother_name,
|
||||
sample_type_name=type_name,
|
||||
@@ -147,7 +156,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 +202,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,
|
||||
|
||||
@@ -156,13 +156,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()
|
||||
]
|
||||
|
||||
|
||||
|
||||
+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,507 @@
|
||||
"""移动市场接口的白名单请求 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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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_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'
|
||||
)
|
||||
|
||||
|
||||
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')
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,839 @@
|
||||
"""移动市场预签单服务:通用数据权限与现场范围始终组合生效。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud import presign_order as presign_crud
|
||||
from app.models.models import (
|
||||
BizCellSampleType,
|
||||
BizCollectionHospital,
|
||||
BizCollectionRegion,
|
||||
BizCollectionRegionStaff,
|
||||
BizPresignOrder,
|
||||
BizPresignOrderSampleType,
|
||||
BizPresignTransfer,
|
||||
)
|
||||
from app.schemas.market_mobile import (
|
||||
MarketPresignOrderBody,
|
||||
MarketPresignTransferBody,
|
||||
)
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_presign_scope,
|
||||
assert_presign_access,
|
||||
)
|
||||
from app.services.market_support import validate_attachment_refs
|
||||
|
||||
|
||||
_MONEY_QUANTUM = Decimal('0.01')
|
||||
|
||||
|
||||
def _money_text(value: Optional[Decimal]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
return format(Decimal(str(value)).quantize(_MONEY_QUANTUM), 'f')
|
||||
|
||||
|
||||
def _mobile_transfer_to_dict(
|
||||
db: Session, row: BizPresignTransfer, tenant_no: str
|
||||
) -> dict:
|
||||
data = presign_crud._transfer_to_dict(db, row, tenant_no)
|
||||
data['transferAmount'] = _money_text(row.transfer_amount)
|
||||
return data
|
||||
|
||||
|
||||
def _reference_data(
|
||||
db: Session, rows: List[BizPresignOrder], tenant_no: str
|
||||
) -> dict:
|
||||
order_ids = [row.id for row in rows]
|
||||
region_ids = {row.region_id for row in rows if row.region_id}
|
||||
hospital_ids = {row.hospital_id for row in rows if row.hospital_id}
|
||||
region_names = {}
|
||||
if region_ids:
|
||||
region_names = {
|
||||
str(row.id): str(row.region_name or '').strip()
|
||||
for row in db.query(BizCollectionRegion).filter(
|
||||
BizCollectionRegion.id.in_(region_ids),
|
||||
BizCollectionRegion.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
hospital_names = {}
|
||||
if hospital_ids:
|
||||
hospital_names = {
|
||||
str(row.id): str(row.hospital_name or '').strip()
|
||||
for row in db.query(BizCollectionHospital).filter(
|
||||
BizCollectionHospital.id.in_(hospital_ids),
|
||||
BizCollectionHospital.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
type_ids_by_order = {order_id: [] for order_id in order_ids}
|
||||
if order_ids:
|
||||
links = (
|
||||
db.query(BizPresignOrderSampleType)
|
||||
.filter(
|
||||
BizPresignOrderSampleType.presign_order_id.in_(order_ids),
|
||||
BizPresignOrderSampleType.tenant_no == tenant_no,
|
||||
BizPresignOrderSampleType.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizPresignOrderSampleType.order_num.asc(),
|
||||
BizPresignOrderSampleType.create_time.asc(),
|
||||
BizPresignOrderSampleType.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for link in links:
|
||||
if link.sample_type_id:
|
||||
type_ids_by_order.setdefault(link.presign_order_id, []).append(
|
||||
str(link.sample_type_id)
|
||||
)
|
||||
type_ids = {
|
||||
type_id
|
||||
for order_type_ids in type_ids_by_order.values()
|
||||
for type_id in order_type_ids
|
||||
}
|
||||
type_names = {}
|
||||
if type_ids:
|
||||
type_names = {
|
||||
str(row.id): str(row.type_name or '').strip()
|
||||
for row in db.query(BizCellSampleType).filter(
|
||||
BizCellSampleType.id.in_(type_ids),
|
||||
BizCellSampleType.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
return {
|
||||
'regionNames': region_names,
|
||||
'hospitalNames': hospital_names,
|
||||
'sampleTypeIds': type_ids_by_order,
|
||||
'sampleTypeNames': {
|
||||
order_id: [type_names.get(type_id, '') for type_id in order_type_ids]
|
||||
for order_id, order_type_ids in type_ids_by_order.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _mobile_order_to_dict(
|
||||
db: Session,
|
||||
row: BizPresignOrder,
|
||||
tenant_no: str,
|
||||
*,
|
||||
with_children: bool,
|
||||
deposit_amount: Optional[Decimal] = None,
|
||||
user_name_map: Optional[dict] = None,
|
||||
reference_data: Optional[dict] = None,
|
||||
) -> dict:
|
||||
deposit = (
|
||||
deposit_amount
|
||||
if deposit_amount is not None
|
||||
else presign_crud.confirmed_deposit_amount(
|
||||
db, presign_order_id=row.id, tenant_no=tenant_no
|
||||
)
|
||||
)
|
||||
data = presign_crud.order_to_dict(
|
||||
db,
|
||||
row,
|
||||
tenant_no,
|
||||
with_children=False,
|
||||
deposit_amount=deposit,
|
||||
user_name_map=user_name_map,
|
||||
)
|
||||
data['contractAmount'] = _money_text(row.contract_amount)
|
||||
data['confirmedDepositAmount'] = _money_text(deposit)
|
||||
references = reference_data or _reference_data(db, [row], tenant_no)
|
||||
data['regionName'] = references['regionNames'].get(row.region_id or '', '')
|
||||
data['hospitalName'] = references['hospitalNames'].get(
|
||||
row.hospital_id or '', ''
|
||||
)
|
||||
data['sampleTypeIds'] = references['sampleTypeIds'].get(row.id, [])
|
||||
data['sampleTypeNames'] = references['sampleTypeNames'].get(row.id, [])
|
||||
if with_children:
|
||||
transfer_rows = (
|
||||
db.query(BizPresignTransfer)
|
||||
.filter(
|
||||
BizPresignTransfer.presign_order_id == row.id,
|
||||
BizPresignTransfer.tenant_no == tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
)
|
||||
.order_by(BizPresignTransfer.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
data['transfers'] = [
|
||||
_mobile_transfer_to_dict(db, transfer, tenant_no)
|
||||
for transfer in transfer_rows
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
def _dump_body(body: MarketPresignOrderBody) -> dict:
|
||||
return body.model_dump(by_alias=False, exclude_unset=True)
|
||||
|
||||
|
||||
def _dump_transfer_body(body: MarketPresignTransferBody) -> dict:
|
||||
return body.model_dump(by_alias=False, exclude_unset=True)
|
||||
|
||||
|
||||
def _reject_direct_conversion(fields: dict) -> None:
|
||||
if str(fields.get('order_status') or '').strip() == 'converted':
|
||||
raise ValueError('预签约订单必须通过转正式操作进入已转正式状态')
|
||||
|
||||
|
||||
def _assert_transfer_order_writable(order: BizPresignOrder) -> None:
|
||||
if (order.order_status or '').strip() not in ('', 'draft', 'active'):
|
||||
raise ValueError('当前状态的预签约订单不可操作转账记录')
|
||||
|
||||
|
||||
def _scoped_order_for_update(
|
||||
db: Session,
|
||||
order_id: str,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> BizPresignOrder:
|
||||
row = (
|
||||
apply_presign_scope(
|
||||
db.query(BizPresignOrder), db, ctx, user_info=user_info
|
||||
)
|
||||
.filter(BizPresignOrder.id == order_id)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail='数据不存在或无权访问')
|
||||
return row
|
||||
|
||||
|
||||
def _current_dept_id(user_info: dict) -> str:
|
||||
"""兼容 API 用户上下文与测试/内部调用的部门字段命名。"""
|
||||
if not isinstance(user_info, dict):
|
||||
return ""
|
||||
return str(
|
||||
user_info.get("dept_id")
|
||||
or user_info.get("deptId")
|
||||
or user_info.get("region_id")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _manager_assignment_allowed(
|
||||
db: Session,
|
||||
ctx: MarketFieldContext,
|
||||
*,
|
||||
region_id: Optional[str],
|
||||
region_manager_user_id: Optional[str],
|
||||
) -> bool:
|
||||
region = (region_id or "").strip()
|
||||
if not region:
|
||||
return False
|
||||
return (
|
||||
db.query(BizCollectionRegionStaff.id)
|
||||
.filter(
|
||||
BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
|
||||
BizCollectionRegionStaff.del_flag == 0,
|
||||
BizCollectionRegionStaff.user_id == ctx.user_id,
|
||||
BizCollectionRegionStaff.is_leader == 1,
|
||||
BizCollectionRegionStaff.region_id == region,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _ensure_manager_assignment(
|
||||
db: Session,
|
||||
ctx: MarketFieldContext,
|
||||
*,
|
||||
region_id: Optional[str],
|
||||
region_manager_user_id: Optional[str],
|
||||
) -> None:
|
||||
if not _manager_assignment_allowed(
|
||||
db,
|
||||
ctx,
|
||||
region_id=region_id,
|
||||
region_manager_user_id=region_manager_user_id,
|
||||
):
|
||||
raise ValueError("预签约订单归属不在当前地区经理范围内")
|
||||
|
||||
|
||||
def _validate_master_data(
|
||||
db: Session,
|
||||
ctx: MarketFieldContext,
|
||||
*,
|
||||
region_id: Optional[str],
|
||||
hospital_id: Optional[str],
|
||||
sample_type_ids: Optional[List[str]],
|
||||
) -> None:
|
||||
region = (region_id or "").strip()
|
||||
hospital = (hospital_id or "").strip()
|
||||
if region:
|
||||
region_exists = (
|
||||
db.query(BizCollectionRegion.id)
|
||||
.filter(
|
||||
BizCollectionRegion.id == region,
|
||||
BizCollectionRegion.tenant_no == ctx.tenant_no,
|
||||
BizCollectionRegion.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
if not region_exists:
|
||||
raise ValueError("采集地区不存在或不属于当前租户")
|
||||
if hospital:
|
||||
hospital_exists = (
|
||||
db.query(BizCollectionHospital.id)
|
||||
.filter(
|
||||
BizCollectionHospital.id == hospital,
|
||||
BizCollectionHospital.region_id == region,
|
||||
BizCollectionHospital.tenant_no == ctx.tenant_no,
|
||||
BizCollectionHospital.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
if not hospital_exists:
|
||||
raise ValueError("医院不存在、不属于当前租户或与采集地区不匹配")
|
||||
if sample_type_ids is not None:
|
||||
requested = {
|
||||
str(sample_type_id).strip()
|
||||
for sample_type_id in sample_type_ids
|
||||
if str(sample_type_id or "").strip()
|
||||
}
|
||||
if requested:
|
||||
existing = {
|
||||
str(row[0])
|
||||
for row in db.query(BizCellSampleType.id)
|
||||
.filter(
|
||||
BizCellSampleType.id.in_(requested),
|
||||
BizCellSampleType.tenant_no == ctx.tenant_no,
|
||||
BizCellSampleType.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
}
|
||||
if existing != requested:
|
||||
raise ValueError("样本类型不存在或不属于当前租户")
|
||||
|
||||
|
||||
def list_presign_orders(
|
||||
db: Session,
|
||||
*,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
order_status: Optional[str] = None,
|
||||
) -> Tuple[List[dict], int]:
|
||||
query = apply_presign_scope(
|
||||
db.query(BizPresignOrder), db, ctx, user_info=user_info
|
||||
)
|
||||
status = (order_status or "").strip()
|
||||
if status:
|
||||
query = query.filter(BizPresignOrder.order_status == status)
|
||||
search = (keyword or "").strip()
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
BizPresignOrder.protocol_no.like(like),
|
||||
BizPresignOrder.mother_name.like(like),
|
||||
)
|
||||
)
|
||||
total = (
|
||||
query.order_by(None)
|
||||
.with_entities(func.count(BizPresignOrder.id))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
safe_page = max(1, page)
|
||||
safe_page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
query.order_by(BizPresignOrder.create_time.desc())
|
||||
.offset((safe_page - 1) * safe_page_size)
|
||||
.limit(safe_page_size)
|
||||
.all()
|
||||
)
|
||||
order_ids = [row.id for row in rows]
|
||||
deposit_map = presign_crud.confirmed_deposit_amount_map(
|
||||
db, presign_order_ids=order_ids, tenant_no=ctx.tenant_no
|
||||
)
|
||||
person_ids = [
|
||||
user_id
|
||||
for row in rows
|
||||
for user_id in (
|
||||
row.collector_user_id,
|
||||
row.sales_user_id,
|
||||
row.region_manager_user_id,
|
||||
)
|
||||
if user_id
|
||||
]
|
||||
user_name_map = presign_crud._user_nick_name_map(
|
||||
db, person_ids, ctx.tenant_no
|
||||
)
|
||||
references = _reference_data(db, rows, ctx.tenant_no)
|
||||
return [
|
||||
_mobile_order_to_dict(
|
||||
db,
|
||||
row,
|
||||
ctx.tenant_no,
|
||||
with_children=False,
|
||||
deposit_amount=deposit_map.get(row.id, 0),
|
||||
user_name_map=user_name_map,
|
||||
reference_data=references,
|
||||
)
|
||||
for row in rows
|
||||
], int(total)
|
||||
|
||||
|
||||
def get_presign_order(
|
||||
db: Session,
|
||||
*,
|
||||
order_id: str,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> dict:
|
||||
row = assert_presign_access(
|
||||
db, order_id, ctx, user_info=user_info
|
||||
)
|
||||
references = _reference_data(db, [row], ctx.tenant_no)
|
||||
return _mobile_order_to_dict(
|
||||
db,
|
||||
row,
|
||||
ctx.tenant_no,
|
||||
with_children=True,
|
||||
reference_data=references,
|
||||
)
|
||||
|
||||
|
||||
def create_presign_order(
|
||||
db: Session,
|
||||
*,
|
||||
body: MarketPresignOrderBody,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> dict:
|
||||
fields = _dump_body(body)
|
||||
_reject_direct_conversion(fields)
|
||||
sample_type_ids = fields.pop("sample_type_ids", None)
|
||||
transfers = fields.pop("transfers", None)
|
||||
if transfers and (
|
||||
fields.get('order_status') or 'active'
|
||||
).strip() not in ('', 'draft', 'active'):
|
||||
raise ValueError('当前状态的预签约订单不可操作转账记录')
|
||||
_validate_master_data(
|
||||
db,
|
||||
ctx,
|
||||
region_id=fields.get("region_id"),
|
||||
hospital_id=fields.get("hospital_id"),
|
||||
sample_type_ids=sample_type_ids,
|
||||
)
|
||||
if ctx.scope_kind == "specialist":
|
||||
fields["sales_user_id"] = ctx.user_id
|
||||
else:
|
||||
_ensure_manager_assignment(
|
||||
db,
|
||||
ctx,
|
||||
region_id=fields.get("region_id"),
|
||||
region_manager_user_id=fields.get("region_manager_user_id"),
|
||||
)
|
||||
presign_crud.validate_presign_person_ids(
|
||||
db,
|
||||
tenant_no=ctx.tenant_no,
|
||||
collector_user_id=fields.get("collector_user_id") or ctx.user_id,
|
||||
sales_user_id=fields.get("sales_user_id"),
|
||||
region_manager_user_id=fields.get("region_manager_user_id"),
|
||||
)
|
||||
transfer_fields = [
|
||||
item.model_dump(by_alias=False, exclude_unset=True)
|
||||
if hasattr(item, "model_dump")
|
||||
else item
|
||||
for item in (transfers or [])
|
||||
]
|
||||
try:
|
||||
row = presign_crud.create_presign_order(
|
||||
db,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
fields=fields,
|
||||
sample_type_ids=sample_type_ids,
|
||||
transfers=transfer_fields,
|
||||
create_dept_id=_current_dept_id(user_info),
|
||||
commit=False,
|
||||
)
|
||||
created_transfers = (
|
||||
db.query(BizPresignTransfer)
|
||||
.filter(
|
||||
BizPresignTransfer.presign_order_id == row.id,
|
||||
BizPresignTransfer.tenant_no == ctx.tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for transfer in created_transfers:
|
||||
if transfer.transfer_attachments:
|
||||
transfer.transfer_attachments = validate_attachment_refs(
|
||||
db,
|
||||
transfer.transfer_attachments,
|
||||
module='presign',
|
||||
ctx=ctx,
|
||||
creating=True,
|
||||
business_id=row.id,
|
||||
)
|
||||
db.add(transfer)
|
||||
db.flush()
|
||||
row = assert_presign_access(
|
||||
db, row.id, ctx, user_info=user_info
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
return _mobile_order_to_dict(
|
||||
db, row, ctx.tenant_no, with_children=True
|
||||
)
|
||||
|
||||
|
||||
def update_presign_order(
|
||||
db: Session,
|
||||
*,
|
||||
order_id: str,
|
||||
body: MarketPresignOrderBody,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> dict:
|
||||
try:
|
||||
current = _scoped_order_for_update(
|
||||
db, order_id, ctx, user_info
|
||||
)
|
||||
if (current.order_status or '').strip() == 'converted':
|
||||
raise ValueError('当前状态的预签约订单不可修改')
|
||||
fields = _dump_body(body)
|
||||
_reject_direct_conversion(fields)
|
||||
sample_type_ids = fields.pop("sample_type_ids", None)
|
||||
fields.pop("transfers", None)
|
||||
resulting_region_id = fields.get("region_id", current.region_id)
|
||||
resulting_hospital_id = fields.get("hospital_id", current.hospital_id)
|
||||
_validate_master_data(
|
||||
db,
|
||||
ctx,
|
||||
region_id=resulting_region_id,
|
||||
hospital_id=resulting_hospital_id,
|
||||
sample_type_ids=sample_type_ids,
|
||||
)
|
||||
if ctx.scope_kind == "specialist":
|
||||
fields["sales_user_id"] = ctx.user_id
|
||||
elif str(resulting_region_id or '').strip() != str(current.region_id or '').strip():
|
||||
_ensure_manager_assignment(
|
||||
db,
|
||||
ctx,
|
||||
region_id=resulting_region_id,
|
||||
region_manager_user_id=fields.get(
|
||||
"region_manager_user_id", current.region_manager_user_id
|
||||
),
|
||||
)
|
||||
presign_crud.validate_presign_person_ids(
|
||||
db,
|
||||
tenant_no=ctx.tenant_no,
|
||||
collector_user_id=fields.get(
|
||||
"collector_user_id", current.collector_user_id
|
||||
),
|
||||
sales_user_id=fields.get("sales_user_id", current.sales_user_id),
|
||||
region_manager_user_id=fields.get(
|
||||
"region_manager_user_id", current.region_manager_user_id
|
||||
),
|
||||
)
|
||||
row = presign_crud.update_presign_order(
|
||||
db,
|
||||
order_id=order_id,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
fields=fields,
|
||||
sample_type_ids=sample_type_ids,
|
||||
create_dept_id=_current_dept_id(user_info),
|
||||
commit=False,
|
||||
)
|
||||
row = assert_presign_access(
|
||||
db, row.id, ctx, user_info=user_info
|
||||
)
|
||||
result = _mobile_order_to_dict(
|
||||
db, row, ctx.tenant_no, with_children=True
|
||||
)
|
||||
db.commit()
|
||||
return result
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def delete_presign_order(
|
||||
db: Session,
|
||||
order_id: str,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> None:
|
||||
try:
|
||||
row = _scoped_order_for_update(db, order_id, ctx, user_info)
|
||||
if (row.order_status or '').strip() == 'converted':
|
||||
raise ValueError('已转正式的预签约订单不可删除')
|
||||
presign_crud.soft_delete_presign_order(
|
||||
db,
|
||||
order_id=order_id,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def _scoped_transfer(
|
||||
db: Session,
|
||||
transfer_id: str,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> BizPresignTransfer:
|
||||
probe = (
|
||||
db.query(BizPresignTransfer)
|
||||
.filter(
|
||||
BizPresignTransfer.id == transfer_id,
|
||||
BizPresignTransfer.tenant_no == ctx.tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if probe is None:
|
||||
raise HTTPException(status_code=404, detail='转账记录不存在或无权访问')
|
||||
order = _scoped_order_for_update(
|
||||
db, probe.presign_order_id, ctx, user_info
|
||||
)
|
||||
_assert_transfer_order_writable(order)
|
||||
row = (
|
||||
db.query(BizPresignTransfer)
|
||||
.filter(
|
||||
BizPresignTransfer.id == transfer_id,
|
||||
BizPresignTransfer.tenant_no == ctx.tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail='转账记录不存在或无权访问')
|
||||
return row
|
||||
|
||||
|
||||
def _update_transfer_if_status(
|
||||
db: Session,
|
||||
*,
|
||||
transfer_id: str,
|
||||
tenant_no: str,
|
||||
expected_status: str,
|
||||
values: dict,
|
||||
extra_conditions: tuple = (),
|
||||
) -> None:
|
||||
updates = dict(values)
|
||||
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(updates, synchronize_session=False)
|
||||
)
|
||||
if changed != 1:
|
||||
raise ValueError('转账状态已变化,请刷新后重试')
|
||||
|
||||
|
||||
def create_presign_transfer(
|
||||
db: Session,
|
||||
order_id: str,
|
||||
body: MarketPresignTransferBody,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> dict:
|
||||
try:
|
||||
order = _scoped_order_for_update(
|
||||
db, order_id, ctx, user_info
|
||||
)
|
||||
_assert_transfer_order_writable(order)
|
||||
fields = _dump_transfer_body(body)
|
||||
if 'transfer_attachments' in fields:
|
||||
fields['transfer_attachments'] = validate_attachment_refs(
|
||||
db,
|
||||
fields['transfer_attachments'],
|
||||
module='presign',
|
||||
ctx=ctx,
|
||||
creating=True,
|
||||
business_id=order.id,
|
||||
)
|
||||
row = presign_crud.add_transfer(
|
||||
db,
|
||||
presign_order_id=order.id,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
fields=fields,
|
||||
create_dept_id=_current_dept_id(user_info),
|
||||
commit=False,
|
||||
)
|
||||
result = _mobile_transfer_to_dict(db, row, ctx.tenant_no)
|
||||
db.commit()
|
||||
return result
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def update_presign_transfer(
|
||||
db: Session,
|
||||
transfer_id: str,
|
||||
body: MarketPresignTransferBody,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> dict:
|
||||
try:
|
||||
current = _scoped_transfer(db, transfer_id, ctx, user_info)
|
||||
if (current.pay_status or '').strip() != 'dfj':
|
||||
raise ValueError('仅待缴费状态可编辑意向金转账笔')
|
||||
fields = _dump_transfer_body(body)
|
||||
if 'transfer_attachments' in fields:
|
||||
fields['transfer_attachments'] = validate_attachment_refs(
|
||||
db,
|
||||
fields['transfer_attachments'],
|
||||
module='presign',
|
||||
ctx=ctx,
|
||||
creating=False,
|
||||
existing_refs=current.transfer_attachments,
|
||||
business_id=current.presign_order_id,
|
||||
)
|
||||
row = presign_crud.update_transfer_draft(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
fields=fields,
|
||||
commit=False,
|
||||
)
|
||||
result = _mobile_transfer_to_dict(db, row, ctx.tenant_no)
|
||||
db.commit()
|
||||
return result
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def delete_presign_transfer(
|
||||
db: Session,
|
||||
transfer_id: str,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> None:
|
||||
try:
|
||||
row = _scoped_transfer(db, transfer_id, ctx, user_info)
|
||||
if (row.pay_status or '').strip() != 'dfj':
|
||||
raise ValueError('仅待缴费状态可删除意向金转账笔')
|
||||
_update_transfer_if_status(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=ctx.tenant_no,
|
||||
expected_status='dfj',
|
||||
values={
|
||||
BizPresignTransfer.del_flag: 1,
|
||||
BizPresignTransfer.update_by: ctx.user_id,
|
||||
BizPresignTransfer.update_time: datetime.now(),
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def submit_presign_transfer(
|
||||
db: Session,
|
||||
transfer_id: str,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> dict:
|
||||
try:
|
||||
_scoped_transfer(db, transfer_id, ctx, user_info)
|
||||
row = presign_crud.submit_receipt_finance(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
commit=False,
|
||||
)
|
||||
result = _mobile_transfer_to_dict(db, row, ctx.tenant_no)
|
||||
db.commit()
|
||||
return result
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def withdraw_presign_transfer(
|
||||
db: Session,
|
||||
transfer_id: str,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> dict:
|
||||
try:
|
||||
_scoped_transfer(db, transfer_id, ctx, user_info)
|
||||
row = presign_crud.withdraw_receipt(
|
||||
db,
|
||||
transfer_id=transfer_id,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
commit=False,
|
||||
)
|
||||
result = _mobile_transfer_to_dict(db, row, ctx.tenant_no)
|
||||
db.commit()
|
||||
return result
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def convert_presign_order(
|
||||
db: Session,
|
||||
*,
|
||||
order_id: str,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> dict:
|
||||
try:
|
||||
row = _scoped_order_for_update(db, order_id, ctx, user_info)
|
||||
if (row.order_status or '').strip() not in ('', 'draft', 'active'):
|
||||
raise ValueError('当前状态的预签约订单不可转正式协议')
|
||||
presign_crud.validate_presign_person_ids(
|
||||
db,
|
||||
tenant_no=ctx.tenant_no,
|
||||
collector_user_id=row.collector_user_id or ctx.user_id,
|
||||
sales_user_id=row.sales_user_id,
|
||||
region_manager_user_id=row.region_manager_user_id,
|
||||
)
|
||||
result = presign_crud.convert_to_protocol(
|
||||
db,
|
||||
order_id=order_id,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
create_dept_id=_current_dept_id(user_info),
|
||||
)
|
||||
if isinstance(result.get('presignOrder'), dict):
|
||||
result = dict(result)
|
||||
result['presignOrder'] = _mobile_order_to_dict(
|
||||
db, row, ctx.tenant_no, with_children=True
|
||||
)
|
||||
return result
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -159,8 +159,7 @@ def resolve_direct_supervisor(
|
||||
db, getattr(initiator, "supervisor_user_id", None), tenant_no
|
||||
)
|
||||
if supervisor_uid:
|
||||
if supervisor_uid == initiator_user_id:
|
||||
raise AssigneeResolveError("直属主管不能是发起人本人,请修正用户主管配置")
|
||||
# 允许主管配置为本人(如部门经理),由高级设置 autoPassWhenApproverIsInitiator 决定是否自动通过
|
||||
return [supervisor_uid], {"resolve_source": "direct_supervisor"}
|
||||
if fallback_to_dept_leader:
|
||||
users, meta = resolve_dept_leader(
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""OA 实例银行回执单(biz_attachment 关联)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import BizAttachment, OaInstance
|
||||
|
||||
BUSINESS_TYPE = "oa_bank_receipt"
|
||||
|
||||
|
||||
class OaBankReceiptError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _require_instance(db: Session, instance_id: str, tenant_no: str) -> OaInstance:
|
||||
iid = (instance_id or "").strip()
|
||||
if not iid:
|
||||
raise OaBankReceiptError("实例ID无效")
|
||||
row = (
|
||||
db.query(OaInstance)
|
||||
.filter(
|
||||
OaInstance.id == iid,
|
||||
OaInstance.tenant_no == tenant_no,
|
||||
OaInstance.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
raise OaBankReceiptError("审批实例不存在")
|
||||
return row
|
||||
|
||||
|
||||
def _row_to_item(row: BizAttachment) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"fileName": row.original_name or "",
|
||||
"fileUrl": row.file_url or "",
|
||||
"fileType": row.file_type or "",
|
||||
"fileSize": row.file_size,
|
||||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_bank_receipts(db: Session, *, instance_id: str, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
_require_instance(db, instance_id, tenant_no)
|
||||
rows = (
|
||||
db.query(BizAttachment)
|
||||
.filter(
|
||||
BizAttachment.tenant_no == tenant_no,
|
||||
BizAttachment.business_type == BUSINESS_TYPE,
|
||||
BizAttachment.business_id == instance_id,
|
||||
BizAttachment.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizAttachment.order_num.asc(),
|
||||
BizAttachment.create_time.asc(),
|
||||
BizAttachment.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_item(r) for r in rows]
|
||||
|
||||
|
||||
def replace_bank_receipts(
|
||||
db: Session,
|
||||
*,
|
||||
instance_id: str,
|
||||
tenant_no: str,
|
||||
items: List[Dict[str, Any]],
|
||||
user_id: str,
|
||||
dept_id: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
_require_instance(db, instance_id, tenant_no)
|
||||
now = datetime.now()
|
||||
desired: List[Dict[str, Any]] = []
|
||||
seen_urls = set()
|
||||
for raw in items or []:
|
||||
url = str((raw or {}).get("fileUrl") or (raw or {}).get("file_url") or "").strip()
|
||||
if not url or url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(url)
|
||||
desired.append(
|
||||
{
|
||||
"fileUrl": url,
|
||||
"fileName": str((raw or {}).get("fileName") or (raw or {}).get("file_name") or "").strip()
|
||||
or url.rsplit("/", 1)[-1],
|
||||
"fileType": str((raw or {}).get("fileType") or (raw or {}).get("file_type") or "").strip(),
|
||||
"fileSize": (raw or {}).get("fileSize")
|
||||
if (raw or {}).get("fileSize") is not None
|
||||
else (raw or {}).get("file_size"),
|
||||
"orderNum": len(desired),
|
||||
}
|
||||
)
|
||||
|
||||
existing = (
|
||||
db.query(BizAttachment)
|
||||
.filter(
|
||||
BizAttachment.tenant_no == tenant_no,
|
||||
BizAttachment.business_type == BUSINESS_TYPE,
|
||||
BizAttachment.business_id == instance_id,
|
||||
BizAttachment.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
existing_by_url = {r.file_url: r for r in existing if r.file_url}
|
||||
desired_urls = {d["fileUrl"] for d in desired}
|
||||
|
||||
for row in existing:
|
||||
if row.file_url not in desired_urls:
|
||||
row.del_flag = 1
|
||||
row.update_by = user_id or None
|
||||
row.update_time = now
|
||||
|
||||
for d in desired:
|
||||
url = d["fileUrl"]
|
||||
order_num = int(d["orderNum"])
|
||||
row: Optional[BizAttachment] = existing_by_url.get(url)
|
||||
if row is None:
|
||||
row = (
|
||||
db.query(BizAttachment)
|
||||
.filter(
|
||||
BizAttachment.tenant_no == tenant_no,
|
||||
BizAttachment.file_url == url,
|
||||
BizAttachment.del_flag == 0,
|
||||
)
|
||||
.order_by(BizAttachment.create_time.desc())
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
ext = ""
|
||||
if "." in d["fileName"]:
|
||||
ext = d["fileName"].rsplit(".", 1)[-1][:20]
|
||||
size = d["fileSize"]
|
||||
try:
|
||||
size_int = int(size) if size is not None else None
|
||||
except (TypeError, ValueError):
|
||||
size_int = None
|
||||
row = BizAttachment(
|
||||
id=uuid.uuid4().hex,
|
||||
business_type=BUSINESS_TYPE,
|
||||
business_id=instance_id,
|
||||
original_name=d["fileName"][:200],
|
||||
file_ext=ext or None,
|
||||
file_url=url,
|
||||
file_size=size_int,
|
||||
file_type=(d["fileType"] or None),
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num,
|
||||
create_by=user_id or None,
|
||||
create_dept_id=dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id or None,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
else:
|
||||
row.business_type = BUSINESS_TYPE
|
||||
row.business_id = instance_id
|
||||
row.del_flag = 0
|
||||
row.order_num = order_num
|
||||
if d["fileName"]:
|
||||
row.original_name = d["fileName"][:200]
|
||||
if d["fileType"]:
|
||||
row.file_type = d["fileType"]
|
||||
if d["fileSize"] is not None:
|
||||
try:
|
||||
row.file_size = int(d["fileSize"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
row.update_by = user_id or None
|
||||
row.update_time = now
|
||||
|
||||
db.flush()
|
||||
return list_bank_receipts(db, instance_id=instance_id, tenant_no=tenant_no)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""OA 日期时间:PC / 小程序 valueFormat 对齐与历史到分数据兼容。
|
||||
|
||||
PC FormCreate(Element)在 datetime 未配置 valueFormat 时默认为
|
||||
``YYYY-MM-DD HH:mm:ss``;小程序曾默认到分,导致回显空白。
|
||||
本模块:对「期望到秒」的字段,将 ``YYYY-MM-DD HH:mm`` 补 ``:00``。
|
||||
请假/加班等显式配置到分的字段不受影响。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
_MINUTE_ONLY_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})$")
|
||||
|
||||
|
||||
def _extract_rules(form_schema: Any) -> List[Any]:
|
||||
if form_schema is None:
|
||||
return []
|
||||
if isinstance(form_schema, list):
|
||||
return form_schema
|
||||
if isinstance(form_schema, dict):
|
||||
rule = form_schema.get("rule")
|
||||
if isinstance(rule, list):
|
||||
return rule
|
||||
return []
|
||||
|
||||
|
||||
def _walk_rules(rules: List[Any], out: Optional[List[dict]] = None) -> List[dict]:
|
||||
out = out if out is not None else []
|
||||
for r in rules or []:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
out.append(r)
|
||||
children = r.get("children")
|
||||
if isinstance(children, list):
|
||||
_walk_rules(children, out)
|
||||
props = r.get("props") or {}
|
||||
nested = props.get("rule") if isinstance(props, dict) else None
|
||||
if isinstance(nested, list):
|
||||
_walk_rules(nested, out)
|
||||
return out
|
||||
|
||||
|
||||
def _effective_value_format(rule: dict) -> str:
|
||||
props = rule.get("props") if isinstance(rule.get("props"), dict) else {}
|
||||
explicit = str(props.get("valueFormat") or props.get("format") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
picker = str(props.get("type") or "date").lower()
|
||||
if picker in ("datetime", "datetimerange"):
|
||||
return "YYYY-MM-DD HH:mm:ss"
|
||||
if picker in ("year",):
|
||||
return "YYYY"
|
||||
if picker in ("month", "monthrange"):
|
||||
return "YYYY-MM"
|
||||
return "YYYY-MM-DD"
|
||||
|
||||
|
||||
def _expects_seconds(fmt: str) -> bool:
|
||||
f = str(fmt or "")
|
||||
return "ss" in f or "SS" in f
|
||||
|
||||
|
||||
def _pad_one(val: Any) -> Any:
|
||||
if not isinstance(val, str):
|
||||
return val
|
||||
s = val.strip()
|
||||
m = _MINUTE_ONLY_RE.match(s)
|
||||
if not m:
|
||||
return val
|
||||
return f"{m.group(1)} {m.group(2)}:00"
|
||||
|
||||
|
||||
def _pad_value(val: Any) -> Any:
|
||||
if isinstance(val, list):
|
||||
return [_pad_one(x) for x in val]
|
||||
return _pad_one(val)
|
||||
|
||||
|
||||
def normalize_datetime_form_values(
|
||||
form_schema: Any, form_data: Optional[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""对期望到秒的 datePicker 字段,将到分字符串补全为到秒。返回新 dict,不改入参。"""
|
||||
data = dict(form_data or {})
|
||||
for rule in _walk_rules(_extract_rules(form_schema)):
|
||||
typ = str(rule.get("type") or "").lower()
|
||||
if typ not in ("datepicker", "fcdatepicker", "date"):
|
||||
continue
|
||||
field = rule.get("field")
|
||||
if not field or field not in data:
|
||||
continue
|
||||
fmt = _effective_value_format(rule)
|
||||
if not _expects_seconds(fmt):
|
||||
continue
|
||||
data[field] = _pad_value(data.get(field))
|
||||
return data
|
||||
@@ -43,6 +43,7 @@ class OaEngineError(ValueError):
|
||||
|
||||
|
||||
_AUTO_PASS_COMMENT = "系统自动通过"
|
||||
_DEDUP_AUTO_PASS_COMMENT = "审批人去重,系统自动通过"
|
||||
|
||||
INITIATOR_CONDITION_FIELD = "__initiator__"
|
||||
|
||||
@@ -1766,6 +1767,13 @@ def advance_from(
|
||||
user_id=user_id,
|
||||
)
|
||||
created_ids.extend(nested or [])
|
||||
deduped = _maybe_auto_pass_approver_dedup(
|
||||
db,
|
||||
inst=inst,
|
||||
ver=ver,
|
||||
tasks=tasks,
|
||||
)
|
||||
created_ids.extend(deduped or [])
|
||||
return created_ids
|
||||
# 未知类型跳过子节点
|
||||
cur = next_node(cur)
|
||||
@@ -1801,6 +1809,10 @@ def start_instance(
|
||||
|
||||
schema = _loads(ver.form_schema_json, {})
|
||||
data = dict(form_data or {})
|
||||
from app.services.oa_datetime_normalize import normalize_datetime_form_values
|
||||
|
||||
# 小程序历史默认到分、PC 默认到秒:入库前对「期望到秒」字段补 :00
|
||||
data = normalize_datetime_form_values(schema, data)
|
||||
try:
|
||||
data = apply_formulas_and_linkages(schema, data)
|
||||
except FormulaError as e:
|
||||
@@ -1878,6 +1890,18 @@ def start_instance(
|
||||
if recv_errs:
|
||||
raise OaEngineError(recv_errs[0])
|
||||
|
||||
from app.services.oa_receive_account import validate_receive_account_ownership
|
||||
|
||||
own_errs = validate_receive_account_ownership(
|
||||
db,
|
||||
schema,
|
||||
data,
|
||||
tenant_no=tenant_no,
|
||||
initiator_user_id=initiator_user_id,
|
||||
)
|
||||
if own_errs:
|
||||
raise OaEngineError(own_errs[0])
|
||||
|
||||
now = datetime.now()
|
||||
title_text = (title or app.app_name or "").strip()
|
||||
normalized_select = _normalize_initiator_select_by_node(initiator_select_by_node)
|
||||
@@ -2019,6 +2043,12 @@ def get_instance_detail(
|
||||
]
|
||||
data["relatedInstanceIds"] = [r.to_instance_id for r in rels]
|
||||
data["formSchemaJson"] = _loads(ver.form_schema_json, {}) if ver else {}
|
||||
from app.services.oa_datetime_normalize import normalize_datetime_form_values
|
||||
|
||||
# 详情回显:历史到分值补秒,避免 PC Element 日期控件解析失败显示空白
|
||||
data["formDataJson"] = normalize_datetime_form_values(
|
||||
data["formSchemaJson"], data.get("formDataJson") or {}
|
||||
)
|
||||
adv = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS)
|
||||
data["advancedSettingsJson"] = adv
|
||||
data["allowAddSign"] = bool(adv.get("allowAddSign", True))
|
||||
@@ -2254,6 +2284,19 @@ def _apply_instance_list_filters(
|
||||
return q, joined_app, joined_initiator
|
||||
|
||||
|
||||
def _filter_by_initiator_dept(q, db: Session, *, tenant_no: str, initiator_dept_id: Optional[str]):
|
||||
"""按发起部门(含下级)过滤;空参数不改 query。"""
|
||||
did = (initiator_dept_id or "").strip()
|
||||
if not did:
|
||||
return q
|
||||
from app.core.data_scope import _get_child_dept_ids
|
||||
|
||||
dept_ids = _get_child_dept_ids(db, did, tenant_no)
|
||||
if not dept_ids:
|
||||
return q.filter(OaInstance.id == "")
|
||||
return q.filter(OaInstance.initiator_dept_id.in_(dept_ids))
|
||||
|
||||
|
||||
def list_instance_history(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -2268,6 +2311,7 @@ def list_instance_history(
|
||||
finish_time_from: Optional[str] = None,
|
||||
finish_time_to: Optional[str] = None,
|
||||
instance_status: Optional[str] = None,
|
||||
initiator_dept_id: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -2287,6 +2331,10 @@ def list_instance_history(
|
||||
q = q.filter(OaInstance.initiator_user_id == user_id)
|
||||
initiator_name = None
|
||||
|
||||
q = _filter_by_initiator_dept(
|
||||
q, db, tenant_no=tenant_no, initiator_dept_id=initiator_dept_id
|
||||
)
|
||||
|
||||
iid = (instance_id or "").strip()
|
||||
if iid:
|
||||
q = q.filter(OaInstance.id.like(f"%{iid}%"))
|
||||
@@ -2547,6 +2595,7 @@ def _history_export_query(
|
||||
finish_time_from: Optional[str] = None,
|
||||
finish_time_to: Optional[str] = None,
|
||||
instance_status: Optional[str] = None,
|
||||
initiator_dept_id: Optional[str] = None,
|
||||
):
|
||||
q = db.query(OaInstance).filter(
|
||||
OaInstance.tenant_no == tenant_no,
|
||||
@@ -2557,6 +2606,10 @@ def _history_export_query(
|
||||
q = q.filter(OaInstance.initiator_user_id == user_id)
|
||||
initiator_name = None
|
||||
|
||||
q = _filter_by_initiator_dept(
|
||||
q, db, tenant_no=tenant_no, initiator_dept_id=initiator_dept_id
|
||||
)
|
||||
|
||||
clean_ids = [str(x).strip() for x in (instance_ids or []) if str(x).strip()]
|
||||
if clean_ids:
|
||||
q = q.filter(OaInstance.id.in_(clean_ids))
|
||||
@@ -2599,6 +2652,7 @@ def export_instance_history(
|
||||
finish_time_from: Optional[str] = None,
|
||||
finish_time_to: Optional[str] = None,
|
||||
instance_status: Optional[str] = None,
|
||||
initiator_dept_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
from app.services.oa_related_approval import flatten_form_fields_for_summary
|
||||
|
||||
@@ -2623,6 +2677,7 @@ def export_instance_history(
|
||||
finish_time_from=finish_time_from,
|
||||
finish_time_to=finish_time_to,
|
||||
instance_status=instance_status,
|
||||
initiator_dept_id=initiator_dept_id,
|
||||
)
|
||||
total = q.count()
|
||||
if total > _HISTORY_EXPORT_MAX_ROWS:
|
||||
@@ -2867,7 +2922,10 @@ def list_tasks(
|
||||
}
|
||||
|
||||
if kind == "mention":
|
||||
from app.services.oa_instance_comment import list_mentioned_instance_ids
|
||||
from app.services.oa_instance_comment import (
|
||||
list_mentioned_instance_ids,
|
||||
mention_read_map,
|
||||
)
|
||||
|
||||
ids = list_mentioned_instance_ids(db, tenant_no=tenant_no, user_id=user_id)
|
||||
if not ids:
|
||||
@@ -2892,8 +2950,20 @@ def list_tasks(
|
||||
total = len(insts)
|
||||
insts = insts[offset : offset + page_size]
|
||||
rows = [_instance_to_dict(i) for i in insts]
|
||||
enriched = _enrich_list_rows(
|
||||
db, tenant_no=tenant_no, rows=rows, instances=insts
|
||||
)
|
||||
read_map = mention_read_map(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
instance_ids=[str(i.id) for i in insts],
|
||||
)
|
||||
for row in enriched:
|
||||
rid = str(row.get("id") or row.get("instanceId") or "").strip()
|
||||
row["isRead"] = bool(read_map.get(rid, True))
|
||||
return {
|
||||
"list": _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts),
|
||||
"list": enriched,
|
||||
"total": int(total),
|
||||
}
|
||||
|
||||
@@ -3088,6 +3158,63 @@ def _maybe_auto_pass_initiator(
|
||||
)
|
||||
|
||||
|
||||
def _prior_approved_assignees(db: Session, instance_id: str) -> set:
|
||||
rows = (
|
||||
db.query(OaTask.assignee_user_id)
|
||||
.filter(
|
||||
OaTask.instance_id == instance_id,
|
||||
OaTask.task_type == "approval",
|
||||
OaTask.status == "approved",
|
||||
OaTask.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {(row[0] or "").strip() for row in rows if (row[0] or "").strip()}
|
||||
|
||||
|
||||
def _maybe_auto_pass_approver_dedup(
|
||||
db: Session,
|
||||
*,
|
||||
inst: OaInstance,
|
||||
ver: OaAppVersion,
|
||||
tasks: List[OaTask],
|
||||
) -> List[str]:
|
||||
"""同一实例内已同意过的审批人,后续节点不再人工审批。
|
||||
|
||||
或签:该人自动通过后整节点通过;会签:仅自动完成本人,其他人仍待批。
|
||||
仅统计 status=approved 的审批任务;拒绝等状态不触发。
|
||||
"""
|
||||
advanced = _advanced(ver)
|
||||
if not advanced.get("approverDedup"):
|
||||
return []
|
||||
if inst.status != "running":
|
||||
return []
|
||||
prior = _prior_approved_assignees(db, inst.id)
|
||||
if not prior:
|
||||
return []
|
||||
created: List[str] = []
|
||||
for task in tasks:
|
||||
if inst.status != "running":
|
||||
break
|
||||
if task.task_type != "approval" or task.status != "pending":
|
||||
continue
|
||||
assignee = (task.assignee_user_id or "").strip()
|
||||
if assignee not in prior:
|
||||
continue
|
||||
created.extend(
|
||||
_approve_task_core(
|
||||
db,
|
||||
task=task,
|
||||
inst=inst,
|
||||
ver=ver,
|
||||
user_id=assignee,
|
||||
comment=_DEDUP_AUTO_PASS_COMMENT,
|
||||
)
|
||||
or []
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
def complete_task(
|
||||
db: Session,
|
||||
task_id: str,
|
||||
|
||||
@@ -210,11 +210,7 @@ def _comment_to_dict(
|
||||
if not isinstance(refs, list):
|
||||
refs = []
|
||||
status = (row.status or "normal").strip() or "normal"
|
||||
can_revoke = (
|
||||
status == "normal"
|
||||
and instance_status == "running"
|
||||
and (row.create_by or "") == (viewer_user_id or "")
|
||||
)
|
||||
can_revoke = status == "normal" and (row.create_by or "") == (viewer_user_id or "")
|
||||
return {
|
||||
"id": row.id,
|
||||
"instanceId": row.instance_id,
|
||||
@@ -335,8 +331,8 @@ def _ensure_view_grants(
|
||||
) -> None:
|
||||
if not user_ids:
|
||||
return
|
||||
existing = (
|
||||
db.query(OaInstanceViewGrant.user_id)
|
||||
existing_rows = (
|
||||
db.query(OaInstanceViewGrant)
|
||||
.filter(
|
||||
OaInstanceViewGrant.instance_id == instance_id,
|
||||
OaInstanceViewGrant.tenant_no == tenant_no,
|
||||
@@ -345,10 +341,17 @@ def _ensure_view_grants(
|
||||
)
|
||||
.all()
|
||||
)
|
||||
have = {str(r[0]) for r in existing}
|
||||
by_uid = {str(r.user_id): r for r in existing_rows}
|
||||
now = datetime.now()
|
||||
for uid in user_ids:
|
||||
if uid in have:
|
||||
row = by_uid.get(uid)
|
||||
if row is not None:
|
||||
# 再 @:重置未读
|
||||
row.is_read = 0
|
||||
row.comment_id = comment_id
|
||||
row.granted_by = actor_user_id
|
||||
row.update_by = actor_user_id
|
||||
row.update_time = now
|
||||
continue
|
||||
db.add(
|
||||
OaInstanceViewGrant(
|
||||
@@ -358,6 +361,7 @@ def _ensure_view_grants(
|
||||
source="comment_mention",
|
||||
comment_id=comment_id,
|
||||
granted_by=actor_user_id,
|
||||
is_read=0,
|
||||
tenant_no=tenant_no,
|
||||
create_by=actor_user_id,
|
||||
create_dept_id=dept_id or "",
|
||||
@@ -408,6 +412,83 @@ def list_mentioned_instance_ids(
|
||||
return [iid for iid in inst_ids if iid in allowed]
|
||||
|
||||
|
||||
def mention_read_map(
|
||||
db: Session, *, tenant_no: str, user_id: str, instance_ids: List[str]
|
||||
) -> Dict[str, bool]:
|
||||
"""instance_id → isRead(无 grant 视为已读)。"""
|
||||
ids = [str(x).strip() for x in (instance_ids or []) if str(x).strip()]
|
||||
uid = (user_id or "").strip()
|
||||
if not ids or not uid:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(OaInstanceViewGrant)
|
||||
.filter(
|
||||
OaInstanceViewGrant.tenant_no == tenant_no,
|
||||
OaInstanceViewGrant.user_id == uid,
|
||||
OaInstanceViewGrant.del_flag == 0,
|
||||
OaInstanceViewGrant.instance_id.in_(ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
out: Dict[str, bool] = {iid: True for iid in ids}
|
||||
for g in rows:
|
||||
iid = (g.instance_id or "").strip()
|
||||
if iid:
|
||||
out[iid] = bool(int(getattr(g, "is_read", 0) or 0) == 1)
|
||||
return out
|
||||
|
||||
|
||||
def count_mention_unread(db: Session, *, tenant_no: str, user_id: str) -> int:
|
||||
uid = (user_id or "").strip()
|
||||
if not uid:
|
||||
return 0
|
||||
q = (
|
||||
db.query(OaInstanceViewGrant)
|
||||
.join(
|
||||
OaInstance,
|
||||
OaInstance.id == OaInstanceViewGrant.instance_id,
|
||||
)
|
||||
.filter(
|
||||
OaInstanceViewGrant.tenant_no == tenant_no,
|
||||
OaInstanceViewGrant.user_id == uid,
|
||||
OaInstanceViewGrant.del_flag == 0,
|
||||
OaInstanceViewGrant.is_read == 0,
|
||||
OaInstance.tenant_no == tenant_no,
|
||||
OaInstance.del_flag == 0,
|
||||
)
|
||||
)
|
||||
return int(q.count())
|
||||
|
||||
|
||||
def mark_mention_instance_read(
|
||||
db: Session, *, tenant_no: str, user_id: str, instance_id: str
|
||||
) -> Dict[str, Any]:
|
||||
uid = (user_id or "").strip()
|
||||
iid = (instance_id or "").strip()
|
||||
if not uid or not iid:
|
||||
raise OaEngineError("参数无效")
|
||||
row = (
|
||||
db.query(OaInstanceViewGrant)
|
||||
.filter(
|
||||
OaInstanceViewGrant.tenant_no == tenant_no,
|
||||
OaInstanceViewGrant.user_id == uid,
|
||||
OaInstanceViewGrant.instance_id == iid,
|
||||
OaInstanceViewGrant.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
raise OaEngineError("提到记录不存在或无权操作")
|
||||
now = datetime.now()
|
||||
if int(getattr(row, "is_read", 0) or 0) != 1:
|
||||
row.is_read = 1
|
||||
row.update_by = uid
|
||||
row.update_time = now
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"instanceId": iid, "isRead": True}
|
||||
|
||||
|
||||
def create_comment(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -420,8 +501,6 @@ def create_comment(
|
||||
mention_user_ids: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
inst = _get_instance(db, instance_id, tenant_no)
|
||||
if (inst.status or "") != "running":
|
||||
raise OaEngineError("仅审批进行中可评论")
|
||||
text = (content or "").strip()
|
||||
if len(text) > CONTENT_MAX:
|
||||
raise OaEngineError(f"评论内容不能超过{CONTENT_MAX}字")
|
||||
@@ -467,6 +546,14 @@ def create_comment(
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
if mentions:
|
||||
from app.services.oa_wechat_notify import schedule_oa_mention_notifies
|
||||
|
||||
schedule_oa_mention_notifies(
|
||||
instance_id=instance_id,
|
||||
commenter_user_id=user_id,
|
||||
mentioned_user_ids=mentions,
|
||||
)
|
||||
name_map = _user_name_map(db, [user_id, *mentions])
|
||||
return _comment_to_dict(
|
||||
row,
|
||||
@@ -485,8 +572,6 @@ def revoke_comment(
|
||||
user_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
inst = _get_instance(db, instance_id, tenant_no)
|
||||
if (inst.status or "") != "running":
|
||||
raise OaEngineError("仅审批进行中可撤回评论")
|
||||
row = (
|
||||
db.query(OaInstanceComment)
|
||||
.filter(
|
||||
|
||||
@@ -3,6 +3,10 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import OaReceiveAccount
|
||||
|
||||
ACCOUNT_TYPES = frozenset({"alipay", "personal", "corporate"})
|
||||
|
||||
_BANK_FIELD_ALIASES = {
|
||||
@@ -199,3 +203,45 @@ def validate_receive_account_form_values(
|
||||
continue
|
||||
errors.extend(_validate_receive_account_rule(rule, data))
|
||||
return errors
|
||||
|
||||
|
||||
def validate_receive_account_ownership(
|
||||
db: Session,
|
||||
schema: Any,
|
||||
form_data: Optional[Dict[str, Any]],
|
||||
*,
|
||||
tenant_no: str,
|
||||
initiator_user_id: str,
|
||||
) -> List[str]:
|
||||
"""校验收款账户快照 id 属于发起人。调用前须已通过格式校验。"""
|
||||
data = form_data or {}
|
||||
owner_id = (initiator_user_id or "").strip()
|
||||
for rule in _schema_rules(schema):
|
||||
if str(rule.get("type") or "") != "receiveAccount":
|
||||
continue
|
||||
field = str(rule.get("field") or "").strip()
|
||||
if not field:
|
||||
continue
|
||||
value = data.get(field)
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
label = _field_label(rule)
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
item_id = str(item.get("id") or "").strip()
|
||||
if not item_id:
|
||||
continue
|
||||
row = (
|
||||
db.query(OaReceiveAccount)
|
||||
.filter(
|
||||
OaReceiveAccount.id == item_id,
|
||||
OaReceiveAccount.tenant_no == tenant_no,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if row is None or int(row.del_flag or 0) == 1:
|
||||
return [f"「{label}」不存在或已删除"]
|
||||
if (row.create_by or "").strip() != owner_id:
|
||||
return [f"「{label}」不属于当前用户"]
|
||||
return []
|
||||
@@ -29,6 +29,7 @@ RESULT_OTHER = "其他"
|
||||
RETURN_PENDING = "/pages/oa/pending/index"
|
||||
RETURN_CC = "/pages/oa/cc/index"
|
||||
RETURN_MINE = "/pages/oa/mine/index"
|
||||
RETURN_MENTIONS = "/pages/oa/mentions/index"
|
||||
|
||||
_ACTION_RESULT = {
|
||||
"approve": RESULT_PASS,
|
||||
@@ -72,6 +73,14 @@ def _build_matter_title(*, applicant: str, process_name: str, fallback: str) ->
|
||||
return (fallback or "").strip() or "审批"
|
||||
|
||||
|
||||
def _build_mention_title(commenter_name: str) -> str:
|
||||
"""抄送模板 thing5:评论人在评论中提到了你。"""
|
||||
name = (commenter_name or "").strip()
|
||||
if name:
|
||||
return f"{name} 在评论中提到了你"
|
||||
return "有人在评论中提到了你"
|
||||
|
||||
|
||||
def _resolve_process_and_applicant(db, inst: Optional[OaInstance]):
|
||||
applicant = ""
|
||||
process_name = ""
|
||||
@@ -224,6 +233,130 @@ def _oa_wx_notify_enabled() -> bool:
|
||||
return bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False))
|
||||
|
||||
|
||||
def _send_mention_one(
|
||||
db,
|
||||
*,
|
||||
instance_id: str,
|
||||
mentioned_user_id: str,
|
||||
commenter_user_id: str,
|
||||
) -> None:
|
||||
"""评论 @:复用抄送模板;thing2=评论人,thing5=「xxx 在评论中提到了你」。"""
|
||||
uid = (mentioned_user_id or "").strip()
|
||||
if not uid:
|
||||
return
|
||||
user = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
|
||||
mp = (getattr(user, "wx_mp_openid", None) or "").strip() if user else ""
|
||||
if not mp:
|
||||
logger.info(
|
||||
"oa wx mention skip: no mp_openid user=%s inst=%s", uid, instance_id
|
||||
)
|
||||
return
|
||||
iid = (instance_id or "").strip()
|
||||
inst = (
|
||||
db.query(OaInstance)
|
||||
.filter(OaInstance.id == iid, OaInstance.del_flag == 0)
|
||||
.first()
|
||||
if iid
|
||||
else None
|
||||
)
|
||||
process_name = ""
|
||||
if inst and inst.app_id:
|
||||
app = (
|
||||
db.query(OaApp)
|
||||
.filter(OaApp.id == inst.app_id, OaApp.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
process_name = (app.app_name if app else "") or ""
|
||||
if not process_name and inst:
|
||||
process_name = (inst.title or "").strip()
|
||||
|
||||
commenter_name = ""
|
||||
cid = (commenter_user_id or "").strip()
|
||||
if cid:
|
||||
commenter = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == cid, SysUser.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
commenter_name = (commenter.nick_name if commenter else "") or ""
|
||||
|
||||
pagepath = build_oa_detail_pagepath(
|
||||
instance_id=iid,
|
||||
task_id="",
|
||||
mode="view",
|
||||
return_path=RETURN_MENTIONS,
|
||||
)
|
||||
send_oa_cc_notify(
|
||||
mp_openid=mp,
|
||||
pagepath=pagepath,
|
||||
title=_build_mention_title(commenter_name),
|
||||
applicant=commenter_name or "未知",
|
||||
node_name="",
|
||||
time_text=datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
process_name=process_name or "审批",
|
||||
)
|
||||
|
||||
|
||||
def notify_oa_mentions_sync(
|
||||
*,
|
||||
instance_id: str,
|
||||
commenter_user_id: str,
|
||||
mentioned_user_ids: Iterable[str],
|
||||
) -> None:
|
||||
if not _oa_wx_notify_enabled():
|
||||
logger.info("oa wx mention skipped: WX_MP_NOTIFY_ENABLED=false")
|
||||
return
|
||||
iid = (instance_id or "").strip()
|
||||
ids = [str(x).strip() for x in (mentioned_user_ids or []) if str(x).strip()]
|
||||
if not iid or not ids:
|
||||
return
|
||||
db = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
for uid in ids:
|
||||
try:
|
||||
_send_mention_one(
|
||||
db,
|
||||
instance_id=iid,
|
||||
mentioned_user_id=uid,
|
||||
commenter_user_id=commenter_user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"oa wx mention failed user=%s inst=%s", uid, iid
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("oa wx mention batch failed inst=%s", iid)
|
||||
finally:
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def schedule_oa_mention_notifies(
|
||||
*,
|
||||
instance_id: str,
|
||||
commenter_user_id: str,
|
||||
mentioned_user_ids: List[str],
|
||||
) -> None:
|
||||
if not _oa_wx_notify_enabled():
|
||||
logger.info("oa wx mention schedule skipped: WX_MP_NOTIFY_ENABLED=false")
|
||||
return
|
||||
iid = (instance_id or "").strip()
|
||||
ids = [str(x).strip() for x in (mentioned_user_ids or []) if str(x).strip()]
|
||||
if not iid or not ids:
|
||||
return
|
||||
threading.Thread(
|
||||
target=notify_oa_mentions_sync,
|
||||
kwargs={
|
||||
"instance_id": iid,
|
||||
"commenter_user_id": (commenter_user_id or "").strip(),
|
||||
"mentioned_user_ids": ids,
|
||||
},
|
||||
daemon=True,
|
||||
name="oa-wx-mention",
|
||||
).start()
|
||||
|
||||
|
||||
def notify_oa_tasks_sync(task_ids: Iterable[str]) -> None:
|
||||
if not _oa_wx_notify_enabled():
|
||||
logger.info("oa wx notify skipped: WX_MP_NOTIFY_ENABLED=false")
|
||||
|
||||
@@ -23,6 +23,10 @@ _ROLE_SPECS = (
|
||||
"name_keys": ("mother_name", "motherName"),
|
||||
"phone_keys": ("mother_phone", "motherPhone"),
|
||||
"id_card_keys": ("mother_id_card", "motherIdCard"),
|
||||
"birth_ym_keys": ("mother_birth_ym", "motherBirthYm"),
|
||||
"ethnic_keys": ("mother_ethnic", "motherEthnic"),
|
||||
"email_keys": ("mother_email", "motherEmail"),
|
||||
"address_keys": ("mother_address", "motherAddress"),
|
||||
"sex_keys": (),
|
||||
"default_sex": "1",
|
||||
},
|
||||
@@ -33,6 +37,10 @@ _ROLE_SPECS = (
|
||||
"name_keys": ("father_name", "fatherName"),
|
||||
"phone_keys": ("father_phone", "fatherPhone"),
|
||||
"id_card_keys": ("father_id_card", "fatherIdCard"),
|
||||
"birth_ym_keys": (),
|
||||
"ethnic_keys": (),
|
||||
"email_keys": (),
|
||||
"address_keys": (),
|
||||
"sex_keys": (),
|
||||
"default_sex": "0",
|
||||
},
|
||||
@@ -43,11 +51,27 @@ _ROLE_SPECS = (
|
||||
"name_keys": ("newborn_name", "newbornName"),
|
||||
"phone_keys": ("newborn_phone", "newbornPhone"),
|
||||
"id_card_keys": ("newborn_id_card", "newbornIdCard"),
|
||||
"birth_ym_keys": (),
|
||||
"ethnic_keys": (),
|
||||
"email_keys": (),
|
||||
"address_keys": (),
|
||||
"sex_keys": ("newborn_sex", "newbornSex"),
|
||||
"default_sex": "2",
|
||||
},
|
||||
)
|
||||
|
||||
_PROFILE_ATTRS = (
|
||||
"customer_name",
|
||||
"phone_no",
|
||||
"id_card_no",
|
||||
"sex",
|
||||
"age",
|
||||
"birth_ym",
|
||||
"ethnic",
|
||||
"email",
|
||||
"address",
|
||||
)
|
||||
|
||||
|
||||
def _is_empty(val: Any) -> bool:
|
||||
if val is None:
|
||||
@@ -92,6 +116,22 @@ def _age_from_id_card(id_card: str) -> Optional[int]:
|
||||
return age
|
||||
|
||||
|
||||
def apply_role_profile_overwrite(target: Any, profile: Any) -> None:
|
||||
"""协议非空字段覆盖客户主档;空串/None 跳过,不碰 tags/remark。"""
|
||||
for attr in _PROFILE_ATTRS:
|
||||
val = getattr(profile, attr, None)
|
||||
if _is_empty(val):
|
||||
continue
|
||||
if attr == "sex":
|
||||
setattr(target, attr, str(val).strip()[:1])
|
||||
elif attr == "birth_ym":
|
||||
setattr(target, attr, str(val).strip()[:6])
|
||||
elif attr == "age":
|
||||
setattr(target, attr, val)
|
||||
else:
|
||||
setattr(target, attr, str(val).strip())
|
||||
|
||||
|
||||
def merge_customer_fields(target: Any, source: Any) -> None:
|
||||
"""目标为空才补;性别在空或未知(2)时补;标签逗号并集;姓名保留目标。"""
|
||||
if _is_empty(getattr(target, "phone_no", None)):
|
||||
@@ -111,6 +151,11 @@ def merge_customer_fields(target: Any, source: Any) -> None:
|
||||
src_remark = getattr(source, "remark", None)
|
||||
if not _is_empty(src_remark):
|
||||
target.remark = src_remark
|
||||
for attr in ("birth_ym", "ethnic", "email", "address"):
|
||||
if _is_empty(getattr(target, attr, None)):
|
||||
src_val = getattr(source, attr, None)
|
||||
if not _is_empty(src_val):
|
||||
setattr(target, attr, src_val)
|
||||
target.customer_tags = _union_tags(
|
||||
getattr(target, "customer_tags", None),
|
||||
getattr(source, "customer_tags", None),
|
||||
@@ -152,53 +197,76 @@ def repoint_customer_refs(db: Session, from_id: str, to_id: str, tenant_no: str)
|
||||
).update({col_name: to_id}, synchronize_session=False)
|
||||
|
||||
|
||||
def _build_role_profile(party: Dict[str, Any], spec: Dict[str, Any]) -> SimpleNamespace:
|
||||
name = _pick(party, spec["name_keys"])
|
||||
phone = _pick(party, spec["phone_keys"])
|
||||
id_card = _pick(party, spec["id_card_keys"])
|
||||
sex = _pick(party, spec["sex_keys"]) or spec["default_sex"]
|
||||
birth_ym = _pick(party, spec.get("birth_ym_keys") or ())
|
||||
ethnic = _pick(party, spec.get("ethnic_keys") or ())
|
||||
email = _pick(party, spec.get("email_keys") or ())
|
||||
address = _pick(party, spec.get("address_keys") or ())
|
||||
return SimpleNamespace(
|
||||
customer_name=name or None,
|
||||
phone_no=phone or None,
|
||||
id_card_no=id_card or None,
|
||||
sex=sex or None,
|
||||
age=_age_from_id_card(id_card),
|
||||
birth_ym=birth_ym or None,
|
||||
ethnic=ethnic or None,
|
||||
email=email or None,
|
||||
address=address or None,
|
||||
)
|
||||
|
||||
|
||||
def _create_role_customer(
|
||||
db: Session,
|
||||
*,
|
||||
name: str,
|
||||
sex: str,
|
||||
id_card: str,
|
||||
phone: str,
|
||||
profile: SimpleNamespace,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
commit: bool = True,
|
||||
create_dept_id: str = "",
|
||||
) -> str:
|
||||
obj = customer_crud.create_customer(
|
||||
db,
|
||||
customer_name=name,
|
||||
sex=sex,
|
||||
id_card_no=id_card or None,
|
||||
age=_age_from_id_card(id_card),
|
||||
phone_no=phone or None,
|
||||
customer_name=profile.customer_name,
|
||||
sex=profile.sex or "2",
|
||||
id_card_no=profile.id_card_no or None,
|
||||
age=profile.age,
|
||||
birth_ym=profile.birth_ym,
|
||||
ethnic=profile.ethnic,
|
||||
email=profile.email,
|
||||
address=profile.address,
|
||||
phone_no=profile.phone_no or None,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
commit=commit,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
return obj.id
|
||||
|
||||
|
||||
def _apply_direct_identity(obj: Any, *, name: str, phone: str, id_card: str) -> None:
|
||||
obj.customer_name = name
|
||||
if phone:
|
||||
obj.phone_no = phone
|
||||
if id_card:
|
||||
obj.id_card_no = id_card
|
||||
age = _age_from_id_card(id_card)
|
||||
if age is not None and _is_empty(getattr(obj, "age", None)):
|
||||
obj.age = age
|
||||
|
||||
|
||||
def _merge_and_repoint(
|
||||
db: Session,
|
||||
*,
|
||||
from_id: str,
|
||||
to_customer: Any,
|
||||
tenant_no: str,
|
||||
profile: Optional[SimpleNamespace] = None,
|
||||
commit: bool = True,
|
||||
) -> str:
|
||||
source = get_customer(db, from_id, tenant_no=tenant_no)
|
||||
if source is not None:
|
||||
merge_customer_fields(to_customer, source)
|
||||
db.add(to_customer)
|
||||
if profile is not None:
|
||||
apply_role_profile_overwrite(to_customer, profile)
|
||||
db.add(to_customer)
|
||||
repoint_customer_refs(db, from_id, to_customer.id, tenant_no)
|
||||
customer_crud.soft_delete_customer(db, from_id, tenant_no=tenant_no)
|
||||
customer_crud.soft_delete_customer(
|
||||
db, from_id, tenant_no=tenant_no, commit=commit
|
||||
)
|
||||
return to_customer.id
|
||||
|
||||
|
||||
@@ -208,55 +276,68 @@ def _sync_one_role(
|
||||
spec: Dict[str, Any],
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
commit: bool = True,
|
||||
create_dept_id: str = "",
|
||||
) -> str:
|
||||
name = _pick(party, spec["name_keys"])
|
||||
if not name:
|
||||
profile = _build_role_profile(party, spec)
|
||||
if _is_empty(profile.customer_name):
|
||||
return ""
|
||||
phone = _pick(party, spec["phone_keys"])
|
||||
id_card = _pick(party, spec["id_card_keys"])
|
||||
id_card = profile.id_card_no or ""
|
||||
linked_id = _pick(party, spec["id_keys"])
|
||||
sex = _pick(party, spec["sex_keys"]) or spec["default_sex"]
|
||||
|
||||
found = find_customer_by_id_card(db, id_card, tenant_no) if id_card else None
|
||||
|
||||
if found is not None:
|
||||
if linked_id and linked_id != found.id:
|
||||
return _merge_and_repoint(db, from_id=linked_id, to_customer=found, tenant_no=tenant_no)
|
||||
merge_customer_fields(
|
||||
found,
|
||||
SimpleNamespace(
|
||||
phone_no=phone or None,
|
||||
age=_age_from_id_card(id_card),
|
||||
sex=sex,
|
||||
remark=None,
|
||||
customer_tags=None,
|
||||
),
|
||||
)
|
||||
return _merge_and_repoint(
|
||||
db,
|
||||
from_id=linked_id,
|
||||
to_customer=found,
|
||||
tenant_no=tenant_no,
|
||||
profile=profile,
|
||||
commit=commit,
|
||||
)
|
||||
apply_role_profile_overwrite(found, profile)
|
||||
db.add(found)
|
||||
return found.id
|
||||
|
||||
if linked_id:
|
||||
linked = get_customer(db, linked_id, tenant_no=tenant_no)
|
||||
if linked is not None:
|
||||
_apply_direct_identity(linked, name=name, phone=phone, id_card=id_card)
|
||||
apply_role_profile_overwrite(linked, profile)
|
||||
db.add(linked)
|
||||
return linked.id
|
||||
|
||||
return _create_role_customer(
|
||||
db,
|
||||
name=name,
|
||||
sex=sex,
|
||||
id_card=id_card,
|
||||
phone=phone,
|
||||
profile=profile,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
commit=commit,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
|
||||
|
||||
def sync_party_customers(db: Session, party_dict: Dict[str, Any], tenant_no: str, user_id: str) -> Dict[str, Any]:
|
||||
def sync_party_customers(
|
||||
db: Session,
|
||||
party_dict: Dict[str, Any],
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
*,
|
||||
commit: bool = True,
|
||||
create_dept_id: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
out = dict(party_dict or {})
|
||||
for spec in _ROLE_SPECS:
|
||||
role_id = _sync_one_role(db, out, spec, tenant_no, user_id)
|
||||
role_id = _sync_one_role(
|
||||
db,
|
||||
out,
|
||||
spec,
|
||||
tenant_no,
|
||||
user_id,
|
||||
commit=commit,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
for key in spec["id_keys"]:
|
||||
out[key] = role_id
|
||||
return out
|
||||
@@ -0,0 +1,61 @@
|
||||
"""催缴纳入规则(纯函数)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional, Tuple
|
||||
|
||||
from app.services.sample_payment_period_summary import summarize_period
|
||||
|
||||
|
||||
def _dec(v: Any) -> Decimal:
|
||||
if v is None or v == "":
|
||||
return Decimal("0")
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def chase_period_money(
|
||||
*,
|
||||
due_amount: Any,
|
||||
transfers: Iterable[Mapping[str, Any]],
|
||||
) -> Tuple[Decimal, Decimal]:
|
||||
"""已缴 = 仅 yjf;欠缴 = max(应缴 − 已缴, 0)。"""
|
||||
summary: Dict[str, Any] = summarize_period(
|
||||
due_amount=due_amount, transfers=transfers
|
||||
)
|
||||
return summary["displayPaidAmount"], summary["arrearsAmount"]
|
||||
|
||||
|
||||
def is_chase_candidate(*, due_date: date, today: date, arrears: Any) -> bool:
|
||||
"""欠缴>0,且应缴日已逾期或落在 [today, today+30]。"""
|
||||
if _dec(arrears) <= 0:
|
||||
return False
|
||||
if due_date < today:
|
||||
return True
|
||||
return today <= due_date <= today + timedelta(days=30)
|
||||
|
||||
|
||||
def parse_due_date_ymd(raw: Any) -> Optional[date]:
|
||||
value = str(raw or "")
|
||||
if len(value) == 8 and value.isdigit():
|
||||
normalized = value
|
||||
elif (
|
||||
len(value) == 10
|
||||
and value[4] == "-"
|
||||
and value[7] == "-"
|
||||
and value[:4].isdigit()
|
||||
and value[5:7].isdigit()
|
||||
and value[8:].isdigit()
|
||||
):
|
||||
normalized = value.replace("-", "")
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
return date(
|
||||
int(normalized[:4]),
|
||||
int(normalized[4:6]),
|
||||
int(normalized[6:8]),
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -12,10 +12,10 @@ LOCKED_STATUSES = frozenset({"jfqrz", "yjf", "tfqrz", "ytf"})
|
||||
BLOCKING_STATUSES = frozenset({"jfqrz", "tfqrz"})
|
||||
|
||||
|
||||
def _dec(v: Any) -> float:
|
||||
def _dec(v: Any) -> Decimal:
|
||||
if v is None:
|
||||
return 0.0
|
||||
return float(Decimal(str(v)))
|
||||
return Decimal("0")
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def dj_due_amount_mismatch_hint(
|
||||
@@ -31,7 +31,7 @@ def dj_due_amount_mismatch_hint(
|
||||
_dec(r.get("due_amount") if r.get("due_amount") is not None else r.get("dueAmount"))
|
||||
for r in rows
|
||||
)
|
||||
if abs(total_due - contract) > 0.015:
|
||||
if abs(total_due - contract) > Decimal("0.015"):
|
||||
return f"趸交各明细应缴金额之和应为 {contract:.2f},当前为 {total_due:.2f},请核对"
|
||||
return None
|
||||
|
||||
@@ -43,41 +43,46 @@ def validate_payment_details_for_save(
|
||||
details: Optional[list],
|
||||
installment_ratios: Optional[List[float]] = None,
|
||||
installment_advance_days: Optional[int] = None,
|
||||
locked_rows_are_read_only: bool = False,
|
||||
) -> None:
|
||||
"""保存缴费明细前的 P0 校验。
|
||||
|
||||
趸交应缴合计与合同金额不一致仅作前端提醒,不在此阻断;按用户填写原样保存。
|
||||
"""
|
||||
if pay_mode not in {'dj', 'fq', 'djfq', 'fgd'}:
|
||||
raise ValueError('不支持的缴费模式')
|
||||
rows = [dict(d) for d in (details or [])]
|
||||
contract = _dec(contract_amount)
|
||||
|
||||
for r in rows:
|
||||
status = (r.get("pay_status") or r.get("payStatus") or "dfj").strip()
|
||||
if status in BLOCKING_STATUSES:
|
||||
raise ValueError("存在缴费确认中或退费处理中的明细,无法保存")
|
||||
if not locked_rows_are_read_only:
|
||||
for r in rows:
|
||||
status = (r.get("pay_status") or r.get("payStatus") or "dfj").strip()
|
||||
if status in BLOCKING_STATUSES:
|
||||
raise ValueError("存在付款确认中或退费处理中的明细,无法保存")
|
||||
|
||||
yjf_rows = [r for r in rows if (r.get("pay_status") or r.get("payStatus")) == "yjf"]
|
||||
|
||||
if pay_mode in ("fq", "djfq"):
|
||||
if pay_mode in ("fq", "djfq", "fgd"):
|
||||
count = int(installment_count or 0)
|
||||
if count < 1:
|
||||
raise ValueError("分期模式须填写分期期数")
|
||||
raise ValueError("分期/分改趸模式须填写期数")
|
||||
if installment_advance_days is not None and int(installment_advance_days) < 0:
|
||||
raise ValueError("应缴费提前天数不能为负数")
|
||||
if installment_ratios is not None:
|
||||
values = [float(x) for x in installment_ratios]
|
||||
values = [Decimal(str(x)) for x in installment_ratios]
|
||||
if len(values) != count:
|
||||
raise ValueError("分期金额数量须与分期期数一致")
|
||||
if is_legacy_installment_ratios(values, Decimal(str(contract)) if contract else None):
|
||||
if abs(sum(values) - 100.0) > 0.01:
|
||||
if abs(sum(values) - Decimal("100")) > Decimal("0.01"):
|
||||
raise ValueError("缴费比例之和须等于 100%")
|
||||
if len(rows) != count:
|
||||
raise ValueError(f"分期明细条数应等于分期期数({count})")
|
||||
fq_forbidden = frozenset({"jfqrz", "tfqrz"})
|
||||
for r in rows:
|
||||
status = (r.get("pay_status") or r.get("payStatus") or "dfj").strip()
|
||||
if status in fq_forbidden:
|
||||
raise ValueError("分期模式下存在不可保存的缴费状态")
|
||||
if not locked_rows_are_read_only:
|
||||
fq_forbidden = frozenset({"jfqrz", "tfqrz"})
|
||||
for r in rows:
|
||||
status = (r.get("pay_status") or r.get("payStatus") or "dfj").strip()
|
||||
if status in fq_forbidden:
|
||||
raise ValueError("分期模式下存在不可保存的缴费状态")
|
||||
if yjf_rows:
|
||||
max_paid_no = max(
|
||||
int(r.get("installment_no") or r.get("installmentNo") or 0)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import json
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP
|
||||
from typing import List, Optional, Sequence, Union
|
||||
|
||||
Number = Union[int, float, Decimal, str]
|
||||
@@ -55,7 +55,11 @@ def add_interval_months_yyyymmdd(date_str: str, total_months: Number) -> str:
|
||||
if whole:
|
||||
result = add_months_yyyymmdd(result, whole)
|
||||
if frac:
|
||||
days = int(round(float(frac) * DAYS_PER_MONTH))
|
||||
days = int(
|
||||
(frac * Decimal(DAYS_PER_MONTH)).to_integral_value(
|
||||
rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
if days:
|
||||
result = add_days_yyyymmdd(result, days)
|
||||
return result
|
||||
@@ -75,12 +79,12 @@ def calc_fq_installment_due_date(
|
||||
return base
|
||||
|
||||
|
||||
def parse_installment_ratios(raw: Optional[str]) -> List[float]:
|
||||
def parse_installment_ratios(raw: Optional[str]) -> List[Decimal]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return [float(x) for x in data] if isinstance(data, list) else []
|
||||
return [Decimal(str(x)) for x in data] if isinstance(data, list) else []
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return []
|
||||
|
||||
@@ -91,12 +95,20 @@ def is_legacy_installment_ratios(
|
||||
) -> bool:
|
||||
if not values:
|
||||
return False
|
||||
nums = [float(v) for v in values]
|
||||
nums = [Decimal(str(v)) for v in values]
|
||||
total = sum(nums)
|
||||
mx = max(nums)
|
||||
if contract_amount is not None and abs(total - float(contract_amount)) < 0.02:
|
||||
if contract_amount is not None and abs(total - contract_amount) < Decimal('0.02'):
|
||||
return False
|
||||
return mx <= 100 and abs(total - 100) < 0.05
|
||||
return mx <= Decimal('100') and abs(total - Decimal('100')) < Decimal('0.05')
|
||||
|
||||
|
||||
def _money_to_cents(value: Decimal) -> int:
|
||||
return int(
|
||||
(Decimal(str(value)) * Decimal('100')).to_integral_value(
|
||||
rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def resolve_installment_due_amounts(
|
||||
@@ -117,7 +129,7 @@ def _calc_installment_due_amounts_inline(
|
||||
) -> List[Decimal]:
|
||||
if contract_amount is None or count < 1:
|
||||
return []
|
||||
total_cents = int(round(float(contract_amount) * 100))
|
||||
total_cents = _money_to_cents(contract_amount)
|
||||
base_cents = total_cents // count
|
||||
amounts: List[Decimal] = []
|
||||
sum_cents = 0
|
||||
@@ -139,14 +151,19 @@ def calc_installment_due_amounts_by_ratios(
|
||||
if contract_amount is None or not ratios:
|
||||
return []
|
||||
count = len(ratios)
|
||||
total_cents = int(round(float(contract_amount) * 100))
|
||||
total_cents = _money_to_cents(contract_amount)
|
||||
amounts: List[Decimal] = []
|
||||
sum_cents = 0
|
||||
for i in range(count):
|
||||
if i == count - 1:
|
||||
cents = total_cents - sum_cents
|
||||
else:
|
||||
cents = int(total_cents * float(ratios[i]) / 100.0)
|
||||
ratio = Decimal(str(ratios[i]))
|
||||
cents = int(
|
||||
(Decimal(total_cents) * ratio / Decimal('100')).to_integral_value(
|
||||
rounding=ROUND_DOWN
|
||||
)
|
||||
)
|
||||
sum_cents += cents
|
||||
amounts.append(Decimal(cents) / Decimal(100))
|
||||
return amounts
|
||||
@@ -160,18 +177,49 @@ def calc_unpaid_amounts_by_ratios(
|
||||
"""部分已缴:按未缴期次比例归一化分配 remaining。"""
|
||||
if remaining <= 0 or not unpaid_indices:
|
||||
return []
|
||||
subset = [float(ratios[i]) for i in unpaid_indices]
|
||||
subset = [Decimal(str(ratios[i])) for i in unpaid_indices]
|
||||
ratio_sum = sum(subset)
|
||||
if ratio_sum <= 0:
|
||||
return []
|
||||
total_cents = int(round(float(remaining) * 100))
|
||||
total_cents = _money_to_cents(remaining)
|
||||
amounts: List[Decimal] = []
|
||||
sum_cents = 0
|
||||
for j, _idx in enumerate(unpaid_indices):
|
||||
if j == len(unpaid_indices) - 1:
|
||||
cents = total_cents - sum_cents
|
||||
else:
|
||||
cents = int(total_cents * subset[j] / ratio_sum)
|
||||
cents = int(
|
||||
(Decimal(total_cents) * subset[j] / ratio_sum).to_integral_value(
|
||||
rounding=ROUND_DOWN
|
||||
)
|
||||
)
|
||||
sum_cents += cents
|
||||
amounts.append(Decimal(cents) / Decimal(100))
|
||||
return amounts
|
||||
|
||||
|
||||
def resolve_locked_remaining_due_amounts(
|
||||
contract_amount: Decimal,
|
||||
locked_due_amount: Decimal,
|
||||
stored: Sequence[Number],
|
||||
unpaid_indices: Sequence[int],
|
||||
) -> List[Decimal]:
|
||||
"""按剩余合同金额分配未缴期次;有期次权重时按权重,无则均摊。"""
|
||||
contract = Decimal(str(contract_amount))
|
||||
locked = Decimal(str(locked_due_amount))
|
||||
remaining = contract - locked
|
||||
if remaining < 0:
|
||||
raise ValueError("合同金额不能小于已缴或锁定明细金额")
|
||||
indices = [int(index) for index in unpaid_indices]
|
||||
if not indices:
|
||||
return []
|
||||
values = [Decimal(str(value)) for value in stored]
|
||||
if (
|
||||
values
|
||||
and max(indices) < len(values)
|
||||
and all(values[index] > 0 for index in indices)
|
||||
):
|
||||
weighted = calc_unpaid_amounts_by_ratios(remaining, values, indices)
|
||||
if weighted:
|
||||
return weighted
|
||||
return _calc_installment_due_amounts_inline(remaining, len(indices))
|
||||
@@ -155,12 +155,23 @@ def get_list_reception_summary_map_by_sample_codes(
|
||||
out: Dict[str, Dict[str, str]] = {}
|
||||
for rec in rows:
|
||||
code = (rec.sample_code or "").strip()
|
||||
if not code or code in out:
|
||||
if not code:
|
||||
continue
|
||||
prev = out.get(code)
|
||||
prev_t = prev.get("_receptionTime") if prev else None
|
||||
rec_t = rec.reception_time
|
||||
if prev and rec_t and prev_t and rec_t <= prev_t:
|
||||
continue
|
||||
if prev and rec_t is None and prev_t is not None:
|
||||
continue
|
||||
out[code] = {
|
||||
"acceptConclusion": (rec.accept_conclusion or "").strip(),
|
||||
"remark": (rec.remark or "").strip(),
|
||||
"receptionTime": rec_t.strftime("%Y-%m-%d %H:%M:%S") if rec_t else "",
|
||||
"_receptionTime": rec_t,
|
||||
}
|
||||
for v in out.values():
|
||||
v.pop("_receptionTime", None)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ def can_confirm_receipt(pay_status: Optional[str]) -> bool:
|
||||
|
||||
|
||||
def can_withdraw_receipt(pay_status: Optional[str]) -> bool:
|
||||
"""仅缴费确认中可撤回为待缴费。"""
|
||||
"""仅付款确认中可撤回为已付款。"""
|
||||
return (pay_status or "").strip() == "jfqrz"
|
||||
|
||||
|
||||
def can_edit_transfer(pay_status: Optional[str]) -> bool:
|
||||
"""财务确认收款(yjf)前可编辑:待缴费 / 缴费确认中。"""
|
||||
"""财务确认收款(yjf)前可编辑:已付款 / 付款确认中。"""
|
||||
return (pay_status or "").strip() in ("dfj", "jfqrz")
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -12,13 +12,16 @@ from app.models.models import (
|
||||
BizPilotInfusionSchedule,
|
||||
BizProtocolPayment,
|
||||
BizProtocolPaymentDetail,
|
||||
BizProtocolPaymentSchedule,
|
||||
BizProtocolPaymentTransfer,
|
||||
BizProtocolSamplePayment,
|
||||
)
|
||||
|
||||
_PAY_MODE_LABELS = {"dj": "趸交", "fq": "分期", "djfq": "趸交+分期"}
|
||||
_PAY_STATUS_LABELS = {
|
||||
"dfj": "待缴费",
|
||||
"jfqrz": "缴费确认中",
|
||||
"yjf": "已缴费",
|
||||
"dfj": "已付款",
|
||||
"jfqrz": "付款确认中",
|
||||
"yjf": "付款完成",
|
||||
"tfqrz": "退费确认中",
|
||||
"ytf": "已退费",
|
||||
}
|
||||
@@ -169,10 +172,59 @@ def build_protocol_payment_summary(
|
||||
}
|
||||
|
||||
|
||||
def pick_earliest_transfer_time_by_protocol(
|
||||
rows: Sequence[Tuple[Any, Any]],
|
||||
) -> Dict[str, datetime]:
|
||||
"""从 (protocol_id, transfer_time) 行中按协议取最早 transfer_time。"""
|
||||
earliest: Dict[str, datetime] = {}
|
||||
for pid, t in rows:
|
||||
if not pid or t is None:
|
||||
continue
|
||||
prev = earliest.get(pid)
|
||||
if prev is None or t < prev:
|
||||
earliest[pid] = t
|
||||
return earliest
|
||||
|
||||
|
||||
def _first_period_first_transfer_times(
|
||||
db: Session,
|
||||
protocol_ids: List[str],
|
||||
tenant_no: str,
|
||||
) -> Dict[str, str]:
|
||||
"""协议 → 第 1 期(schedule_no=1)多笔转账中 transfer_time 最早的一笔。"""
|
||||
ids = [x.strip() for x in protocol_ids if (x or "").strip()]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(
|
||||
BizProtocolPaymentTransfer.collection_protocol_id,
|
||||
BizProtocolPaymentTransfer.transfer_time,
|
||||
)
|
||||
.join(
|
||||
BizProtocolPaymentSchedule,
|
||||
BizProtocolPaymentSchedule.id == BizProtocolPaymentTransfer.schedule_id,
|
||||
)
|
||||
.filter(
|
||||
BizProtocolPaymentTransfer.collection_protocol_id.in_(ids),
|
||||
BizProtocolPaymentTransfer.tenant_no == tenant_no,
|
||||
BizProtocolPaymentTransfer.del_flag == 0,
|
||||
BizProtocolPaymentSchedule.tenant_no == tenant_no,
|
||||
BizProtocolPaymentSchedule.del_flag == 0,
|
||||
BizProtocolPaymentSchedule.schedule_no == 1,
|
||||
BizProtocolPaymentTransfer.transfer_time.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
earliest = pick_earliest_transfer_time_by_protocol(rows)
|
||||
return {pid: _fmt_dt(t) for pid, t in earliest.items() if _fmt_dt(t)}
|
||||
|
||||
|
||||
def build_protocol_payment_summaries(
|
||||
db: Session,
|
||||
protocol_ids: List[str],
|
||||
tenant_no: str,
|
||||
*,
|
||||
include_sample_payment_modes: bool = False,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
ids = [x.strip() for x in protocol_ids if (x or "").strip()]
|
||||
if not ids:
|
||||
@@ -200,14 +252,56 @@ def build_protocol_payment_summaries(
|
||||
.all()
|
||||
):
|
||||
details_by_payment.setdefault(d.payment_id, []).append(d)
|
||||
sample_modes: Dict[str, set] = {}
|
||||
if include_sample_payment_modes:
|
||||
for pid, mode in db.query(
|
||||
BizProtocolSamplePayment.collection_protocol_id,
|
||||
BizProtocolSamplePayment.pay_mode,
|
||||
).filter(
|
||||
BizProtocolSamplePayment.collection_protocol_id.in_(ids),
|
||||
BizProtocolSamplePayment.tenant_no == tenant_no,
|
||||
BizProtocolSamplePayment.del_flag == 0,
|
||||
).all():
|
||||
if mode:
|
||||
sample_modes.setdefault(pid, set()).add(mode)
|
||||
first_pay_map = _first_period_first_transfer_times(db, ids, tenant_no)
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for pid in ids:
|
||||
pay = pay_by_proto.get(pid)
|
||||
if not pay:
|
||||
summary = (
|
||||
build_protocol_payment_summary(pay, details_by_payment.get(pay.id, []))
|
||||
if pay
|
||||
else None
|
||||
)
|
||||
first_pay = first_pay_map.get(pid) or ""
|
||||
modes = sample_modes.get(pid, set())
|
||||
if not summary and not first_pay and not modes:
|
||||
continue
|
||||
summary = build_protocol_payment_summary(pay, details_by_payment.get(pay.id, []))
|
||||
if summary:
|
||||
out[pid] = summary
|
||||
if not summary:
|
||||
summary = {
|
||||
"payMode": "",
|
||||
"payModeLabel": "",
|
||||
"contractAmount": None,
|
||||
"receivedAmount": None,
|
||||
"pendingAmount": None,
|
||||
"statusHint": "",
|
||||
"installmentCount": None,
|
||||
"paymentDetails": [],
|
||||
"overallPayStatus": "",
|
||||
"overallPayStatusLabel": "—",
|
||||
"overallPayStatusTagType": "info",
|
||||
}
|
||||
if modes:
|
||||
# 协议列表按有效样本方案展示;未开启的质检费用摘要保持原语义。
|
||||
ordered_modes = sorted(modes, key=lambda mode: (
|
||||
{"dj": 0, "fq": 1, "djfq": 2, "fgd": 3}.get(mode, 99), mode
|
||||
))
|
||||
labels = {**_PAY_MODE_LABELS, "fgd": "分改趸"}
|
||||
summary["payMode"] = "djfq" if modes == {"dj", "fq"} else "+".join(ordered_modes)
|
||||
summary["payModeLabel"] = "+".join(labels.get(mode, mode) for mode in ordered_modes)
|
||||
if first_pay:
|
||||
summary["firstPayTransferTime"] = first_pay
|
||||
out[pid] = summary
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
"""样本编码全链路纠错:预览影响面 + 事务级联更新。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
BizCellProduct,
|
||||
BizProductionTaskInput,
|
||||
BizProductionWorkOrder,
|
||||
BizProtocolPaymentSchedule,
|
||||
BizProtocolPaymentTransfer,
|
||||
BizProtocolSample,
|
||||
BizProtocolSamplePayment,
|
||||
BizProtocolSampleTransport,
|
||||
BizQcTask,
|
||||
BizQcWorkOrder,
|
||||
BizSampleCode,
|
||||
BizSampleCodeCorrectionLog,
|
||||
BizSampleCustomerService,
|
||||
BizSampleFreightItem,
|
||||
BizSampleInsurance,
|
||||
BizSampleProductionReception,
|
||||
BizSampleReception,
|
||||
BizSampleStorageCert,
|
||||
BizSampleStorageCertQc,
|
||||
BizStorageReleaseReview,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonationSample,
|
||||
BizResearchDonationSampleTransport,
|
||||
)
|
||||
|
||||
SOURCE_CELL = "cell_storage"
|
||||
SOURCE_DONATION = "research_donation"
|
||||
VALID_SOURCES = {SOURCE_CELL, SOURCE_DONATION}
|
||||
|
||||
# (key, label, model, code_attr, has_reception_source, cell_only)
|
||||
_TABLE_SPECS: List[Tuple[str, str, Any, str, bool, bool]] = [
|
||||
("biz_protocol_sample_payment", "按样本缴费", BizProtocolSamplePayment, "sample_code", False, True),
|
||||
("biz_protocol_payment_schedule", "缴费期次", BizProtocolPaymentSchedule, "sample_code", False, True),
|
||||
("biz_protocol_payment_transfer", "缴费转账", BizProtocolPaymentTransfer, "sample_code", False, True),
|
||||
("biz_protocol_sample_transport", "协议样本运输", BizProtocolSampleTransport, "sample_code", False, True),
|
||||
(
|
||||
"biz_research_donation_sample_transport",
|
||||
"捐赠样本运输",
|
||||
BizResearchDonationSampleTransport,
|
||||
"sample_code",
|
||||
False,
|
||||
False,
|
||||
),
|
||||
("biz_sample_freight_item", "运费明细", BizSampleFreightItem, "sample_code", False, False),
|
||||
("biz_sample_reception", "样本接收", BizSampleReception, "sample_code", True, False),
|
||||
("biz_qc_work_order", "质检工单", BizQcWorkOrder, "sample_code", True, False),
|
||||
("biz_qc_task", "质检任务", BizQcTask, "sample_code", False, False),
|
||||
("biz_storage_release_review", "入库放行", BizStorageReleaseReview, "sample_code", True, False),
|
||||
("biz_sample_storage_cert_qc", "储存证质检", BizSampleStorageCertQc, "sample_code", False, True),
|
||||
(
|
||||
"biz_sample_production_reception",
|
||||
"生产部门接收",
|
||||
BizSampleProductionReception,
|
||||
"sample_code",
|
||||
True,
|
||||
False,
|
||||
),
|
||||
("biz_production_work_order", "生产工单", BizProductionWorkOrder, "sample_code", True, False),
|
||||
("biz_sample_storage_cert", "储存证", BizSampleStorageCert, "sample_code", False, True),
|
||||
("biz_sample_insurance", "样本保险", BizSampleInsurance, "sample_code", False, True),
|
||||
("biz_sample_customer_service", "客户服务扩展", BizSampleCustomerService, "sample_code", False, True),
|
||||
]
|
||||
|
||||
_UNIQUE_TABLES = {
|
||||
"biz_sample_storage_cert",
|
||||
"biz_sample_insurance",
|
||||
"biz_sample_customer_service",
|
||||
"biz_sample_storage_cert_qc",
|
||||
}
|
||||
|
||||
|
||||
class SampleCodeCorrectionError(ValueError):
|
||||
"""业务校验失败(可直接返回给前端)。"""
|
||||
|
||||
|
||||
def _norm(code: Optional[str]) -> str:
|
||||
return (code or "").strip()
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _load_source_sample(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
reception_source: str,
|
||||
sample_code: str,
|
||||
):
|
||||
code = _norm(sample_code)
|
||||
if reception_source == SOURCE_CELL:
|
||||
return (
|
||||
db.query(BizProtocolSample)
|
||||
.filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolSample.sample_code == code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return (
|
||||
db.query(BizResearchDonationSample)
|
||||
.filter(
|
||||
BizResearchDonationSample.tenant_no == tenant_no,
|
||||
BizResearchDonationSample.del_flag == 0,
|
||||
BizResearchDonationSample.sample_code == code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _code_used_by_other_sample(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
sample_code: str,
|
||||
exclude_sample_id: Optional[str],
|
||||
) -> bool:
|
||||
code = _norm(sample_code)
|
||||
q1 = db.query(BizProtocolSample.id).filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolSample.sample_code == code,
|
||||
)
|
||||
q2 = db.query(BizResearchDonationSample.id).filter(
|
||||
BizResearchDonationSample.tenant_no == tenant_no,
|
||||
BizResearchDonationSample.del_flag == 0,
|
||||
BizResearchDonationSample.sample_code == code,
|
||||
)
|
||||
if exclude_sample_id:
|
||||
q1 = q1.filter(BizProtocolSample.id != exclude_sample_id)
|
||||
q2 = q2.filter(BizResearchDonationSample.id != exclude_sample_id)
|
||||
return q1.first() is not None or q2.first() is not None
|
||||
|
||||
|
||||
def _count_by_code(
|
||||
db: Session,
|
||||
model,
|
||||
*,
|
||||
tenant_no: str,
|
||||
code: str,
|
||||
code_attr: str = "sample_code",
|
||||
reception_source: Optional[str] = None,
|
||||
has_reception_source: bool = False,
|
||||
) -> int:
|
||||
col = getattr(model, code_attr)
|
||||
q = db.query(model).filter(
|
||||
model.tenant_no == tenant_no,
|
||||
model.del_flag == 0,
|
||||
col == code,
|
||||
)
|
||||
if has_reception_source and reception_source:
|
||||
q = q.filter(model.reception_source == reception_source)
|
||||
return int(q.count())
|
||||
|
||||
|
||||
def _update_by_code(
|
||||
db: Session,
|
||||
model,
|
||||
*,
|
||||
tenant_no: str,
|
||||
old_code: str,
|
||||
new_code: str,
|
||||
code_attr: str = "sample_code",
|
||||
reception_source: Optional[str] = None,
|
||||
has_reception_source: bool = False,
|
||||
user_id: Optional[str] = None,
|
||||
) -> int:
|
||||
col = getattr(model, code_attr)
|
||||
q = db.query(model).filter(
|
||||
model.tenant_no == tenant_no,
|
||||
model.del_flag == 0,
|
||||
col == old_code,
|
||||
)
|
||||
if has_reception_source and reception_source:
|
||||
q = q.filter(model.reception_source == reception_source)
|
||||
rows = q.all()
|
||||
now = _now()
|
||||
for row in rows:
|
||||
setattr(row, code_attr, new_code)
|
||||
if hasattr(row, "update_by"):
|
||||
row.update_by = user_id
|
||||
if hasattr(row, "update_time"):
|
||||
row.update_time = now
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _count_task_inputs(db: Session, tenant_no: str, code: str) -> int:
|
||||
return int(
|
||||
db.query(BizProductionTaskInput)
|
||||
.filter(
|
||||
BizProductionTaskInput.tenant_no == tenant_no,
|
||||
BizProductionTaskInput.del_flag == 0,
|
||||
BizProductionTaskInput.input_type == "sample",
|
||||
BizProductionTaskInput.input_code == code,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def _update_task_inputs(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
old_code: str,
|
||||
new_code: str,
|
||||
user_id: Optional[str],
|
||||
) -> int:
|
||||
rows = (
|
||||
db.query(BizProductionTaskInput)
|
||||
.filter(
|
||||
BizProductionTaskInput.tenant_no == tenant_no,
|
||||
BizProductionTaskInput.del_flag == 0,
|
||||
BizProductionTaskInput.input_type == "sample",
|
||||
BizProductionTaskInput.input_code == old_code,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
now = _now()
|
||||
for row in rows:
|
||||
row.input_code = new_code
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _count_same_product_code(db: Session, tenant_no: str, code: str) -> int:
|
||||
return int(
|
||||
db.query(BizCellProduct)
|
||||
.filter(
|
||||
BizCellProduct.tenant_no == tenant_no,
|
||||
BizCellProduct.del_flag == 0,
|
||||
BizCellProduct.product_code == code,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def _unique_conflict(
|
||||
db: Session,
|
||||
model,
|
||||
*,
|
||||
tenant_no: str,
|
||||
new_code: str,
|
||||
) -> bool:
|
||||
return (
|
||||
db.query(model.id)
|
||||
.filter(
|
||||
model.tenant_no == tenant_no,
|
||||
model.del_flag == 0,
|
||||
model.sample_code == new_code,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _spec_applies(reception_source: str, cell_only: bool, table_key: str) -> bool:
|
||||
if reception_source == SOURCE_CELL:
|
||||
if table_key == "biz_research_donation_sample_transport":
|
||||
return False
|
||||
return True
|
||||
# donation
|
||||
if cell_only:
|
||||
return False
|
||||
if table_key.startswith("biz_protocol_"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def preview_correction(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
reception_source: str,
|
||||
old_sample_code: str,
|
||||
new_sample_code: str,
|
||||
new_sample_code_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
source = _norm(reception_source) or SOURCE_CELL
|
||||
old_code = _norm(old_sample_code)
|
||||
new_code = _norm(new_sample_code)
|
||||
blockers: List[str] = []
|
||||
|
||||
if source not in VALID_SOURCES:
|
||||
blockers.append("receptionSource 必须是 cell_storage 或 research_donation")
|
||||
if not old_code:
|
||||
blockers.append("旧样本编码不能为空")
|
||||
if not new_code:
|
||||
blockers.append("新样本编码不能为空")
|
||||
if old_code and new_code and old_code == new_code:
|
||||
blockers.append("新样本编码不能与旧编码相同")
|
||||
|
||||
sample = None
|
||||
if not blockers:
|
||||
sample = _load_source_sample(
|
||||
db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
|
||||
)
|
||||
if sample is None:
|
||||
blockers.append(f"未找到来源「{source}」下样本编码「{old_code}」")
|
||||
elif _code_used_by_other_sample(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
sample_code=new_code,
|
||||
exclude_sample_id=sample.id,
|
||||
):
|
||||
blockers.append(f"样本编码「{new_code}」已被其它协议/捐赠样本占用")
|
||||
|
||||
impacts: List[Dict[str, Any]] = []
|
||||
if sample is not None and not blockers:
|
||||
sot_key = (
|
||||
"biz_protocol_sample"
|
||||
if source == SOURCE_CELL
|
||||
else "biz_research_donation_sample"
|
||||
)
|
||||
impacts.append(
|
||||
{
|
||||
"table": sot_key,
|
||||
"label": "样本赋值源表",
|
||||
"count": 1,
|
||||
}
|
||||
)
|
||||
|
||||
pool_count = 0
|
||||
if source == SOURCE_CELL:
|
||||
pool_id = _norm(new_sample_code_id) or _norm(getattr(sample, "sample_code_id", None))
|
||||
if pool_id:
|
||||
pool_row = (
|
||||
db.query(BizSampleCode)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.id == pool_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if pool_row and _norm(pool_row.sample_code) == old_code:
|
||||
pool_count = 1
|
||||
elif not pool_row and _norm(new_sample_code_id):
|
||||
blockers.append(f"指定的新编码池 ID「{new_sample_code_id}」不存在")
|
||||
existing_new_pool = (
|
||||
db.query(BizSampleCode)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.sample_code == new_code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing_new_pool and pool_count == 0:
|
||||
impacts.append(
|
||||
{
|
||||
"table": "biz_sample_code",
|
||||
"label": "编码池(切换占用)",
|
||||
"count": 1,
|
||||
}
|
||||
)
|
||||
elif pool_count:
|
||||
impacts.append(
|
||||
{
|
||||
"table": "biz_sample_code",
|
||||
"label": "编码池(就地改码)",
|
||||
"count": pool_count,
|
||||
}
|
||||
)
|
||||
|
||||
for key, label, model, attr, has_rs, cell_only in _TABLE_SPECS:
|
||||
if not _spec_applies(source, cell_only, key):
|
||||
continue
|
||||
cnt = _count_by_code(
|
||||
db,
|
||||
model,
|
||||
tenant_no=tenant_no,
|
||||
code=old_code,
|
||||
code_attr=attr,
|
||||
reception_source=source if has_rs else None,
|
||||
has_reception_source=has_rs,
|
||||
)
|
||||
if cnt:
|
||||
impacts.append({"table": key, "label": label, "count": cnt})
|
||||
if (
|
||||
key in _UNIQUE_TABLES
|
||||
and source == SOURCE_CELL
|
||||
and cnt > 0
|
||||
and _unique_conflict(db, model, tenant_no=tenant_no, new_code=new_code)
|
||||
):
|
||||
blockers.append(
|
||||
f"表 {key} 已存在样本编码「{new_code}」记录,无法直接改码"
|
||||
)
|
||||
|
||||
ti = _count_task_inputs(db, tenant_no, old_code)
|
||||
if ti:
|
||||
impacts.append(
|
||||
{
|
||||
"table": "biz_production_task_input",
|
||||
"label": "生产任务投入物(样本)",
|
||||
"count": ti,
|
||||
}
|
||||
)
|
||||
|
||||
same_product_old = (
|
||||
_count_same_product_code(db, tenant_no, old_code) if old_code else 0
|
||||
)
|
||||
same_product_new = (
|
||||
_count_same_product_code(db, tenant_no, new_code) if new_code else 0
|
||||
)
|
||||
|
||||
total = sum(int(i.get("count") or 0) for i in impacts)
|
||||
return {
|
||||
"ok": len(blockers) == 0,
|
||||
"blockers": blockers,
|
||||
"receptionSource": source,
|
||||
"oldSampleCode": old_code,
|
||||
"newSampleCode": new_code,
|
||||
"sampleId": getattr(sample, "id", None) if sample else None,
|
||||
"impacts": impacts,
|
||||
"totalAffectedRows": total,
|
||||
"hints": {
|
||||
"sameProductCodeAsOld": same_product_old,
|
||||
"sameProductCodeAsNew": same_product_new,
|
||||
"labStorageCodeUnchanged": True,
|
||||
"cellProductCodeNotAutoUpdated": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_pool(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
sample: BizProtocolSample,
|
||||
old_code: str,
|
||||
new_code: str,
|
||||
new_sample_code_id: Optional[str],
|
||||
user_id: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""返回 pool 处理结果摘要。"""
|
||||
now = _now()
|
||||
result = {"action": "none", "poolId": None}
|
||||
|
||||
preferred_id = _norm(new_sample_code_id)
|
||||
existing_new = (
|
||||
db.query(BizSampleCode)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.sample_code == new_code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if preferred_id:
|
||||
preferred = (
|
||||
db.query(BizSampleCode)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.id == preferred_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if preferred is None:
|
||||
raise SampleCodeCorrectionError(f"指定的新编码池 ID「{preferred_id}」不存在")
|
||||
if _norm(preferred.sample_code) not in {new_code, old_code}:
|
||||
# 允许指向已是新码的池行,或指向即将就地改码的旧池行
|
||||
if _norm(preferred.sample_code) != new_code:
|
||||
preferred.sample_code = new_code
|
||||
preferred.update_by = user_id
|
||||
preferred.update_time = now
|
||||
result["action"] = "rename_preferred"
|
||||
existing_new = preferred
|
||||
|
||||
old_pool_id = _norm(getattr(sample, "sample_code_id", None))
|
||||
old_pool = None
|
||||
if old_pool_id:
|
||||
old_pool = (
|
||||
db.query(BizSampleCode)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.id == old_pool_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_new is not None:
|
||||
sample.sample_code_id = existing_new.id
|
||||
sample.sample_code = new_code
|
||||
existing_new.issued = 1
|
||||
existing_new.update_by = user_id
|
||||
existing_new.update_time = now
|
||||
result["action"] = result["action"] if result["action"] != "none" else "reassign"
|
||||
result["poolId"] = existing_new.id
|
||||
if (
|
||||
old_pool
|
||||
and old_pool.id != existing_new.id
|
||||
and _norm(old_pool.sample_code) == old_code
|
||||
):
|
||||
still_used = (
|
||||
db.query(BizProtocolSample.id)
|
||||
.filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolSample.sample_code_id == old_pool.id,
|
||||
BizProtocolSample.id != sample.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if still_used is None:
|
||||
old_pool.issued = 0
|
||||
old_pool.update_by = user_id
|
||||
old_pool.update_time = now
|
||||
return result
|
||||
|
||||
if old_pool and _norm(old_pool.sample_code) == old_code:
|
||||
old_pool.sample_code = new_code
|
||||
old_pool.issued = 1
|
||||
old_pool.update_by = user_id
|
||||
old_pool.update_time = now
|
||||
sample.sample_code = new_code
|
||||
sample.sample_code_id = old_pool.id
|
||||
result["action"] = "rename_inplace"
|
||||
result["poolId"] = old_pool.id
|
||||
return result
|
||||
|
||||
sample.sample_code = new_code
|
||||
result["action"] = "sample_text_only"
|
||||
return result
|
||||
|
||||
|
||||
def apply_correction(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
reception_source: str,
|
||||
old_sample_code: str,
|
||||
new_sample_code: str,
|
||||
new_sample_code_id: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
preview = preview_correction(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
reception_source=reception_source,
|
||||
old_sample_code=old_sample_code,
|
||||
new_sample_code=new_sample_code,
|
||||
new_sample_code_id=new_sample_code_id,
|
||||
)
|
||||
if not preview.get("ok"):
|
||||
raise SampleCodeCorrectionError(
|
||||
";".join(preview.get("blockers") or ["校验未通过"])
|
||||
)
|
||||
|
||||
source = preview["receptionSource"]
|
||||
old_code = preview["oldSampleCode"]
|
||||
new_code = preview["newSampleCode"]
|
||||
sample = _load_source_sample(
|
||||
db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
|
||||
)
|
||||
if sample is None:
|
||||
raise SampleCodeCorrectionError(f"未找到样本编码「{old_code}」")
|
||||
|
||||
now = _now()
|
||||
impact_counts: Dict[str, int] = {}
|
||||
|
||||
if source == SOURCE_CELL:
|
||||
pool_info = _resolve_pool(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
sample=sample,
|
||||
old_code=old_code,
|
||||
new_code=new_code,
|
||||
new_sample_code_id=new_sample_code_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
impact_counts["biz_sample_code"] = 1 if pool_info.get("action") != "none" else 0
|
||||
sample.update_by = user_id
|
||||
sample.update_time = now
|
||||
impact_counts["biz_protocol_sample"] = 1
|
||||
else:
|
||||
sample.sample_code = new_code
|
||||
sample.update_by = user_id
|
||||
sample.update_time = now
|
||||
impact_counts["biz_research_donation_sample"] = 1
|
||||
|
||||
# UNIQUE 表先更新,避免其它逻辑读到半新半旧
|
||||
for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
|
||||
if key not in _UNIQUE_TABLES:
|
||||
continue
|
||||
if not _spec_applies(source, cell_only, key):
|
||||
continue
|
||||
n = _update_by_code(
|
||||
db,
|
||||
model,
|
||||
tenant_no=tenant_no,
|
||||
old_code=old_code,
|
||||
new_code=new_code,
|
||||
code_attr=attr,
|
||||
reception_source=source if has_rs else None,
|
||||
has_reception_source=has_rs,
|
||||
user_id=user_id,
|
||||
)
|
||||
if n:
|
||||
impact_counts[key] = n
|
||||
|
||||
for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
|
||||
if key in _UNIQUE_TABLES:
|
||||
continue
|
||||
if not _spec_applies(source, cell_only, key):
|
||||
continue
|
||||
n = _update_by_code(
|
||||
db,
|
||||
model,
|
||||
tenant_no=tenant_no,
|
||||
old_code=old_code,
|
||||
new_code=new_code,
|
||||
code_attr=attr,
|
||||
reception_source=source if has_rs else None,
|
||||
has_reception_source=has_rs,
|
||||
user_id=user_id,
|
||||
)
|
||||
if n:
|
||||
impact_counts[key] = n
|
||||
|
||||
ti = _update_task_inputs(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
old_code=old_code,
|
||||
new_code=new_code,
|
||||
user_id=user_id,
|
||||
)
|
||||
if ti:
|
||||
impact_counts["biz_production_task_input"] = ti
|
||||
|
||||
log = BizSampleCodeCorrectionLog(
|
||||
id=generate_uuid(),
|
||||
reception_source=source,
|
||||
old_sample_code=old_code,
|
||||
new_sample_code=new_code,
|
||||
impact_json=json.dumps(impact_counts, ensure_ascii=False),
|
||||
reason=_norm(reason) 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(log)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"logId": log.id,
|
||||
"receptionSource": source,
|
||||
"oldSampleCode": old_code,
|
||||
"newSampleCode": new_code,
|
||||
"impactCounts": impact_counts,
|
||||
"totalAffectedRows": sum(impact_counts.values()),
|
||||
"hints": preview.get("hints") or {},
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
"""协议新增样本:服务号模板消息通知。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizProtocolSample,
|
||||
BizProtocolSampleTransport,
|
||||
BizWxNotifyLog,
|
||||
SysUser,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonation,
|
||||
BizResearchDonationSample,
|
||||
BizResearchDonationSampleTransport,
|
||||
)
|
||||
from app.services.web_auth_roles import list_user_ids_with_effective_role_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NOTIFY_BIZ_TYPE = "sample_collect_notify"
|
||||
NOTIFY_DATE_ONCE = "1970-01-01"
|
||||
|
||||
PROTOCOL_KIND_CELL = "cell_storage"
|
||||
PROTOCOL_KIND_DONATION = "research_donation"
|
||||
|
||||
TASK_TYPE_CELL = "细胞存储"
|
||||
TASK_TYPE_DONATION = "科研捐赠"
|
||||
|
||||
|
||||
def sample_codes_from_payload(samples: Optional[Iterable[Any]]) -> Set[str]:
|
||||
"""从请求体样本列表提取非空 sample_code(兼容 camelCase)。"""
|
||||
out: Set[str] = set()
|
||||
for raw in samples or []:
|
||||
if isinstance(raw, dict):
|
||||
code = (raw.get("sample_code") or raw.get("sampleCode") or "").strip()
|
||||
else:
|
||||
code = (getattr(raw, "sample_code", None) or "").strip()
|
||||
if code:
|
||||
out.add(code)
|
||||
return out
|
||||
|
||||
|
||||
def sample_codes_from_orm_rows(rows: Optional[Iterable[Any]]) -> Set[str]:
|
||||
out: Set[str] = set()
|
||||
for row in rows or []:
|
||||
code = (getattr(row, "sample_code", None) or "").strip()
|
||||
if code:
|
||||
out.add(code)
|
||||
return out
|
||||
|
||||
|
||||
def diff_new_sample_codes(
|
||||
*,
|
||||
before_codes: Iterable[str],
|
||||
submitted_codes: Iterable[str],
|
||||
) -> List[str]:
|
||||
"""提交相对保存前的新增 sample_code;稳定排序便于测试。"""
|
||||
before = {str(c).strip() for c in before_codes if str(c).strip()}
|
||||
submitted = {str(c).strip() for c in submitted_codes if str(c).strip()}
|
||||
return sorted(submitted - before)
|
||||
|
||||
|
||||
def format_sample_collect_title(eta_time: Optional[datetime] = None) -> str:
|
||||
"""工单标题:有 ETA → 样本采集(预计到达 M月D日);否则到达待定。"""
|
||||
if eta_time is not None:
|
||||
title = f"样本采集(预计到达 {eta_time.month}月{eta_time.day}日)"
|
||||
else:
|
||||
title = "样本采集(到达待定)"
|
||||
return title
|
||||
|
||||
|
||||
def format_protocol_create_time(dt: Optional[datetime]) -> str:
|
||||
if not dt:
|
||||
return ""
|
||||
return dt.strftime("%Y年%m月%d日 %H:%M")
|
||||
|
||||
|
||||
def _notify_enabled() -> bool:
|
||||
if not bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False)):
|
||||
return False
|
||||
tid = (getattr(settings, "WX_MP_TEMPLATE_SAMPLE_COLLECT", "") or "").strip()
|
||||
return bool(tid)
|
||||
|
||||
|
||||
def _role_name() -> str:
|
||||
return (
|
||||
getattr(settings, "WX_MP_SAMPLE_COLLECT_ROLE_NAME", "") or "样本采集通知人员"
|
||||
).strip() or "样本采集通知人员"
|
||||
|
||||
|
||||
def _task_type_for_kind(protocol_kind: str) -> str:
|
||||
if protocol_kind == PROTOCOL_KIND_DONATION:
|
||||
return TASK_TYPE_DONATION
|
||||
return TASK_TYPE_CELL
|
||||
|
||||
|
||||
def _biz_id(sample_code: str, user_id: str) -> str:
|
||||
return f"{(sample_code or '').strip()}:{(user_id or '').strip()}"
|
||||
|
||||
|
||||
def _already_success(
|
||||
db: Session, *, tenant_no: str, sample_code: str, user_id: str
|
||||
) -> bool:
|
||||
row = (
|
||||
db.query(BizWxNotifyLog)
|
||||
.filter(
|
||||
BizWxNotifyLog.tenant_no == tenant_no,
|
||||
BizWxNotifyLog.biz_type == NOTIFY_BIZ_TYPE,
|
||||
BizWxNotifyLog.biz_id == _biz_id(sample_code, user_id),
|
||||
BizWxNotifyLog.notify_date == NOTIFY_DATE_ONCE,
|
||||
BizWxNotifyLog.status == "success",
|
||||
BizWxNotifyLog.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
def _write_success_log(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
sample_code: str,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
db.add(
|
||||
BizWxNotifyLog(
|
||||
id=str(uuid.uuid4()).replace("-", "").upper(),
|
||||
biz_type=NOTIFY_BIZ_TYPE,
|
||||
biz_id=_biz_id(sample_code, user_id),
|
||||
notify_date=NOTIFY_DATE_ONCE,
|
||||
user_id=user_id or "",
|
||||
status="success",
|
||||
errcode=None,
|
||||
errmsg=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 list_notify_recipient_users(db: Session, *, tenant_no: str) -> List[SysUser]:
|
||||
"""有效拥有「样本采集通知人员」角色且已绑定服务号的用户。
|
||||
|
||||
有效角色 = 用户直接绑角色 ∪ 经启用职位继承(sys_user_post → sys_post → sys_post_role),
|
||||
与登录权限解析一致。
|
||||
"""
|
||||
role_name = _role_name()
|
||||
user_ids = list_user_ids_with_effective_role_name(
|
||||
db, tenant_no=tenant_no, role_name=role_name
|
||||
)
|
||||
if not user_ids:
|
||||
return []
|
||||
rows = (
|
||||
db.query(SysUser)
|
||||
.filter(
|
||||
SysUser.id.in_(list(user_ids)),
|
||||
SysUser.tenant_no == tenant_no,
|
||||
SysUser.del_flag == 0,
|
||||
SysUser.wx_mp_openid.isnot(None),
|
||||
SysUser.wx_mp_openid != "",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
# 按 user_id 已去重;再按 openid 去重,避免一人多职位/多账号同微信重复推送
|
||||
seen_openid: Set[str] = set()
|
||||
out: List[SysUser] = []
|
||||
for u in rows:
|
||||
oid = (getattr(u, "wx_mp_openid", None) or "").strip()
|
||||
if not oid or oid in seen_openid:
|
||||
continue
|
||||
seen_openid.add(oid)
|
||||
out.append(u)
|
||||
return out
|
||||
|
||||
|
||||
def _load_cell_context(
|
||||
db: Session, *, protocol_id: str, tenant_no: str, new_codes: List[str]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
protocol = (
|
||||
db.query(BizCollectionProtocol)
|
||||
.filter(
|
||||
BizCollectionProtocol.id == protocol_id,
|
||||
BizCollectionProtocol.tenant_no == tenant_no,
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not protocol:
|
||||
return None
|
||||
samples = (
|
||||
db.query(BizProtocolSample)
|
||||
.filter(
|
||||
BizProtocolSample.collection_protocol_id == protocol_id,
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolSample.sample_code.in_(new_codes),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
code_to_id = {
|
||||
(s.sample_code or "").strip(): s.id
|
||||
for s in samples
|
||||
if (s.sample_code or "").strip()
|
||||
}
|
||||
sample_ids = list(code_to_id.values())
|
||||
eta_by_code: Dict[str, Optional[datetime]] = {c: None for c in new_codes}
|
||||
if sample_ids:
|
||||
transports = (
|
||||
db.query(BizProtocolSampleTransport)
|
||||
.filter(
|
||||
BizProtocolSampleTransport.collection_protocol_id == protocol_id,
|
||||
BizProtocolSampleTransport.tenant_no == tenant_no,
|
||||
BizProtocolSampleTransport.del_flag == 0,
|
||||
BizProtocolSampleTransport.sample_id.in_(sample_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
id_to_code = {sid: code for code, sid in code_to_id.items()}
|
||||
for t in transports:
|
||||
code = id_to_code.get((t.sample_id or "").strip()) or (
|
||||
getattr(t, "sample_code", None) or ""
|
||||
).strip()
|
||||
if code and code in eta_by_code and t.eta_time and eta_by_code[code] is None:
|
||||
eta_by_code[code] = t.eta_time
|
||||
creator = None
|
||||
if protocol.create_by:
|
||||
creator = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == protocol.create_by, SysUser.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
return {
|
||||
"create_time": protocol.create_time,
|
||||
"applicant": (creator.nick_name if creator else "") or "",
|
||||
"eta_by_code": eta_by_code,
|
||||
}
|
||||
|
||||
|
||||
def _load_donation_context(
|
||||
db: Session, *, protocol_id: str, tenant_no: str, new_codes: List[str]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
donation = (
|
||||
db.query(BizResearchDonation)
|
||||
.filter(
|
||||
BizResearchDonation.id == protocol_id,
|
||||
BizResearchDonation.tenant_no == tenant_no,
|
||||
BizResearchDonation.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not donation:
|
||||
return None
|
||||
samples = (
|
||||
db.query(BizResearchDonationSample)
|
||||
.filter(
|
||||
BizResearchDonationSample.research_donation_id == protocol_id,
|
||||
BizResearchDonationSample.tenant_no == tenant_no,
|
||||
BizResearchDonationSample.del_flag == 0,
|
||||
BizResearchDonationSample.sample_code.in_(new_codes),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
code_to_id = {
|
||||
(s.sample_code or "").strip(): s.id
|
||||
for s in samples
|
||||
if (s.sample_code or "").strip()
|
||||
}
|
||||
sample_ids = list(code_to_id.values())
|
||||
eta_by_code: Dict[str, Optional[datetime]] = {c: None for c in new_codes}
|
||||
if sample_ids:
|
||||
transports = (
|
||||
db.query(BizResearchDonationSampleTransport)
|
||||
.filter(
|
||||
BizResearchDonationSampleTransport.research_donation_id
|
||||
== protocol_id,
|
||||
BizResearchDonationSampleTransport.tenant_no == tenant_no,
|
||||
BizResearchDonationSampleTransport.del_flag == 0,
|
||||
BizResearchDonationSampleTransport.sample_id.in_(sample_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
id_to_code = {sid: code for code, sid in code_to_id.items()}
|
||||
for t in transports:
|
||||
code = id_to_code.get((t.sample_id or "").strip()) or (
|
||||
getattr(t, "sample_code", None) or ""
|
||||
).strip()
|
||||
if code and code in eta_by_code and t.eta_time and eta_by_code[code] is None:
|
||||
eta_by_code[code] = t.eta_time
|
||||
creator = None
|
||||
if donation.create_by:
|
||||
creator = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == donation.create_by, SysUser.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
return {
|
||||
"create_time": donation.create_time,
|
||||
"applicant": (creator.nick_name if creator else "") or "",
|
||||
"eta_by_code": eta_by_code,
|
||||
}
|
||||
|
||||
|
||||
def run_sample_collect_notify(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
protocol_id: str,
|
||||
protocol_kind: str,
|
||||
new_sample_codes: Iterable[str],
|
||||
) -> Dict[str, int]:
|
||||
"""对新增样本向角色用户发送通知。返回计数。"""
|
||||
stats = {"codes": 0, "sent": 0, "skipped": 0, "failed": 0}
|
||||
if not _notify_enabled():
|
||||
logger.info("sample collect notify skipped: notify disabled or no template")
|
||||
return stats
|
||||
|
||||
codes = [c for c in (str(x).strip() for x in new_sample_codes) if c]
|
||||
if not codes:
|
||||
return stats
|
||||
stats["codes"] = len(codes)
|
||||
tenant = (tenant_no or "").strip() or "zkxw"
|
||||
kind = (protocol_kind or "").strip() or PROTOCOL_KIND_CELL
|
||||
|
||||
if kind == PROTOCOL_KIND_DONATION:
|
||||
ctx = _load_donation_context(
|
||||
db, protocol_id=protocol_id, tenant_no=tenant, new_codes=codes
|
||||
)
|
||||
else:
|
||||
ctx = _load_cell_context(
|
||||
db, protocol_id=protocol_id, tenant_no=tenant, new_codes=codes
|
||||
)
|
||||
if not ctx:
|
||||
logger.info(
|
||||
"sample collect notify skip: protocol missing id=%s kind=%s",
|
||||
protocol_id,
|
||||
kind,
|
||||
)
|
||||
return stats
|
||||
|
||||
recipients = list_notify_recipient_users(db, tenant_no=tenant)
|
||||
if not recipients:
|
||||
logger.info(
|
||||
"sample collect notify skip: no recipients tenant=%s role=%s",
|
||||
tenant,
|
||||
_role_name(),
|
||||
)
|
||||
return stats
|
||||
|
||||
from app.services.wechat_mp_message import send_sample_collect_notify
|
||||
|
||||
applicant = ctx.get("applicant") or ""
|
||||
time_text = format_protocol_create_time(ctx.get("create_time"))
|
||||
task_type = _task_type_for_kind(kind)
|
||||
eta_by_code: Dict[str, Optional[datetime]] = ctx.get("eta_by_code") or {}
|
||||
|
||||
for code in codes:
|
||||
title = format_sample_collect_title(eta_by_code.get(code))
|
||||
for user in recipients:
|
||||
uid = (user.id or "").strip()
|
||||
mp = (getattr(user, "wx_mp_openid", None) or "").strip()
|
||||
if not uid or not mp:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
if _already_success(db, tenant_no=tenant, sample_code=code, user_id=uid):
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
result = send_sample_collect_notify(
|
||||
mp_openid=mp,
|
||||
title=title,
|
||||
applicant=applicant,
|
||||
time_text=time_text,
|
||||
task_type=task_type,
|
||||
pagepath="",
|
||||
)
|
||||
if result.get("success"):
|
||||
_write_success_log(
|
||||
db, tenant_no=tenant, sample_code=code, user_id=uid
|
||||
)
|
||||
stats["sent"] += 1
|
||||
else:
|
||||
logger.error(
|
||||
"sample collect notify failed code=%s user=%s err=%s",
|
||||
code,
|
||||
uid,
|
||||
result,
|
||||
)
|
||||
stats["failed"] += 1
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("sample collect notify commit failed")
|
||||
raise
|
||||
return stats
|
||||
|
||||
|
||||
def notify_sample_collect_sync(
|
||||
*,
|
||||
tenant_no: str,
|
||||
protocol_id: str,
|
||||
protocol_kind: str,
|
||||
new_sample_codes: List[str],
|
||||
) -> None:
|
||||
db = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
stats = run_sample_collect_notify(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=protocol_id,
|
||||
protocol_kind=protocol_kind,
|
||||
new_sample_codes=new_sample_codes,
|
||||
)
|
||||
logger.info(
|
||||
"sample collect notify done protocol=%s kind=%s stats=%s",
|
||||
protocol_id,
|
||||
protocol_kind,
|
||||
stats,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"sample collect notify batch failed protocol=%s kind=%s",
|
||||
protocol_id,
|
||||
protocol_kind,
|
||||
)
|
||||
finally:
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def schedule_sample_collect_notify(
|
||||
*,
|
||||
tenant_no: str,
|
||||
protocol_id: str,
|
||||
protocol_kind: str,
|
||||
before_codes: Iterable[str],
|
||||
submitted_codes: Optional[Iterable[str]],
|
||||
) -> None:
|
||||
"""保存成功后调度:submitted_codes 为 None 表示本次未改样本,不推。"""
|
||||
if not _notify_enabled():
|
||||
logger.info("sample collect notify schedule skipped: disabled")
|
||||
return
|
||||
if submitted_codes is None:
|
||||
return
|
||||
new_codes = diff_new_sample_codes(
|
||||
before_codes=before_codes, submitted_codes=submitted_codes
|
||||
)
|
||||
if not new_codes:
|
||||
return
|
||||
pid = (protocol_id or "").strip()
|
||||
if not pid:
|
||||
return
|
||||
threading.Thread(
|
||||
target=notify_sample_collect_sync,
|
||||
kwargs={
|
||||
"tenant_no": (tenant_no or "").strip() or "zkxw",
|
||||
"protocol_id": pid,
|
||||
"protocol_kind": (protocol_kind or "").strip() or PROTOCOL_KIND_CELL,
|
||||
"new_sample_codes": new_codes,
|
||||
},
|
||||
daemon=True,
|
||||
name="sample-collect-wx-notify",
|
||||
).start()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""客户服务快捷登记:payload 规范化与字典回写(逐步扩展)。"""
|
||||
from typing import Any, Dict, FrozenSet, Optional
|
||||
|
||||
CUSTOMER_SERVICE_FIELD_KEYS: FrozenSet[str] = frozenset(
|
||||
{
|
||||
"sms_send_date",
|
||||
"sms_send_status",
|
||||
"microbe_followup_date",
|
||||
"microbe_followup_status",
|
||||
"fullmoon_followup_date",
|
||||
"fullmoon_followup_status",
|
||||
"household_registered",
|
||||
"einvoice_send_time",
|
||||
"effective_address",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_dict_value(raw) -> Optional[str]:
|
||||
"""Trim; empty/None -> None."""
|
||||
if raw is None:
|
||||
return None
|
||||
text = str(raw).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _is_empty_merge_value(value: Any) -> bool:
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
return not value.strip()
|
||||
return False
|
||||
|
||||
|
||||
def merge_customer_service_fields(
|
||||
existing: Optional[Dict[str, Any]],
|
||||
incoming: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Merge incoming onto existing; empty/None/whitespace incoming values do not overwrite."""
|
||||
existing = existing or {}
|
||||
incoming = incoming or {}
|
||||
keys = (set(existing.keys()) | set(incoming.keys())) & CUSTOMER_SERVICE_FIELD_KEYS
|
||||
merged: Dict[str, Any] = {k: existing.get(k) for k in keys}
|
||||
for key, value in incoming.items():
|
||||
if key not in CUSTOMER_SERVICE_FIELD_KEYS:
|
||||
continue
|
||||
if _is_empty_merge_value(value):
|
||||
continue
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def has_any_customer_service_payload(incoming: Optional[Dict[str, Any]]) -> bool:
|
||||
"""True if any known customer-service field has a non-empty value."""
|
||||
incoming = incoming or {}
|
||||
for key in CUSTOMER_SERVICE_FIELD_KEYS:
|
||||
if key not in incoming:
|
||||
continue
|
||||
if not _is_empty_merge_value(incoming.get(key)):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,230 @@
|
||||
"""样本信息列表:批量补进度标识(接收/质检/入库/证保险)与客户服务宽表字段。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud import sample_customer_service as sample_cs_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.models.models import BizStorageReleaseReview, SysUser
|
||||
from app.services.protocol_sample_list_summary import (
|
||||
get_list_qc_conclusion_map_by_sample_codes,
|
||||
get_list_reception_summary_map_by_sample_codes,
|
||||
)
|
||||
from app.services.sample_info_progress import (
|
||||
collection_progress,
|
||||
insurance_handled,
|
||||
qc_progress,
|
||||
reception_progress,
|
||||
storage_cert_handled,
|
||||
storage_progress,
|
||||
)
|
||||
|
||||
|
||||
def _fmt_dt(t: Optional[datetime]) -> str:
|
||||
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
|
||||
|
||||
|
||||
def _has_urls(raw: Optional[str]) -> bool:
|
||||
return bool((raw or "").strip())
|
||||
|
||||
|
||||
def get_storage_review_map_by_sample_codes(
|
||||
db: Session,
|
||||
tenant_no: str,
|
||||
sample_codes: List[str],
|
||||
*,
|
||||
reception_source: str,
|
||||
) -> Dict[str, Dict[str, str]]:
|
||||
codes = list({(c or "").strip() for c in sample_codes if (c or "").strip()})
|
||||
if not tenant_no or not codes or not (reception_source or "").strip():
|
||||
return {}
|
||||
rows = (
|
||||
db.query(BizStorageReleaseReview)
|
||||
.filter(
|
||||
BizStorageReleaseReview.tenant_no == tenant_no,
|
||||
BizStorageReleaseReview.del_flag == 0,
|
||||
BizStorageReleaseReview.reception_source == reception_source,
|
||||
BizStorageReleaseReview.sample_code.in_(codes),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for rev in rows:
|
||||
code = (rev.sample_code or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
prev = out.get(code)
|
||||
prev_t = prev.get("_t") if prev else None
|
||||
cur_t = rev.storage_time or rev.update_time or rev.create_time
|
||||
if prev and cur_t and prev_t and cur_t <= prev_t:
|
||||
continue
|
||||
if prev and cur_t is None and prev_t is not None:
|
||||
continue
|
||||
out[code] = {
|
||||
"storageStatus": (rev.storage_status or "").strip(),
|
||||
"reviewConclusion": (rev.review_conclusion or "").strip(),
|
||||
"storageTime": _fmt_dt(rev.storage_time),
|
||||
"storageBy": (rev.storage_by or "").strip(),
|
||||
"storageByName": (rev.storage_by_name or "").strip(),
|
||||
"storageAttachments": (rev.review_attachments or "").strip(),
|
||||
"storageRemark": (rev.remark or "").strip(),
|
||||
"_t": cur_t,
|
||||
}
|
||||
for v in out.values():
|
||||
v.pop("_t", None)
|
||||
return out
|
||||
|
||||
|
||||
def _user_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
|
||||
ids = list({(uid or "").strip() for uid in user_ids if (uid or "").strip()})
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(SysUser.id, SysUser.nick_name)
|
||||
.filter(SysUser.id.in_(ids), SysUser.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
return {str(row[0]): (row[1] or "") for row in rows}
|
||||
|
||||
|
||||
def enrich_sample_info_items(
|
||||
db: Session,
|
||||
tenant_no: str,
|
||||
items: List[Dict[str, Any]],
|
||||
*,
|
||||
reception_source: str,
|
||||
include_handle_flags: bool,
|
||||
) -> None:
|
||||
if not items or not tenant_no:
|
||||
return
|
||||
codes = [str(i.get("sampleCode") or "").strip() for i in items]
|
||||
codes = [c for c in codes if c]
|
||||
rec_map = get_list_reception_summary_map_by_sample_codes(
|
||||
db, tenant_no, codes, reception_source=reception_source
|
||||
)
|
||||
qc_map = get_list_qc_conclusion_map_by_sample_codes(
|
||||
db, tenant_no, codes, reception_source=reception_source
|
||||
)
|
||||
st_map = get_storage_review_map_by_sample_codes(
|
||||
db, tenant_no, codes, reception_source=reception_source
|
||||
)
|
||||
cert_map: Dict[str, Any] = {}
|
||||
ins_map: Dict[str, Any] = {}
|
||||
cs_map: Dict[str, Any] = {}
|
||||
if include_handle_flags and reception_source == "cell_storage":
|
||||
cert_map = sample_storage_cert_crud.get_map_by_sample_codes(db, tenant_no, codes)
|
||||
ins_map = sample_insurance_crud.get_map_by_sample_codes(db, tenant_no, codes)
|
||||
cs_map = sample_cs_crud.get_map_by_sample_codes(db, tenant_no, codes)
|
||||
|
||||
for item in items:
|
||||
code = str(item.get("sampleCode") or "").strip()
|
||||
cflag, clabel = collection_progress(str(item.get("sampleStatus") or ""))
|
||||
item["collectionFlag"] = cflag
|
||||
item["collectionFlagLabel"] = clabel
|
||||
rec = rec_map.get(code)
|
||||
rflag, rlabel = reception_progress(rec)
|
||||
item["receptionFlag"] = rflag
|
||||
item["receptionFlagLabel"] = rlabel
|
||||
item["receptionTime"] = (rec or {}).get("receptionTime") or ""
|
||||
qc = qc_map.get(code)
|
||||
qflag, qlabel = qc_progress(qc)
|
||||
item["qcFlag"] = qflag
|
||||
item["qcFlagLabel"] = qlabel
|
||||
item["qcCompleteTime"] = ""
|
||||
st = st_map.get(code)
|
||||
sflag, slabel = storage_progress(st)
|
||||
item["storageFlag"] = sflag
|
||||
item["storageFlagLabel"] = slabel
|
||||
item["storageTime"] = (st or {}).get("storageTime") or ""
|
||||
item["storageBy"] = (st or {}).get("storageBy") or ""
|
||||
item["storageByName"] = (st or {}).get("storageByName") or ""
|
||||
item["storageAttachments"] = (st or {}).get("storageAttachments") or ""
|
||||
item["storageRemark"] = (st or {}).get("storageRemark") or ""
|
||||
if include_handle_flags:
|
||||
cert_row = cert_map.get(code)
|
||||
ins_row = ins_map.get(code)
|
||||
cert_done = storage_cert_handled(cert_row)
|
||||
ins_done = insurance_handled(ins_row)
|
||||
item["storageCertHandled"] = cert_done
|
||||
item["insuranceHandled"] = ins_done
|
||||
item["mailCertTime"] = _fmt_dt(getattr(cert_row, "mail_cert_time", None)) if cert_row else ""
|
||||
item["storageCertHandleTime"] = (
|
||||
_fmt_dt(getattr(cert_row, "handle_time", None)) if cert_row else ""
|
||||
)
|
||||
item["storageCertHandlerUserId"] = (
|
||||
getattr(cert_row, "handler_user_id", None) or ""
|
||||
) if cert_row else ""
|
||||
item["storageCertAttachments"] = (
|
||||
getattr(cert_row, "cert_image_urls", None) or ""
|
||||
) if cert_row else ""
|
||||
item["storageCertRemark"] = (getattr(cert_row, "remark", None) or "") if cert_row else ""
|
||||
item["insuranceHandleTime"] = _fmt_dt(getattr(ins_row, "handle_time", None)) if ins_row else ""
|
||||
item["insuranceHandlerUserId"] = (
|
||||
getattr(ins_row, "handler_user_id", None) or ""
|
||||
) if ins_row else ""
|
||||
item["insuranceAttachments"] = (
|
||||
getattr(ins_row, "insurance_attachments", None) or ""
|
||||
) if ins_row else ""
|
||||
item["insuranceRemark"] = (getattr(ins_row, "handle_remark", None) or "") if ins_row else ""
|
||||
item["policySendTime"] = _fmt_dt(getattr(ins_row, "policy_send_time", None)) if ins_row else ""
|
||||
# 购买保险时间 = 保险办理时间
|
||||
item["purchaseInsuranceTime"] = item["insuranceHandleTime"]
|
||||
|
||||
cs_row = cs_map.get(code)
|
||||
if cs_row:
|
||||
cs = sample_cs_crud.row_to_dict(cs_row)
|
||||
item["smsSendDate"] = cs.get("smsSendDate") or ""
|
||||
item["smsSendStatus"] = cs.get("smsSendStatus") or ""
|
||||
item["microbeFollowupDate"] = cs.get("microbeFollowupDate") or ""
|
||||
item["microbeFollowupStatus"] = cs.get("microbeFollowupStatus") or ""
|
||||
item["fullmoonFollowupDate"] = cs.get("fullmoonFollowupDate") or ""
|
||||
item["fullmoonFollowupStatus"] = cs.get("fullmoonFollowupStatus") or ""
|
||||
item["householdRegistered"] = cs.get("householdRegistered") or ""
|
||||
item["einvoiceSendTime"] = cs.get("einvoiceSendTime") or ""
|
||||
item["effectiveAddress"] = cs.get("effectiveAddress") or ""
|
||||
else:
|
||||
item["smsSendDate"] = ""
|
||||
item["smsSendStatus"] = ""
|
||||
item["microbeFollowupDate"] = ""
|
||||
item["microbeFollowupStatus"] = ""
|
||||
item["fullmoonFollowupDate"] = ""
|
||||
item["fullmoonFollowupStatus"] = ""
|
||||
item["householdRegistered"] = ""
|
||||
item["einvoiceSendTime"] = ""
|
||||
item["effectiveAddress"] = ""
|
||||
|
||||
item["protocolAttachmentPresent"] = _has_urls(item.get("protocolAttachments"))
|
||||
item["insuranceDocPresent"] = bool(ins_done)
|
||||
item["informedConsentPresent"] = _has_urls(item.get("consentFormUrls"))
|
||||
item["healthSurveyPresent"] = _has_urls(item.get("healthSurveyFormUrls"))
|
||||
|
||||
name_ids = [
|
||||
str(item.get("storageBy") or "")
|
||||
for item in items
|
||||
if str(item.get("storageBy") or "").strip()
|
||||
and not str(item.get("storageByName") or "").strip()
|
||||
]
|
||||
if include_handle_flags:
|
||||
name_ids.extend(
|
||||
str(item.get("storageCertHandlerUserId") or "")
|
||||
for item in items
|
||||
if str(item.get("storageCertHandlerUserId") or "").strip()
|
||||
)
|
||||
name_ids.extend(
|
||||
str(item.get("insuranceHandlerUserId") or "")
|
||||
for item in items
|
||||
if str(item.get("insuranceHandlerUserId") or "").strip()
|
||||
)
|
||||
name_map = _user_name_map(db, name_ids)
|
||||
for item in items:
|
||||
if not str(item.get("storageByName") or "").strip():
|
||||
item["storageByName"] = name_map.get(str(item.get("storageBy") or ""), "")
|
||||
if include_handle_flags:
|
||||
cert_uid = str(item.get("storageCertHandlerUserId") or "")
|
||||
ins_uid = str(item.get("insuranceHandlerUserId") or "")
|
||||
item["storageCertHandlerName"] = name_map.get(cert_uid, "") if cert_uid else ""
|
||||
item["insuranceHandlerName"] = name_map.get(ins_uid, "") if ins_uid else ""
|
||||
@@ -0,0 +1,98 @@
|
||||
"""样本信息管理:进度展示码、证/保险已办理判定、办理字段同步。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
|
||||
def collection_progress(sample_status: str) -> Tuple[str, str]:
|
||||
st = (sample_status or "").strip()
|
||||
if not st:
|
||||
return "uncollected", "未采集"
|
||||
if st == "yzf":
|
||||
return "voided", "已作废"
|
||||
if st in ("ycj", "jcz", "yrk"):
|
||||
return "collected", "已采集"
|
||||
return "uncollected", "未采集"
|
||||
|
||||
|
||||
def reception_progress(reception: Optional[Dict[str, Any]]) -> Tuple[str, str]:
|
||||
if not reception:
|
||||
return "none", "未接收"
|
||||
c = (reception.get("acceptConclusion") or "").strip()
|
||||
if c == "js":
|
||||
return "js", "已接收"
|
||||
if c == "bj":
|
||||
return "bj", "拒收"
|
||||
return "none", "未接收"
|
||||
|
||||
|
||||
def qc_progress(qc: Optional[Dict[str, Any]]) -> Tuple[str, str]:
|
||||
if not qc:
|
||||
return "incomplete", "未完成"
|
||||
if (qc.get("qcWorkOrderStatus") or "").strip() == "yzf":
|
||||
return "voided", "已作废"
|
||||
conc = (qc.get("qcConclusion") or "").strip()
|
||||
if conc in ("tg", "btg"):
|
||||
return "complete", "已完成"
|
||||
return "incomplete", "未完成"
|
||||
|
||||
|
||||
def storage_progress(review: Optional[Dict[str, Any]]) -> Tuple[str, str]:
|
||||
if not review:
|
||||
return "none", "未入库"
|
||||
if (review.get("storageStatus") or "").strip() == "yrk":
|
||||
return "yrk", "已入库"
|
||||
if (review.get("reviewConclusion") or "").strip() == "btg":
|
||||
return "rejected", "拒绝入库"
|
||||
return "none", "未入库"
|
||||
|
||||
|
||||
def storage_cert_handled(row: Any) -> bool:
|
||||
if row is None:
|
||||
return False
|
||||
if getattr(row, "handle_time", None):
|
||||
return True
|
||||
if (getattr(row, "make_status", None) or "").strip() == "1":
|
||||
return True
|
||||
return bool((getattr(row, "make_date", None) or "").strip())
|
||||
|
||||
|
||||
def insurance_handled(row: Any) -> bool:
|
||||
if row is None:
|
||||
return False
|
||||
if getattr(row, "handle_time", None):
|
||||
return True
|
||||
if (getattr(row, "todo_status", None) or "").strip() == "1":
|
||||
return True
|
||||
return bool((getattr(row, "handle_date", None) or "").strip())
|
||||
|
||||
|
||||
def sync_handle_status_fields(handle_time: Optional[datetime]) -> Tuple[str, str]:
|
||||
if handle_time is None:
|
||||
return "0", ""
|
||||
return "1", handle_time.strftime("%Y%m%d")
|
||||
|
||||
|
||||
def apply_handle_payload(
|
||||
*,
|
||||
handle_time: Optional[datetime],
|
||||
handler_user_id: Optional[str],
|
||||
attachments: str = "",
|
||||
remark: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""规范化办理写入字段。有办理时间则办理人员必填。"""
|
||||
uid = (handler_user_id or "").strip() or None
|
||||
if handle_time is not None and not uid:
|
||||
raise ValueError("填写办理时间时必须指定办理人员")
|
||||
status, legacy_date = sync_handle_status_fields(handle_time)
|
||||
if handle_time is None:
|
||||
uid = None
|
||||
return {
|
||||
"handle_time": handle_time,
|
||||
"handler_user_id": uid,
|
||||
"attachments": (attachments or "").strip(),
|
||||
"remark": (remark or "").strip(),
|
||||
"make_or_todo_status": status,
|
||||
"legacy_date": legacy_date,
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"""样本信息列表快捷登记:去重与入库字段规范化。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from app.services.sample_info_progress import apply_handle_payload
|
||||
|
||||
|
||||
def unique_nonempty_ids(ids: Iterable[Optional[str]]) -> List[str]:
|
||||
seen = set()
|
||||
out: List[str] = []
|
||||
for raw in ids:
|
||||
s = (raw or "").strip()
|
||||
if not s or s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def unique_sample_items(
|
||||
items: Optional[Iterable[Dict[str, str]]],
|
||||
) -> List[tuple]:
|
||||
"""按 sampleCode 去重,保留首次出现的 (sampleCode, protocolId)。"""
|
||||
seen = set()
|
||||
out: List[tuple] = []
|
||||
for it in items or []:
|
||||
code = (it.get("sampleCode") or "").strip()
|
||||
pid = (it.get("protocolId") or "").strip()
|
||||
if not code or code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
out.append((code, pid))
|
||||
return out
|
||||
|
||||
|
||||
def apply_quick_storage_payload(
|
||||
*,
|
||||
handle_time: Optional[datetime],
|
||||
handler_user_id: Optional[str],
|
||||
attachments: str = "",
|
||||
remark: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""快捷登记入库必须填写办理时间;不走生产/质检双门禁。"""
|
||||
if handle_time is None:
|
||||
raise ValueError("请填写办理时间")
|
||||
payload = apply_handle_payload(
|
||||
handle_time=handle_time,
|
||||
handler_user_id=handler_user_id,
|
||||
attachments=attachments,
|
||||
remark=remark,
|
||||
)
|
||||
return {
|
||||
"storage_status": "yrk",
|
||||
"storage_time": payload["handle_time"],
|
||||
"storage_by": payload["handler_user_id"],
|
||||
"storage_in_date": payload["legacy_date"],
|
||||
"attachments": payload["attachments"],
|
||||
"remark": payload["remark"],
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"""fq → fgd:计算并期变更指令(纯函数,不写库)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Iterable, List, Mapping
|
||||
|
||||
from app.services.protocol_transfer_status import schedule_paid_and_arrears
|
||||
|
||||
|
||||
def _dec(v: Any) -> Decimal:
|
||||
if v is None or v == "":
|
||||
return Decimal("0")
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def _status(row: Mapping[str, Any], *keys: str) -> str:
|
||||
for k in keys:
|
||||
if k in row and row[k] is not None:
|
||||
return str(row[k]).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def plan_fq_to_fgd_merge(
|
||||
*,
|
||||
schedules: Iterable[Mapping[str, Any]],
|
||||
transfers: Iterable[Mapping[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
sch_list = [
|
||||
s
|
||||
for s in schedules
|
||||
if _status(s, "scheduleStatus", "schedule_status") != "void"
|
||||
and not s.get("_deleted")
|
||||
]
|
||||
sch_list.sort(key=lambda s: int(s.get("scheduleNo") or s.get("schedule_no") or 0))
|
||||
tr_list = list(transfers or [])
|
||||
|
||||
by_sch: Dict[str, List[Mapping[str, Any]]] = {}
|
||||
for t in tr_list:
|
||||
sid = str(t.get("scheduleId") or t.get("schedule_id") or "")
|
||||
by_sch.setdefault(sid, []).append(t)
|
||||
|
||||
def _arrears(s: Mapping[str, Any]) -> Decimal:
|
||||
sid = str(s.get("id") or "")
|
||||
confirmed = [
|
||||
_dec(t.get("transferAmount", t.get("transfer_amount")))
|
||||
for t in by_sch.get(sid, [])
|
||||
if _status(t, "payStatus", "pay_status") == "yjf"
|
||||
]
|
||||
_paid, arr = schedule_paid_and_arrears(
|
||||
due=s.get("dueAmount", s.get("due_amount")),
|
||||
confirmed_transfers=confirmed,
|
||||
)
|
||||
arr_d = _dec(arr)
|
||||
return arr_d if arr_d > 0 else Decimal("0")
|
||||
|
||||
target = None
|
||||
for s in sch_list:
|
||||
if _arrears(s) > 0:
|
||||
target = s
|
||||
break
|
||||
if target is None:
|
||||
for s in sch_list:
|
||||
if _status(s, "scheduleStatus", "schedule_status") != "cleared":
|
||||
target = s
|
||||
break
|
||||
if target is None:
|
||||
raise ValueError("没有可合并的未清期次")
|
||||
|
||||
target_no = int(target.get("scheduleNo") or target.get("schedule_no") or 0)
|
||||
later = [
|
||||
s
|
||||
for s in sch_list
|
||||
if int(s.get("scheduleNo") or s.get("schedule_no") or 0) > target_no
|
||||
]
|
||||
for s in later:
|
||||
sid = str(s.get("id") or "")
|
||||
if any(
|
||||
_status(t, "payStatus", "pay_status") == "yjf" for t in by_sch.get(sid, [])
|
||||
):
|
||||
raise ValueError("后续期已有已确认转账,无法合并为分改趸")
|
||||
|
||||
remain = sum((_arrears(s) for s in sch_list), Decimal("0"))
|
||||
tid = str(target.get("id") or "")
|
||||
target_confirmed = sum(
|
||||
(
|
||||
_dec(t.get("transferAmount", t.get("transfer_amount")))
|
||||
for t in by_sch.get(tid, [])
|
||||
if _status(t, "payStatus", "pay_status") == "yjf"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
new_due = target_confirmed + remain
|
||||
soft_ids = [str(s.get("id") or "") for s in later if str(s.get("id") or "")]
|
||||
soft_set = set(soft_ids)
|
||||
kept = [s for s in sch_list if str(s.get("id") or "") not in soft_set]
|
||||
|
||||
return {
|
||||
"targetScheduleId": tid,
|
||||
"newDueAmount": new_due,
|
||||
"softDeleteScheduleIds": soft_ids,
|
||||
"newInstallmentCount": len(kept),
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Iterable, List, Mapping
|
||||
|
||||
|
||||
def _dec(v: Any) -> Decimal:
|
||||
if v is None or v == "":
|
||||
return Decimal("0")
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def _status(row: Mapping[str, Any], *keys: str) -> str:
|
||||
for k in keys:
|
||||
if k in row and row[k] is not None:
|
||||
return str(row[k]).strip()
|
||||
return ""
|
||||
|
||||
|
||||
_DISPLAY_PAID_STATUSES = ("yjf",)
|
||||
|
||||
|
||||
def summarize_period(
|
||||
*,
|
||||
due_amount: Any,
|
||||
transfers: Iterable[Mapping[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
displayPaidAmount: 已缴费金额仅计付款完成(yjf)
|
||||
arrearsAmount: max(due - displayPaidAmount, 0)
|
||||
financeAudit: jfqrz > (confirmed>=due 且无确认中 → yjf) > (0<confirmed → partial) > dfj
|
||||
payDate: 首笔 transferTime 的日期部分(调用方应按 create_time ASC 传入)
|
||||
payTypeSummary: 0 笔空;1 笔用该笔 type;多笔「多笔」
|
||||
"""
|
||||
trs: List[Mapping[str, Any]] = list(transfers or [])
|
||||
display_paid = sum(
|
||||
(
|
||||
_dec(t.get("transferAmount", t.get("transfer_amount")))
|
||||
for t in trs
|
||||
if _status(t, "payStatus", "pay_status") in _DISPLAY_PAID_STATUSES
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
due = _dec(due_amount)
|
||||
arrears = due - display_paid
|
||||
if arrears < 0:
|
||||
arrears = Decimal("0")
|
||||
|
||||
has_jfqrz = any(_status(t, "payStatus", "pay_status") == "jfqrz" for t in trs)
|
||||
confirmed = sum(
|
||||
(
|
||||
_dec(t.get("transferAmount", t.get("transfer_amount")))
|
||||
for t in trs
|
||||
if _status(t, "payStatus", "pay_status") == "yjf"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
if has_jfqrz:
|
||||
audit = "jfqrz"
|
||||
elif due > 0 and confirmed >= due:
|
||||
audit = "yjf"
|
||||
elif confirmed > 0:
|
||||
audit = "partial"
|
||||
else:
|
||||
audit = "dfj"
|
||||
|
||||
pay_date = ""
|
||||
if trs:
|
||||
raw = str(trs[0].get("transferTime") or trs[0].get("transfer_time") or "")
|
||||
pay_date = raw[:10] if len(raw) >= 10 else raw
|
||||
|
||||
types = [
|
||||
_status(t, "transferType", "transfer_type")
|
||||
for t in trs
|
||||
if _status(t, "transferType", "transfer_type")
|
||||
]
|
||||
if len(types) == 0:
|
||||
pay_type = ""
|
||||
elif len(set(types)) == 1 and len(types) == 1:
|
||||
pay_type = types[0]
|
||||
else:
|
||||
pay_type = "多笔"
|
||||
|
||||
return {
|
||||
"displayPaidAmount": display_paid,
|
||||
"arrearsAmount": arrears,
|
||||
"payDate": pay_date,
|
||||
"payTypeSummary": pay_type,
|
||||
"financeAudit": audit,
|
||||
}
|
||||
@@ -19,39 +19,69 @@ def _status(row: Mapping[str, Any], *keys: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _schedule_id(row: Mapping[str, Any]) -> str:
|
||||
return str(row.get("id") or row.get("scheduleId") or row.get("schedule_id") or "").strip()
|
||||
|
||||
|
||||
def _transfer_schedule_id(row: Mapping[str, Any]) -> str:
|
||||
return str(row.get("scheduleId") or row.get("schedule_id") or "").strip()
|
||||
|
||||
|
||||
def is_schedule_effectively_cleared(
|
||||
schedule: Mapping[str, Any],
|
||||
*,
|
||||
confirmed_paid: Decimal,
|
||||
) -> bool:
|
||||
"""
|
||||
批次已缴清:显式 cleared,或财务确认实缴(yjf)已覆盖应缴。
|
||||
兼容历史数据未回写 schedule_status 的情况。
|
||||
"""
|
||||
if _status(schedule, "scheduleStatus", "schedule_status") == "cleared":
|
||||
return True
|
||||
due = _dec(schedule.get("dueAmount", schedule.get("due_amount")))
|
||||
return confirmed_paid >= due
|
||||
|
||||
|
||||
def compute_sample_payment_progress(
|
||||
*,
|
||||
schedules: Iterable[Mapping[str, Any]],
|
||||
transfers: Iterable[Mapping[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
progressStatus 优先级:jfqrz > yjq > bfyj > dfj
|
||||
progressStatus 优先级:yjq > bfyj > dfj(不含付款确认中)
|
||||
已缴仅计 pay_status=yjf;有效期排除 schedule_status=void
|
||||
canConfirm:无 jfqrz,且存在有效未清期(欠缴>0)或可编辑的 dfj 草稿
|
||||
已缴清期:schedule_status=cleared,或该期 yjf 合计 >= 应缴
|
||||
canConfirm:无 jfqrz,且存在有效未清期
|
||||
"""
|
||||
sch_list: List[Mapping[str, Any]] = list(schedules or [])
|
||||
tr_list: List[Mapping[str, Any]] = list(transfers or [])
|
||||
|
||||
paid_by_schedule: Dict[str, Decimal] = {}
|
||||
paid = Decimal("0")
|
||||
for t in tr_list:
|
||||
if _status(t, "payStatus", "pay_status") != "yjf":
|
||||
continue
|
||||
amount = _dec(t.get("transferAmount", t.get("transfer_amount")))
|
||||
paid += amount
|
||||
sid = _transfer_schedule_id(t)
|
||||
if sid:
|
||||
paid_by_schedule[sid] = paid_by_schedule.get(sid, Decimal("0")) + amount
|
||||
|
||||
active = [
|
||||
s
|
||||
for s in sch_list
|
||||
if _status(s, "scheduleStatus", "schedule_status") != "void"
|
||||
]
|
||||
active_schedule_count = len(active)
|
||||
cleared_schedule_count = sum(
|
||||
1
|
||||
cleared_flags = [
|
||||
is_schedule_effectively_cleared(
|
||||
s,
|
||||
confirmed_paid=paid_by_schedule.get(_schedule_id(s), Decimal("0")),
|
||||
)
|
||||
for s in active
|
||||
if _status(s, "scheduleStatus", "schedule_status") == "cleared"
|
||||
)
|
||||
]
|
||||
cleared_schedule_count = sum(1 for cleared in cleared_flags if cleared)
|
||||
|
||||
paid = sum(
|
||||
(
|
||||
_dec(t.get("transferAmount", t.get("transfer_amount")))
|
||||
for t in tr_list
|
||||
if _status(t, "payStatus", "pay_status") == "yjf"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
due_total = sum((_dec(s.get("dueAmount", s.get("due_amount"))) for s in active), Decimal("0"))
|
||||
due_remain = due_total - paid
|
||||
if due_remain < 0:
|
||||
@@ -59,18 +89,14 @@ def compute_sample_payment_progress(
|
||||
|
||||
has_jfqrz = any(_status(t, "payStatus", "pay_status") == "jfqrz" for t in tr_list)
|
||||
|
||||
if has_jfqrz:
|
||||
progress = "jfqrz"
|
||||
elif active_schedule_count > 0 and cleared_schedule_count == active_schedule_count:
|
||||
if active_schedule_count > 0 and cleared_schedule_count == active_schedule_count:
|
||||
progress = "yjq"
|
||||
elif paid > 0:
|
||||
progress = "bfyj"
|
||||
else:
|
||||
progress = "dfj"
|
||||
|
||||
can_confirm = (not has_jfqrz) and any(
|
||||
_status(s, "scheduleStatus", "schedule_status") != "cleared" for s in active
|
||||
)
|
||||
can_confirm = (not has_jfqrz) and any(not cleared for cleared in cleared_flags)
|
||||
|
||||
return {
|
||||
"progressStatus": progress,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any, Iterable, Mapping, Optional, Tuple
|
||||
|
||||
|
||||
def _dec(v: Any) -> Decimal:
|
||||
if v is None or v == "":
|
||||
return Decimal("0")
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def _status(row: Mapping[str, Any], *keys: str) -> str:
|
||||
for k in keys:
|
||||
if k in row and row[k] is not None:
|
||||
return str(row[k]).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _due_date(row: Mapping[str, Any]) -> str:
|
||||
for k in ("dueDate", "due_date"):
|
||||
if k in row and row[k] is not None:
|
||||
return str(row[k]).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def schedule_required_fields_ok(
|
||||
*,
|
||||
schedules: Iterable[Mapping[str, Any]],
|
||||
sample_code: str = "",
|
||||
require_fields: bool = True,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
未作废期:应缴金额与应缴时间必填(分期场景由调用方 require_fields=True)。
|
||||
"""
|
||||
if not require_fields:
|
||||
return True, None
|
||||
code = (sample_code or "").strip()
|
||||
prefix = f"样本 {code} " if code else ""
|
||||
idx = 0
|
||||
for s in schedules or []:
|
||||
if not isinstance(s, Mapping):
|
||||
continue
|
||||
st = _status(s, "scheduleStatus", "schedule_status") or "open"
|
||||
if st == "void":
|
||||
continue
|
||||
idx += 1
|
||||
amt_raw = s.get("dueAmount", s.get("due_amount"))
|
||||
if amt_raw is None or amt_raw == "":
|
||||
return False, f"{prefix}第 {idx} 期应缴金额必须填写"
|
||||
amt = _dec(amt_raw)
|
||||
if amt <= 0:
|
||||
return False, f"{prefix}第 {idx} 期应缴金额必须填写"
|
||||
if not _due_date(s):
|
||||
return False, f"{prefix}第 {idx} 期应缴时间必须填写"
|
||||
return True, None
|
||||
|
||||
|
||||
def schedule_dues_match_contract(
|
||||
*,
|
||||
contract_amount: Any,
|
||||
schedules: Iterable[Mapping[str, Any]],
|
||||
sample_code: str = "",
|
||||
tol: Decimal = Decimal("0.015"),
|
||||
require_schedule_fields: bool = False,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
未作废期 dueAmount 合计须等于合同金额。
|
||||
require_schedule_fields=True 时先校验每期金额与应缴日必填。
|
||||
返回 (True, None) 或 (False, 错误文案)。
|
||||
"""
|
||||
sched_list = [s for s in (schedules or []) if isinstance(s, Mapping)]
|
||||
if require_schedule_fields:
|
||||
ok_req, req_msg = schedule_required_fields_ok(
|
||||
schedules=sched_list, sample_code=sample_code, require_fields=True
|
||||
)
|
||||
if not ok_req:
|
||||
return False, req_msg
|
||||
|
||||
if contract_amount is None or contract_amount == "":
|
||||
return True, None
|
||||
contract = _dec(contract_amount)
|
||||
total = Decimal("0")
|
||||
active_count = 0
|
||||
for s in sched_list:
|
||||
st = _status(s, "scheduleStatus", "schedule_status") or "open"
|
||||
if st == "void":
|
||||
continue
|
||||
active_count += 1
|
||||
total += _dec(s.get("dueAmount", s.get("due_amount")))
|
||||
# 尚未生成应缴批次时不拦(与前端一致)
|
||||
if active_count == 0:
|
||||
return True, None
|
||||
if abs(contract - total) <= tol:
|
||||
return True, None
|
||||
code = (sample_code or "").strip()
|
||||
prefix = f"样本 {code} " if code else ""
|
||||
msg = (
|
||||
f"{prefix}合同金额 {contract:.2f} 与各期应缴合计 {total:.2f} 不一致,请调整后再保存"
|
||||
)
|
||||
return False, msg
|
||||
Loaded 100 of 779 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user