Compare commits
188
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
+60
@@ -0,0 +1,60 @@
|
||||
# 生产 Docker 交付包:仅 tag 触发
|
||||
# 镜像与包名固定为 v1.1.0(与现网 .env / 部署习惯一致),便于生产升级时不必改镜像标签
|
||||
# 产物:/root/zkxw-releases/zkxw-docker-deploy-v1.1.0.tar.gz
|
||||
# 脚本:./scripts/package-docker-deploy.sh
|
||||
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: package-prod
|
||||
|
||||
concurrency:
|
||||
limit: 1
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
|
||||
clone:
|
||||
depth: 1
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
host:
|
||||
path: /var/run/docker.sock
|
||||
- name: releases
|
||||
host:
|
||||
path: /root/zkxw-releases
|
||||
|
||||
steps:
|
||||
- name: package-docker
|
||||
image: docker:27-cli
|
||||
privileged: true
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
- name: releases
|
||||
path: /releases
|
||||
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
|
||||
# 固定交付版本(勿改为 DRONE_TAG,生产 .env 使用 v1.1.0)
|
||||
ZKXW_DEPLOY_VERSION: v1.1.0
|
||||
commands:
|
||||
- set -eu
|
||||
- apk add --no-cache bash git python3
|
||||
- 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` 服务。
|
||||
@@ -40,7 +40,10 @@ 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
|
||||
|
||||
__all__ = [
|
||||
"public_router",
|
||||
@@ -84,5 +87,8 @@ __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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -24,6 +25,31 @@ from app.services.party_customer_sync import sync_party_customers
|
||||
|
||||
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 +216,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],
|
||||
@@ -266,17 +311,15 @@ def _persist_protocol_sub_resources(
|
||||
)
|
||||
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,
|
||||
)
|
||||
if samples is not None:
|
||||
cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples)
|
||||
|
||||
@@ -290,6 +333,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 +355,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,
|
||||
@@ -368,6 +413,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):
|
||||
@@ -500,6 +546,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 +564,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 +1155,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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -6,7 +6,11 @@ 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
|
||||
@@ -14,6 +18,9 @@ from app.models.research_donation import BizResearchDonation
|
||||
|
||||
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 +126,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 +260,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:
|
||||
@@ -259,9 +294,10 @@ 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:
|
||||
@@ -397,6 +433,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))
|
||||
@@ -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,7 +6,7 @@ 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -34,6 +34,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 +69,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(
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1076,7 +1351,7 @@ def save_sample_payments_for_protocol(
|
||||
user_id: Optional[str],
|
||||
sample_payments: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""按样本保存缴费方案:upsert 方案 → 同步批次与转账笔(确认中可改,已确认只读)。"""
|
||||
"""按样本保存缴费方案;锁定批次/转账所在方案禁止结构性变更。"""
|
||||
existing = (
|
||||
db.query(BizProtocolSamplePayment)
|
||||
.filter(
|
||||
@@ -1084,10 +1359,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()
|
||||
@@ -1172,7 +1481,11 @@ def save_sample_payments_for_protocol(
|
||||
|
||||
|
||||
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 +1493,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 +1536,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 +1602,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 +1808,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 +1816,7 @@ def _compute_progress_for_payment(
|
||||
]
|
||||
tr_dicts = [
|
||||
{
|
||||
"scheduleId": t.schedule_id,
|
||||
"payStatus": t.pay_status or "",
|
||||
"transferAmount": t.transfer_amount,
|
||||
}
|
||||
|
||||
@@ -9,14 +9,17 @@ 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_sample_codes_not_used,
|
||||
_format_sample_collect_summary,
|
||||
_load_express_name_map,
|
||||
_load_sample_type_name_map,
|
||||
_load_site_name_map,
|
||||
)
|
||||
@@ -46,6 +49,7 @@ from app.models.models import (
|
||||
SysUser,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizDonationStatusChange,
|
||||
BizResearchDonation,
|
||||
BizResearchDonationAudit,
|
||||
BizResearchDonationCollection,
|
||||
@@ -66,6 +70,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 +251,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 +404,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 +501,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 +780,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 +844,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 +884,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 +896,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 +925,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 +1016,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 +1109,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 +1128,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 +1148,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 +1169,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 +1184,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 +1223,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 +1243,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,14 +1306,28 @@ 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:
|
||||
@@ -1089,7 +1340,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 +1360,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 +1372,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 +1592,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 +1620,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 +1637,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 +1758,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 +1908,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 +2167,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 +2227,7 @@ def _create_party(
|
||||
user_id: Optional[str],
|
||||
raw: dict,
|
||||
now: datetime,
|
||||
create_dept_id: str = "",
|
||||
) -> BizResearchDonationParty:
|
||||
obj = BizResearchDonationParty(
|
||||
id=generate_uuid(),
|
||||
@@ -1941,7 +2236,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 +2265,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 +2282,7 @@ def _create_collection(
|
||||
user_id: Optional[str],
|
||||
raw: dict,
|
||||
now: datetime,
|
||||
create_dept_id: str = "",
|
||||
) -> BizResearchDonationCollection:
|
||||
obj = BizResearchDonationCollection(
|
||||
id=generate_uuid(),
|
||||
@@ -1989,7 +2291,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 +2383,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 +2413,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 +2442,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 +2477,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),
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
|
||||
|
||||
+4
-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
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -152,7 +152,10 @@ 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)
|
||||
|
||||
|
||||
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="租户编号")
|
||||
@@ -1056,6 +1060,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 +1121,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 +1532,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 +1614,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 +1700,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 +1748,54 @@ class BizSampleStorageCertQc(Base):
|
||||
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"""
|
||||
|
||||
@@ -3711,6 +3825,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"""
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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:
|
||||
@@ -3088,6 +3143,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,
|
||||
@@ -420,8 +416,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}字")
|
||||
@@ -485,8 +479,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 []
|
||||
@@ -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,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
|
||||
@@ -0,0 +1,133 @@
|
||||
"""顺丰月结抬头与结算规则。不访问数据库。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from typing import Any, Optional
|
||||
|
||||
SF_TRANSPORT_MODE = "sfkd"
|
||||
SETTLE_UNSETTLED = "wjs"
|
||||
SETTLE_SETTLED = "yjs"
|
||||
_AMOUNT_QUANT = Decimal("0.01")
|
||||
_MAX_AMOUNT = Decimal("9999999999999999.99")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FreightHeaderPlan:
|
||||
transport_mode: str
|
||||
waybill_no: Optional[str]
|
||||
freight_amount: Optional[Decimal]
|
||||
settle_status: Optional[str]
|
||||
clear_settle_time: bool
|
||||
|
||||
|
||||
def normalize_waybill(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _is_blank(value: Any) -> bool:
|
||||
return value is None or (isinstance(value, str) and value.strip() == "")
|
||||
|
||||
|
||||
def parse_optional_amount(value: Any) -> Optional[Decimal]:
|
||||
if _is_blank(value):
|
||||
return None
|
||||
try:
|
||||
amount = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
raise ValueError("运费金额格式不正确")
|
||||
if not amount.is_finite():
|
||||
raise ValueError("运费金额格式不正确")
|
||||
if amount < 0:
|
||||
raise ValueError("运费不能为负数")
|
||||
if amount > _MAX_AMOUNT:
|
||||
raise ValueError("运费金额超出范围")
|
||||
return amount.quantize(_AMOUNT_QUANT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def freight_amount_label(transport_mode: Optional[str], amount: Optional[Decimal]) -> str:
|
||||
if (transport_mode or "") == SF_TRANSPORT_MODE and amount is None:
|
||||
return "月结"
|
||||
if amount is None:
|
||||
return ""
|
||||
return format(Decimal(amount).quantize(_AMOUNT_QUANT, rounding=ROUND_HALF_UP), "f")
|
||||
|
||||
|
||||
def plan_freight_header(
|
||||
*,
|
||||
transport_mode: str,
|
||||
waybill_no: Any = None,
|
||||
freight_amount: Any = None,
|
||||
) -> FreightHeaderPlan:
|
||||
mode = str(transport_mode or "").strip()
|
||||
if not mode:
|
||||
raise ValueError("请选择运输方式")
|
||||
if mode == SF_TRANSPORT_MODE:
|
||||
waybill = normalize_waybill(waybill_no)
|
||||
if not waybill:
|
||||
raise ValueError("请填写顺丰单号")
|
||||
if len(waybill) > 64:
|
||||
raise ValueError("顺丰单号不能超过 64 个字符")
|
||||
return FreightHeaderPlan(mode, waybill, None, SETTLE_UNSETTLED, True)
|
||||
if normalize_waybill(waybill_no):
|
||||
raise ValueError("该运输方式不能填写顺丰单号")
|
||||
amount = parse_optional_amount(freight_amount)
|
||||
if amount is None:
|
||||
raise ValueError("请填写运费")
|
||||
return FreightHeaderPlan(mode, None, amount, None, True)
|
||||
|
||||
|
||||
def resolve_header_for_save(
|
||||
*,
|
||||
transport_mode: str,
|
||||
existing_waybill: Any,
|
||||
existing_amount: Any,
|
||||
existing_settle_status: Any,
|
||||
waybill_supplied: bool,
|
||||
waybill_no: Any,
|
||||
amount_supplied: bool,
|
||||
freight_amount: Any,
|
||||
) -> FreightHeaderPlan:
|
||||
if (existing_settle_status or "") == SETTLE_SETTLED:
|
||||
raise ValueError("已结算的运费不能修改运输方式或顺丰单号")
|
||||
if transport_mode == SF_TRANSPORT_MODE:
|
||||
waybill = waybill_no if waybill_supplied else existing_waybill
|
||||
return plan_freight_header(
|
||||
transport_mode=transport_mode, waybill_no=waybill, freight_amount=None
|
||||
)
|
||||
amount = freight_amount if amount_supplied else existing_amount
|
||||
return plan_freight_header(
|
||||
transport_mode=transport_mode, waybill_no="", freight_amount=amount
|
||||
)
|
||||
|
||||
|
||||
def plan_settle(*, current_amount: Optional[Decimal], submitted_amount: Any, now: datetime):
|
||||
amount = current_amount if _is_blank(submitted_amount) else parse_optional_amount(submitted_amount)
|
||||
return amount, SETTLE_SETTLED, now
|
||||
|
||||
|
||||
def assert_settle_request(ids: list, submitted_amount: Any) -> None:
|
||||
if not ids:
|
||||
raise ValueError("请选择运费批次")
|
||||
if len(ids) != 1 and not _is_blank(submitted_amount):
|
||||
raise ValueError("批量结算不能填写运费")
|
||||
|
||||
|
||||
def plan_fill_amount(*, current_amount: Optional[Decimal], settle_status: Optional[str], submitted_amount: Any) -> Decimal:
|
||||
if settle_status != SETTLE_SETTLED or current_amount is not None:
|
||||
raise ValueError("只有已结算且仍为月结的运费可以补填金额")
|
||||
amount = parse_optional_amount(submitted_amount)
|
||||
if amount is None:
|
||||
raise ValueError("请填写运费")
|
||||
return amount
|
||||
|
||||
|
||||
def assert_freight_mutable(settle_status: Optional[str], action: str) -> None:
|
||||
if (settle_status or "") != SETTLE_SETTLED:
|
||||
return
|
||||
if action == "delete":
|
||||
raise ValueError("已结算的运费不能删除")
|
||||
if action == "items":
|
||||
raise ValueError("已结算的运费不能调整样本")
|
||||
raise ValueError("已结算的运费不能修改运输方式或顺丰单号")
|
||||
@@ -50,11 +50,13 @@ def _dedupe_role_id_rows(rows) -> List[str]:
|
||||
return out
|
||||
|
||||
|
||||
def get_direct_enabled_role_ids(db: Session, user_id: str) -> List[str]:
|
||||
def get_direct_enabled_role_ids(
|
||||
db: Session, user_id: str, tenant_no: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""用户直接绑定且启用的角色 ID(sys_user_role → sys_role)。"""
|
||||
if not user_id:
|
||||
return []
|
||||
tenant = settings.DEFAULT_TENANT_NO
|
||||
tenant = (tenant_no or settings.DEFAULT_TENANT_NO).strip()
|
||||
rows = (
|
||||
db.query(SysRole.id)
|
||||
.join(SysUserRole, SysUserRole.role_id == SysRole.id)
|
||||
@@ -72,11 +74,13 @@ def get_direct_enabled_role_ids(db: Session, user_id: str) -> List[str]:
|
||||
return _dedupe_role_id_rows(rows)
|
||||
|
||||
|
||||
def get_post_inherited_role_ids(db: Session, user_id: str) -> List[str]:
|
||||
def get_post_inherited_role_ids(
|
||||
db: Session, user_id: str, tenant_no: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""经启用职位继承的启用角色 ID(sys_user_post → sys_post → sys_post_role → sys_role)。"""
|
||||
if not user_id:
|
||||
return []
|
||||
tenant = settings.DEFAULT_TENANT_NO
|
||||
tenant = (tenant_no or settings.DEFAULT_TENANT_NO).strip()
|
||||
rows = (
|
||||
db.query(SysRole.id)
|
||||
.join(SysPostRole, SysPostRole.role_id == SysRole.id)
|
||||
@@ -111,14 +115,18 @@ def get_post_inherited_role_ids(db: Session, user_id: str) -> List[str]:
|
||||
return _dedupe_role_id_rows(rows)
|
||||
|
||||
|
||||
def get_effective_role_ids(db: Session, user_id: str) -> List[str]:
|
||||
def get_effective_role_ids(
|
||||
db: Session, user_id: str, tenant_no: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""用户有效角色 ID:直接绑定 ∪ 启用职位关联的启用角色(去重保序)。"""
|
||||
if not user_id:
|
||||
return []
|
||||
out: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
for rid in get_direct_enabled_role_ids(db, user_id) + get_post_inherited_role_ids(
|
||||
db, user_id
|
||||
for rid in get_direct_enabled_role_ids(
|
||||
db, user_id, tenant_no=tenant_no
|
||||
) + get_post_inherited_role_ids(
|
||||
db, user_id, tenant_no=tenant_no
|
||||
):
|
||||
if rid not in seen:
|
||||
seen.add(rid)
|
||||
@@ -282,12 +290,14 @@ def list_user_ids_with_any_effective_role_ids(
|
||||
return out
|
||||
|
||||
|
||||
def get_role_names_by_ids(db: Session, role_ids: List[str]) -> List[str]:
|
||||
def get_role_names_by_ids(
|
||||
db: Session, role_ids: List[str], tenant_no: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""按给定角色 ID 返回启用角色名称(保序、去重)。"""
|
||||
ordered = list(dict.fromkeys([str(r).strip() for r in (role_ids or []) if r]))
|
||||
if not ordered:
|
||||
return []
|
||||
tenant = settings.DEFAULT_TENANT_NO
|
||||
tenant = (tenant_no or settings.DEFAULT_TENANT_NO).strip()
|
||||
rows = (
|
||||
db.query(SysRole.id, SysRole.role_name)
|
||||
.filter(
|
||||
@@ -313,25 +323,37 @@ def get_role_names_by_ids(db: Session, role_ids: List[str]) -> List[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _active_role_name_rows(db: Session, user_id: str) -> list:
|
||||
def _active_role_name_rows(
|
||||
db: Session, user_id: str, tenant_no: Optional[str] = None
|
||||
) -> list:
|
||||
"""查询用户当前有效的 sys_role.role_name 列表(含职位继承)。"""
|
||||
return get_role_names_by_ids(db, get_effective_role_ids(db, user_id))
|
||||
return get_role_names_by_ids(
|
||||
db,
|
||||
get_effective_role_ids(db, user_id, tenant_no=tenant_no),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
|
||||
|
||||
def user_has_active_roles(db: Session, user_id: str) -> bool:
|
||||
def user_has_active_roles(
|
||||
db: Session, user_id: str, tenant_no: Optional[str] = None
|
||||
) -> bool:
|
||||
"""用户是否至少拥有一个有效角色(直接或经职位继承)。"""
|
||||
return bool(get_effective_role_ids(db, user_id))
|
||||
return bool(get_effective_role_ids(db, user_id, tenant_no=tenant_no))
|
||||
|
||||
|
||||
def get_user_role_names(db: Session, user_id: str) -> list:
|
||||
def get_user_role_names(
|
||||
db: Session, user_id: str, tenant_no: Optional[str] = None
|
||||
) -> list:
|
||||
"""用户当前有效的 sys_role.role_name 列表(去重、保序)。"""
|
||||
names = _active_role_name_rows(db, user_id)
|
||||
names = _active_role_name_rows(db, user_id, tenant_no=tenant_no)
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def resolve_web_role_for_user(db: Session, user_id: str) -> str:
|
||||
def resolve_web_role_for_user(
|
||||
db: Session, user_id: str, tenant_no: Optional[str] = None
|
||||
) -> str:
|
||||
"""根据用户有效角色解析 Web 端 role(admin / super_admin / guest)。"""
|
||||
names = _active_role_name_rows(db, user_id)
|
||||
names = _active_role_name_rows(db, user_id, tenant_no=tenant_no)
|
||||
if not names:
|
||||
return NO_ROLE_WEB_ROLE
|
||||
mapped = [_map_role_name_to_web(n) for n in names]
|
||||
@@ -340,7 +362,9 @@ def resolve_web_role_for_user(db: Session, user_id: str) -> str:
|
||||
return "admin"
|
||||
|
||||
|
||||
def iter_user_menu_perm_codes(db: Session, user_id: str) -> Set[str]:
|
||||
def iter_user_menu_perm_codes(
|
||||
db: Session, user_id: str, tenant_no: Optional[str] = None
|
||||
) -> Set[str]:
|
||||
"""用户通过有效角色关联得到的菜单权限标识(sys_menu.perms,空则退回菜单 id)。
|
||||
|
||||
注:不过滤 visible——F 类型按钮等 visible=0 的菜单仍可通过角色授权生效;
|
||||
@@ -349,10 +373,10 @@ def iter_user_menu_perm_codes(db: Session, user_id: str) -> Set[str]:
|
||||
"""
|
||||
if not user_id:
|
||||
return set()
|
||||
role_ids = get_effective_role_ids(db, user_id)
|
||||
tenant = (tenant_no or settings.DEFAULT_TENANT_NO).strip()
|
||||
role_ids = get_effective_role_ids(db, user_id, tenant_no=tenant)
|
||||
if not role_ids:
|
||||
return set()
|
||||
tenant = settings.DEFAULT_TENANT_NO
|
||||
try:
|
||||
q = (
|
||||
db.query(SysMenu.perms, SysMenu.id)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+53307
-19619
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,11 @@
|
||||
# Graph Report - app (2026-10-10)
|
||||
# Graph Report - app (2026-10-22)
|
||||
|
||||
## Corpus Check
|
||||
- Large corpus: 291 files · ~344,645 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
- Large corpus: 312 files · ~381,667 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
|
||||
## Summary
|
||||
- 5913 nodes · 32444 edges · 44 communities detected
|
||||
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 22160 edges (avg confidence: 0.6)
|
||||
- 6406 nodes · 35474 edges · 43 communities detected
|
||||
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 24253 edges (avg confidence: 0.6)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
@@ -46,152 +46,151 @@
|
||||
- [[_COMMUNITY_Community 34|Community 34]]
|
||||
- [[_COMMUNITY_Community 35|Community 35]]
|
||||
- [[_COMMUNITY_Community 36|Community 36]]
|
||||
- [[_COMMUNITY_Community 37|Community 37]]
|
||||
- [[_COMMUNITY_Community 39|Community 39]]
|
||||
- [[_COMMUNITY_Community 40|Community 40]]
|
||||
- [[_COMMUNITY_Community 41|Community 41]]
|
||||
- [[_COMMUNITY_Community 42|Community 42]]
|
||||
- [[_COMMUNITY_Community 43|Community 43]]
|
||||
- [[_COMMUNITY_Community 44|Community 44]]
|
||||
- [[_COMMUNITY_Community 45|Community 45]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `success_response()` - 820 edges
|
||||
2. `error_response()` - 690 edges
|
||||
3. `SysUser` - 664 edges
|
||||
4. `BizQcTaskResult` - 378 edges
|
||||
5. `BizProductionMaterial` - 362 edges
|
||||
6. `BizProductionMaterialUsage` - 355 edges
|
||||
7. `BizQcMaterialUsage` - 353 edges
|
||||
8. `BizQcMaterial` - 343 edges
|
||||
9. `BizProductionMaterialStockBatch` - 342 edges
|
||||
10. `BizMaterialUsage` - 339 edges
|
||||
1. `success_response()` - 834 edges
|
||||
2. `SysUser` - 736 edges
|
||||
3. `error_response()` - 703 edges
|
||||
4. `BizQcTaskResult` - 388 edges
|
||||
5. `BizProductionMaterial` - 372 edges
|
||||
6. `BizProductionMaterialUsage` - 365 edges
|
||||
7. `BizQcMaterialUsage` - 363 edges
|
||||
8. `BizQcMaterial` - 353 edges
|
||||
9. `BizProductionMaterialStockBatch` - 352 edges
|
||||
10. `BizMaterialUsage` - 349 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `统一记录 API 请求/响应出入参的中间件` --uses--> `SysApiRequestLog` [INFERRED]
|
||||
core/api_request_log_middleware.py → models/models.py
|
||||
- `get_menu_list()` --calls--> `success_response()` [INFERRED]
|
||||
api/menu.py → core/response.py
|
||||
- `sync_lab_storage_seq_after_use()` --calls--> `generate_uuid()` [INFERRED]
|
||||
crud/sample_reception_lab_code.py → core/utils.py
|
||||
- `中试服务申请单管理 API(申请单、患者信息、输注方案、输注计划、出库联动)` --uses--> `SysUser` [INFERRED]
|
||||
api/pilot_application.py → models/models.py
|
||||
- `一揽子保存:申请单 + 患者信息 + 输注方案 + 输注计划(含细胞产品)` --uses--> `SysUser` [INFERRED]
|
||||
api/pilot_application.py → models/models.py
|
||||
- `菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms)` --uses--> `SysUser` [INFERRED]
|
||||
core/menu_guard.py → models/models.py
|
||||
- `运营端角色守卫:admin / super_admin 可访问。` --uses--> `SysUser` [INFERRED]
|
||||
core/menu_guard.py → models/models.py
|
||||
- `读取用户授权的菜单权限标识集合(与前端 meta.menuCode / perms 对齐)。` --uses--> `SysUser` [INFERRED]
|
||||
core/menu_guard.py → models/models.py
|
||||
- `接口菜单权限守卫(已统一放行 menuCode 校验,仅保留运营端登录)。` --uses--> `SysUser` [INFERRED]
|
||||
core/menu_guard.py → models/models.py
|
||||
|
||||
## Communities
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.01
|
||||
Nodes (1101): _assert_user_tenant_access(), attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu() (+1093 more)
|
||||
Nodes (1171): _assert_user_tenant_access(), attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu() (+1163 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.01
|
||||
Nodes (820): list_collection_regions(), BaseSettings, Config, secret_key_must_be_strong(), Settings, generate_uuid(), ApiRequestLogCRUD, delete() (+812 more)
|
||||
Nodes (830): validate_ymd(), BaseSettings, Config, secret_key_must_be_strong(), Settings, generate_uuid(), ApiRequestLogCRUD, delete() (+822 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.05
|
||||
Nodes (674): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+666 more)
|
||||
Cohesion: 0.01
|
||||
Nodes (730): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+722 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.01
|
||||
Nodes (581): GarmentBatchDeleteBody, GarmentBatchDiscardBody, GarmentCreateBody, GarmentDiscardBody, GarmentUpdateBody, SterilizeLogBatchBody, SterilizeLogCreateBody, SterilizeLogUpdateBody (+573 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (754): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+746 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.02
|
||||
Nodes (423): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+415 more)
|
||||
Cohesion: 0.01
|
||||
Nodes (402): _normalize_date_bound(), assert_protocol_sensitive_fields(), _attach_handler_name(), BatchRefundFinanceSubmitBody, create_collection_protocol(), delete_collection_protocol(), get_collection_protocol_detail(), _get_current_user_id() (+394 more)
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.02
|
||||
Nodes (261): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), lookup_attachments_by_urls(), batch_review() (+253 more)
|
||||
Cohesion: 0.01
|
||||
Nodes (390): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), _load_lineage_qc_for_nodes(), batch_review() (+382 more)
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.02
|
||||
Nodes (243): _normalize_date_bound(), _persist_protocol_sub_resources(), format_date(), generate_id(), optional_date(), parse_date(), 将字符串 / date / datetime 转为 date 或 None, camel_to_snake() (+235 more)
|
||||
Nodes (285): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+277 more)
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.02
|
||||
Nodes (208): list_ethnics_for_select(), _create_insurance_company(), _create_insurance_company_invoice(), _create_insurance_project_price(), delete_insurance_company(), delete_insurance_company_invoice(), delete_insurance_project_price(), _fmt_dt() (+200 more)
|
||||
Nodes (135): 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。, 服务号网页授权 code 绑定当前用户(需 Bearer)。, 微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, web_qr_confirm(), web_qr_scanned(), 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线) (+127 more)
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.02
|
||||
Nodes (168): mp_bind(), mp_bind_status(), 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。, 服务号网页授权 code 绑定当前用户(需 Bearer)。, 微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, _resolve_user_id() (+160 more)
|
||||
Nodes (181): _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes(), create_cell_product() (+173 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.03
|
||||
Nodes (196): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+188 more)
|
||||
Nodes (138): list_ethnics_for_select(), apply_data_scope(), _get_child_dept_ids(), _resolve_user_role_scopes(), list_ethnics(), 民族码表 CRUD(全局 sys_ethnic), _row_to_dict(), create_leave() (+130 more)
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 0.02
|
||||
Nodes (172): api_save_application(), get_protocol_nos_used_in_protocols(), _fmt_dt(), get_handover_by_schedule(), get_outbound_handover(), get_outbound_handover_view(), _handover_row_to_dict(), _handover_view_dict() (+164 more)
|
||||
Cohesion: 0.03
|
||||
Nodes (109): _register_attachment_routes(), _register_freight_routes(), _register_payment_routes(), _register_presign_routes(), _register_protocol_routes(), _collect_descendants(), _current_user(), _find_mobile_root() (+101 more)
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.04
|
||||
Nodes (116): _body_to_payload(), _get_current_user_id(), get_production_reception_detail(), _get_tenant_no(), post_production_reception(), ProductionReceptionBody, put_production_reception(), _resolve_reception_id() (+108 more)
|
||||
Nodes (97): get_customer_service_dict(), _get_tenant_no(), _get_current_user_id(), get_sample_insurance(), get_sample_storage_cert(), _get_tenant_no(), put_sample_insurance(), put_sample_storage_cert() (+89 more)
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.03
|
||||
Nodes (114): build_void_type_label_map(), format_protocol_status_label(), resolve_void_type_label(), 登记步骤物料使用;关联领用记录时不扣减库存。调用方控制事务,本函数不 commit。, register_step_usage_from_requisition(), add_task_step(), _aggregate_production_tab_step_field(), assert_step_assignee_for_write() (+106 more)
|
||||
Cohesion: 0.06
|
||||
Nodes (103): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+95 more)
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 0.05
|
||||
Nodes (65): _collect_descendants(), _current_user(), _find_mobile_root(), list_my_mobile_menus(), _mobile_menu_icon(), _mobile_menu_icon_colors(), _user_menu_ids(), _jwt_user_payload() (+57 more)
|
||||
Nodes (99): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+91 more)
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.06
|
||||
Nodes (59): _save_sample_payments(), apply_fq_to_fgd_merge(), assert_sample_payment_structure_editable(), _build_list_summary_row(), _build_periods_array(), _compute_progress_for_payment(), confirm_receipt(), _filter_and_page_progress() (+51 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (78): get_designer_data(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, BaseHTTPMiddleware, ApiCryptoError, _b64url_decode(), _b64url_encode(), decrypt_json() (+70 more)
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.13
|
||||
Nodes (29): _assert_routes_exist(), collect_missing_route_ids(), create_set(), create_set_from_work_order(), create_tasks_from_set(), delete_set(), get_set(), _item_count_map() (+21 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (89): _save_sample_payments(), add_transfer(), apply_fq_to_fgd_merge(), _assert_locked_schedule_structure(), assert_sample_payment_structure_editable(), _build_list_summary_row(), _build_periods_array(), _compute_progress_for_payment() (+81 more)
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.08
|
||||
Nodes (61): add_transfer(), assert_presign_convertible(), confirm_receipt(), confirmed_deposit_amount(), confirmed_deposit_amount_map(), convert_to_protocol(), create_presign_order(), _dec() (+53 more)
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.09
|
||||
Nodes (50): complete_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products(), _format_yyyy_mm_dd(), _generate_application_no(), get_application() (+42 more)
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 0.12
|
||||
Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块 支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3) Args: out_trade_no: 商户订单号 total, 申请退款 Args: out_trade_no: 商户订单号 total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API) Args: out_trade_no: 商户订单号 tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.13
|
||||
Nodes (25): create_customer(), get_customer(), id_card_taken(), list_customers(), 客户 CRUD(biz_customer), _row_to_dict(), soft_delete_customer(), update_customer() (+17 more)
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 0.14
|
||||
Nodes (25): _assert_routes_exist(), create_set(), create_set_from_work_order(), create_tasks_from_set(), delete_set(), get_set(), _item_count_map(), _item_to_dict() (+17 more)
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.17
|
||||
Nodes (23): apply_overtime_kit_hours(), _build_rest_day_segments(), _build_workday_windows(), calc_overtime_hours(), _day_at_minutes(), _day_start_ms(), _extract_rules(), _format_ymd() (+15 more)
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.16
|
||||
Nodes (22): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+14 more)
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 0.16
|
||||
Nodes (20): batch_create_checkups(), create_checkup(), delete_actual_item(), delete_reserve_item(), ensure_checkup_for_patient(), get_checkup_by_application(), get_checkup_by_id(), list_actual_items() (+12 more)
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 0.21
|
||||
Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more)
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.21
|
||||
Nodes (17): create_protocol_refund_record(), delete_protocol_refund_record(), _get_current_user_id(), _get_tenant_no(), list_protocol_refund_records(), _protocol_accessible(), RefundRecordBody, RefundRecordUpdateBody (+9 more)
|
||||
### Community 24 - "Community 24"
|
||||
Cohesion: 0.19
|
||||
Nodes (17): apply_trip_kit_days(), calc_trip_leg_days(), _day_index(), _end_offset(), _extract_rules(), is_half_day_multiple(), _kit_trip_fields(), 出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐) (+9 more)
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.22
|
||||
Nodes (15): apply_trip_kit_days(), calc_trip_leg_days(), _day_index(), _end_offset(), _extract_rules(), is_half_day_multiple(), 出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐), 遍历 tripKit:行程未手改则覆盖 duration;出差天数未手改则覆盖为各行程时长之和。 (+7 more)
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 0.32
|
||||
Nodes (12): _attach_owner_names(), create_account(), _filtered_query(), get_account(), list_accounts(), list_options(), _owner_name_map(), OA 收款账户 CRUD(oa_receive_account) (+4 more)
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 0.24
|
||||
Nodes (9): 微信消息通知服务 支持微信小程序模板消息推送 使用方式: from app.core.wechat_message import Wech, 发送模板消息 Args: openid: 用户openid templ, send_delivery_notice(), send_delivery_warning(), send_order_status(), send_payment_success(), send_refund_result(), send_urge_notification() (+1 more)
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.29
|
||||
Nodes (11): get_detail(), _load_sample_context(), _row_to_dict(), upsert(), dumps_payload(), empty_detail(), loads_payload(), normalize_payload() (+3 more)
|
||||
|
||||
### Community 24 - "Community 24"
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 0.24
|
||||
Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 试剂变动记录 CRUD(biz_reagent_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减试剂库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 0.27
|
||||
Nodes (11): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), create_dept(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。 (+3 more)
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.24
|
||||
Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 0.31
|
||||
Nodes (10): create_account(), _filtered_query(), get_account(), list_accounts(), list_options(), OA 收款账户 CRUD(oa_receive_account), row_to_dict(), soft_delete_account() (+2 more)
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 0.29
|
||||
Nodes (2): BaseModel, SoftDeleteModel
|
||||
@@ -214,92 +213,86 @@ Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_de
|
||||
|
||||
### Community 33 - "Community 33"
|
||||
Cohesion: 0.67
|
||||
Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
|
||||
|
||||
### Community 34 - "Community 34"
|
||||
Cohesion: 0.67
|
||||
Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
### Community 34 - "Community 34"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
|
||||
|
||||
### Community 37 - "Community 37"
|
||||
### Community 36 - "Community 36"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
|
||||
|
||||
### Community 40 - "Community 40"
|
||||
### Community 39 - "Community 39"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送订单状态通知 Args: openid: 用户openid ord
|
||||
|
||||
### Community 41 - "Community 41"
|
||||
### Community 40 - "Community 40"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送支付成功通知 Args: openid: 用户openid ord
|
||||
|
||||
### Community 42 - "Community 42"
|
||||
### Community 41 - "Community 41"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送配送通知 Args: openid: 配送员openid task
|
||||
|
||||
### Community 43 - "Community 43"
|
||||
### Community 42 - "Community 42"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送配送时效预警 Args: openid: 配送员openid ta
|
||||
|
||||
### Community 44 - "Community 44"
|
||||
### Community 43 - "Community 43"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送退款结果通知 Args: openid: 用户openid ord
|
||||
|
||||
### Community 45 - "Community 45"
|
||||
### Community 44 - "Community 44"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送催单通知给配送员 Args: deliver_openid: 配送员openid
|
||||
|
||||
## Knowledge Gaps
|
||||
- **200 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+195 more)
|
||||
- **208 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `严格判断当前租户用户是否拥有指定移动市场权限码。`, `为移动市场 API 提供租户化、精确匹配的菜单权限依赖。` (+203 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Community 28`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 32`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 33`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
|
||||
- **Thin community `Community 33`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 34`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
|
||||
- **Thin community `Community 34`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 35`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
|
||||
- **Thin community `Community 35`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 36`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
|
||||
- **Thin community `Community 36`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 37`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
|
||||
- **Thin community `Community 39`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 40`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
|
||||
- **Thin community `Community 40`** (1 nodes): `发送支付成功通知 Args: openid: 用户openid ord`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 41`** (1 nodes): `发送支付成功通知 Args: openid: 用户openid ord`
|
||||
- **Thin community `Community 41`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 42`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
|
||||
- **Thin community `Community 42`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 43`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
|
||||
- **Thin community `Community 43`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 44`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 45`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid`
|
||||
- **Thin community `Community 44`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `SysUser` connect `Community 4` to `Community 0`, `Community 1`, `Community 2`, `Community 3`, `Community 5`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 18`, `Community 20`, `Community 24`, `Community 26`?**
|
||||
_High betweenness centrality (0.114) - this node is a cross-community bridge._
|
||||
- **Why does `OaEngineError` connect `Community 4` to `Community 1`?**
|
||||
_High betweenness centrality (0.040) - this node is a cross-community bridge._
|
||||
- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 19`, `Community 20`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 27`?**
|
||||
_High betweenness centrality (0.039) - this node is a cross-community bridge._
|
||||
- **Are the 1046 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
|
||||
_`str` has 1046 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 819 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
|
||||
_`success_response()` has 819 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 689 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
|
||||
_`error_response()` has 689 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 662 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤) data_scope 值: 1 = 全部数据 2 = 本部门数据 3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
|
||||
_`SysUser` has 662 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 21`, `Community 25`, `Community 27`?**
|
||||
_High betweenness centrality (0.109) - this node is a cross-community bridge._
|
||||
- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 4`, `Community 7`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 14`?**
|
||||
_High betweenness centrality (0.038) - this node is a cross-community bridge._
|
||||
- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 19`, `Community 21`, `Community 23`, `Community 25`, `Community 26`, `Community 27`?**
|
||||
_High betweenness centrality (0.025) - this node is a cross-community bridge._
|
||||
- **Are the 1181 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
|
||||
_`str` has 1181 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 833 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
|
||||
_`success_response()` has 833 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 734 inferred relationships involving `SysUser` (e.g. with `菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms)` and `运营端角色守卫:admin / super_admin 可访问。`) actually correct?**
|
||||
_`SysUser` has 734 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 702 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
|
||||
_`error_response()` has 702 INFERRED edges - model-reasoned connections that need verification._
|
||||
+65680
-24997
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/__init__.py": 1778578257.989357,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/main.py": 1791520627.5289237,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/main.py": 1791860609.7614543,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/tasks/__init__.py": 1778579841.437791,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/auth.py": 1786415793.4635684,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/auth.py": 1791692287.6777437,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/mobile_menu_guard.py": 1791642893.5237567,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/api_crypto.py": 1778578258.039314,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/login_crypto.py": 1782186069.0708613,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/config.py": 1790216731.3913553,
|
||||
@@ -14,16 +15,16 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/request_payload.py": 1778578258.04292,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/__init__.py": 1778578258.028834,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/response.py": 1779928224.7450857,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/menu_guard.py": 1785905319.9201946,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/menu_guard.py": 1791915790.8400602,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/db_collation.py": 1779890253.2250955,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/qc_material_amount_auth_constants.py": 1783919118.6811988,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/context.py": 1779242263.6923044,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/utils.py": 1780305790.0009735,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/protocol_status_display.py": 1783303208.7618413,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/protocol_status_display.py": 1792196253.8018239,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/storage.py": 1786527936.515132,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/order_status_compat.py": 1778578258.0426462,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/exceptions.py": 1778578258.0413945,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/data_scope.py": 1785663691.2518938,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/data_scope.py": 1791643686.8535404,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/admin_request_json.py": 1778578258.038963,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/api_crypto_middleware.py": 1778581508.2309763,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/wechat_message.py": 1778578258.0458734,
|
||||
@@ -33,11 +34,11 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/material.py": 1782894693.6389399,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/production_material.py": 1782404882.9262497,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/qc_param.py": 1782379593.174925,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1791603961.4959393,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1792576366.7658954,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/cleanroom_garment.py": 1781347080.6165943,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/cn_holiday.py": 1786373798.242758,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/__init__.py": 1791386246.9181905,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/research_donation.py": 1791432129.1135461,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/research_donation.py": 1792199580.930273,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/qc_material.py": 1787116012.3385794,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/base.py": 1778578258.0821667,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/clinical_payment.py": 1779834956.9588954,
|
||||
@@ -63,6 +64,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/inquiry.py": 1778578258.095357,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/order.py": 1778578258.0960286,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/__init__.py": 1781502992.2721803,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/market_mobile.py": 1792578205.3646982,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/order_adjust.py": 1778578258.0963318,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/cell_product.py": 1782711015.11309,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/menu.py": 1778578258.0957444,
|
||||
@@ -83,6 +85,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/equipment.py": 1782717312.9816003,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task.py": 1786958454.8959644,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/process_route.py": 1788231203.61016,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sf_express_settlement.py": 1792580020.6752806,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_code.py": 1782709076.5173395,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task_result.py": 1785289887.2915215,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_review.py": 1785314186.7271576,
|
||||
@@ -92,7 +95,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_result.py": 1779255909.8554008,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_inspection_item_template.py": 1786941802.105201,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_stock_batch.py": 1782404955.550011,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1786616084.8158407,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1791693092.544366,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_work_order.py": 1789366011.0738122,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/material.py": 1781522283.6438622,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/dict_type.py": 1779265621.5212462,
|
||||
@@ -113,14 +116,14 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_production_reception.py": 1788159570.8985534,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/reagent.py": 1779512669.9847593,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_inbound_lab_storage_sort.py": 1786959444.2857325,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1791603944.4774525,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1792199580.9285016,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/cleanroom_garment.py": 1782264452.4901433,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/temp_qc_target.py": 1787193516.0555077,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_usage.py": 1783428516.0973594,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/biz_no_generator.py": 1779882385.4674172,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/presign_order.py": 1791606023.4116733,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/biz_no_generator.py": 1791892839.5616264,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/presign_order.py": 1792223982.1589236,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_month_template.py": 1787115871.8303692,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_info_quick_register.py": 1791299727.9118006,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_info_quick_register.py": 1792377880.3840506,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_confirm.py": 1779828818.7646794,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/reagent_stock_log.py": 1779515479.6599197,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_schedule_mgmt.py": 1781604486.4315972,
|
||||
@@ -130,6 +133,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/equipment_snapshot.py": 1781524050.998141,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_entry_sms_notification.py": 1781063498.05542,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/express_company.py": 1781402091.5987046,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/customer_service_dict.py": 1791859621.720187,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_application.py": 1781079895.8304482,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_lab_test_item.py": 1779265673.3888667,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pre_transit_sample.py": 1783921714.0966582,
|
||||
@@ -149,13 +153,14 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_region.py": 1791436619.5126424,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_delivery.py": 1779935673.6808827,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_item.py": 1779007909.2933013,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_storage_cert.py": 1791860094.4119813,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/site.py": 1781401290.6845422,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/api_request_log.py": 1778578258.0614743,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/dict_data.py": 1782893595.9312406,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/research_donation.py": 1791517650.4311852,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/research_donation.py": 1792199678.6192756,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_stock_in_no.py": 1787114586.3799527,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/ethnic.py": 1779265849.547566,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_freight.py": 1791468766.6474462,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_freight.py": 1792579247.5842276,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_formula.py": 1781344175.5871844,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_checkup.py": 1779935785.233911,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/env_detect_item.py": 1781145689.9525824,
|
||||
@@ -168,11 +173,12 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_material.py": 1781612877.0137436,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/process.py": 1787621642.2171764,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_service.py": 1781599314.0124536,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/customer.py": 1791454064.7945733,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/customer.py": 1791648050.9604611,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/workflow.py": 1779265993.677624,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/oa_receive_account.py": 1785390044.8154407,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_customer_service.py": 1791860094.4207802,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/oa_receive_account.py": 1792579491.9561448,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_sms_notification.py": 1781063495.0317714,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception.py": 1788233133.5793514,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception.py": 1791777806.124801,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/consumable_pack.py": 1779265715.416005,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_inspection_batch.py": 1786011049.96145,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_prep.py": 1781514953.3551314,
|
||||
@@ -181,75 +187,81 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_storage_cert_qc.py": 1791520567.195576,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_handover.py": 1780455574.0993364,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_facility.py": 1786945250.4117062,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/oa_instance_draft.py": 1786527123.364954,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/oa_instance_draft.py": 1792462713.458171,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_lab_code.py": 1783308340.5854337,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/cell_sample_type.py": 1781747645.1762104,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_outbound_status_sync.py": 1781595840.6955194,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/consumable_stock_log.py": 1779515477.284446,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/dept.py": 1785145511.4446244,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/base.py": 1778578258.062207,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_donation_convert.py": 1779161468.6582925,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_sample_collection.py": 1791187386.0011332,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_sample_payment.py": 1791615497.9605725,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_donation_convert.py": 1791786447.2823255,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_insurance.py": 1791860094.3744645,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_sample_collection.py": 1791860264.2204025,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_sample_payment.py": 1792202468.4610422,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_item_param.py": 1782380067.962216,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/leave.py": 1779265839.500242,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/output_type.py": 1779265703.7363207,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/exhibition_appointment.py": 1778801075.5211918,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/clinical_payment.py": 1782713876.5518267,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/clinical_payment.py": 1791799767.089836,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/storage_release_review.py": 1787110947.8175044,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/outbound_release_review.py": 1785303263.2759058,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/public.py": 1789710686.7963963,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/public.py": 1791692287.6797194,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/material.py": 1782894693.6582327,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/production_material.py": 1782404982.2474263,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/mini_menu.py": 1789979771.8476667,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/mini_menu.py": 1791861013.3286748,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/qc.py": 1787193588.7157009,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/market.py": 1781522283.6823432,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_production_reception.py": 1788159565.164049,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/collection_protocol.py": 1791382887.7933187,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/collection_protocol.py": 1792199682.804543,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/cleanroom_garment.py": 1781347256.2326367,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/basic_info.py": 1791454077.2373106,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/presign_order.py": 1791606023.394863,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/presign_order.py": 1792079421.3688679,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_infusion_confirm.py": 1779657043.1389203,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_infusion_schedule_mgmt.py": 1779940938.18027,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/__init__.py": 1791520620.6934447,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/__init__.py": 1791860598.0884578,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/market_mobile.py": 1792578366.9390223,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/storage_entry_sms_notification.py": 1780325524.9170487,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/customer_service_dict.py": 1791859621.7209744,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_application.py": 1781079904.0516074,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pre_transit_sample.py": 1786938943.6929982,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/qc_sms_notification.py": 1780326875.7886882,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation_sample_collection.py": 1791187409.0427368,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/oa_approval.py": 1789977632.0231144,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_refund_record.py": 1790996724.7941585,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/oa_approval.py": 1792579495.6813116,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_refund_record.py": 1792197256.603858,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/operations.py": 1785213144.2881503,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1791504651.1573102,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1792580899.7326677,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_delivery.py": 1779655584.820777,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_cert_insurance.py": 1791912978.5275548,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/admin.py": 1790217215.444356,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/env_detect.py": 1786945487.513935,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1783920522.841086,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1792199703.3429806,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/ethnic.py": 1779266243.9511197,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_freight.py": 1791424761.175849,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_freight.py": 1792580031.1094093,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_checkup.py": 1779935751.2658868,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/mini_auth.py": 1789710909.065258,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_service.py": 1781599314.0221174,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/workflow.py": 1790217176.7514315,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_sms_notification.py": 1780325524.907156,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_reception.py": 1782719108.3741593,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_storage_cert_qc.py": 1791520619.236499,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_storage_cert_qc.py": 1791912978.5283322,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/insurance_company.py": 1781410837.9443986,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/production.py": 1788234236.5398064,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_collection.py": 1791299447.2139852,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_payment.py": 1791615498.0325954,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_collection.py": 1792377889.4818213,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_payment.py": 1791913783.0750406,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/leave.py": 1790217143.1910717,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/clinical_payment.py": 1781750841.074568,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1789972028.300875,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_info_list_enrich.py": 1791187353.991077,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_period_summary.py": 1791528484.4779947,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1792580527.7126133,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sf_express_settlement.py": 1792575921.313689,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_info_list_enrich.py": 1792376536.1772416,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_period_summary.py": 1791799261.7547023,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_datetime_normalize.py": 1792462649.0641665,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_overtime_hours.py": 1786373766.5113294,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_callback.py": 1790217147.1955774,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/qc_work_order_fee_summary.py": 1791517006.7566624,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/qc_work_order_fee_summary.py": 1792078734.3604057,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/storage_facility_code.py": 1785213139.8646047,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_person_select.py": 1785401156.2563787,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/qc_work_order_conclusion.py": 1785206750.2532814,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/party_customer_sync.py": 1791454146.9545324,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/party_customer_sync.py": 1791648050.9613898,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_dept_select.py": 1786000695.5043027,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mini_auth.py": 1789710434.6386433,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_publish.py": 1786001976.196836,
|
||||
@@ -258,36 +270,45 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wx_qr_session.py": 1789710006.0382662,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_transport_mode.py": 1791386325.9832487,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/cn_holiday.py": 1786373910.8580065,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_info_quick_register.py": 1791299380.0357544,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_info_quick_register.py": 1791860094.3744802,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_payment.py": 1792202405.459428,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_confirmation_date.py": 1785403063.141215,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/web_auth_roles.py": 1785909942.9479194,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/web_auth_roles.py": 1791692287.6755066,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_trip_days.py": 1785407394.0606225,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_payment_installment.py": 1781432208.0765035,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_bank_receipt.py": 1792140951.8290772,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_payment_installment.py": 1791921039.0872521,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_sample_list_summary.py": 1791187322.7863488,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_related_approval.py": 1787892816.3154016,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mp_message.py": 1786689383.1677845,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_leave_hours.py": 1785397787.0493405,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_transfer_status.py": 1791605518.9053025,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/payment_chase.py": 1791531453.7590458,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_transfer_status.py": 1791799767.0946853,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/payment_chase.py": 1791903365.3346775,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_refund_record.py": 1790996661.009547,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_sample.py": 1791712921.0372562,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_schedule_validate.py": 1791539753.4546175,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_info_progress.py": 1791187232.4075856,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_instance_comment.py": 1790052184.171669,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_progress.py": 1791176083.458991,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_instance_comment.py": 1792134136.443889,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_progress.py": 1792202352.3417196,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_resignation_kit_sync.py": 1785405020.285242,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mp_auth.py": 1786615330.0733685,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_support.py": 1792574711.417624,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_assignee.py": 1790665524.6969597,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_login_service.py": 1786615952.6840453,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_protocol.py": 1792392055.0493736,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_field_scope.py": 1791651783.1531875,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/customer_relations.py": 1791383150.5354238,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_receive_account.py": 1785390254.4121296,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_receive_account.py": 1792579495.1077259,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_presign.py": 1791713904.5883873,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_owner_fields.py": 1785401151.5200977,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_money_field.py": 1785380625.3526123,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_storage_cert_qc.py": 1791520419.7485287,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_pay_mode_switch.py": 1791504357.182062,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_pay_mode_switch.py": 1791922115.8003666,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_freight.py": 1792578248.775886,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_post_select.py": 1785394392.5716882,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_wechat_notify.py": 1789108034.959046,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_confirmation_kit_sync.py": 1785403667.4270058,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_fgd_convert.py": 1791525433.8399634,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_formula.py": 1785145511.513383,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_customer_service_register.py": 1791860136.2147195,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/ws/__init__.py": 1778578258.105995
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
#!/usr/bin/env python3
|
||||
"""采集协议单缴费模型 v1 → v2 迁移(生产专用,可重复执行)。
|
||||
|
||||
规则(与 docs/superpowers/specs/2026-10-15-protocol-save-auto-submit-and-v1-to-v2-migration-design.md 一致):
|
||||
|
||||
- 单样本 + 无退费:1:1 迁到 sample_payment / schedule / transfer,并置 payment_model_version=2
|
||||
- 多样本 + 无退费:默认只进人工清单;加 --multi-average 时按样本数平均分合同/应缴/转账
|
||||
- 有退费痕迹 / 无有效样本 / 无样本编码 / 无 v1 缴费主表:只进人工清单
|
||||
- 不物理删除 v1 payment / payment_detail
|
||||
|
||||
用法(在 backend 目录、已配置 .env):
|
||||
python -m scripts.migrate_protocol_payment_v1_to_v2 --list-only
|
||||
python -m scripts.migrate_protocol_payment_v1_to_v2 --dry-run
|
||||
python -m scripts.migrate_protocol_payment_v1_to_v2
|
||||
python -m scripts.migrate_protocol_payment_v1_to_v2 --multi-average
|
||||
python -m scripts.migrate_protocol_payment_v1_to_v2 --multi-average --include-open-payments
|
||||
python -m scripts.migrate_protocol_payment_v1_to_v2 --protocol-id <ID>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizProtocolPayment,
|
||||
BizProtocolPaymentDetail,
|
||||
BizProtocolPaymentSchedule,
|
||||
BizProtocolPaymentTransfer,
|
||||
BizProtocolSample,
|
||||
BizProtocolSamplePayment,
|
||||
)
|
||||
|
||||
REFUND_STATUSES = frozenset({"tfqrz", "ytf"})
|
||||
OPEN_STATUSES = frozenset({"dfj", "jfqrz"})
|
||||
VOID_SAMPLE_STATUS = "yzf"
|
||||
CENTS = Decimal("0.01")
|
||||
|
||||
|
||||
def _dec(value: Any) -> Optional[Decimal]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _money(value: Any) -> Optional[Decimal]:
|
||||
d = _dec(value)
|
||||
if d is None:
|
||||
return None
|
||||
return d.quantize(CENTS, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def split_amount_across(total: Optional[Decimal], n: int) -> List[Optional[Decimal]]:
|
||||
"""金额按分均分,余数落在最后一项;total 为空则返回全 None。"""
|
||||
if n <= 0:
|
||||
raise ValueError("n must be positive")
|
||||
if total is None:
|
||||
return [None] * n
|
||||
cents = int((total.quantize(CENTS, rounding=ROUND_HALF_UP) * 100).to_integral_value(rounding=ROUND_DOWN))
|
||||
base, rem = divmod(cents, n)
|
||||
out: List[Optional[Decimal]] = []
|
||||
for i in range(n):
|
||||
c = base + (rem if i == n - 1 else 0)
|
||||
out.append((Decimal(c) / Decimal(100)).quantize(CENTS))
|
||||
return out
|
||||
|
||||
|
||||
def detail_has_refund(d: BizProtocolPaymentDetail) -> bool:
|
||||
status = (d.pay_status or "").strip()
|
||||
if status in REFUND_STATUSES:
|
||||
return True
|
||||
amt = _dec(d.refund_amount)
|
||||
if amt is not None and amt > 0:
|
||||
return True
|
||||
for attr in (
|
||||
"refund_attachments",
|
||||
"refund_remark",
|
||||
"refund_finance_confirm_attachments",
|
||||
"refund_finance_remark",
|
||||
):
|
||||
if str(getattr(d, attr, None) or "").strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_active_sample(s: BizProtocolSample) -> bool:
|
||||
if int(s.del_flag or 0) != 0:
|
||||
return False
|
||||
status = (s.sample_status or "").strip()
|
||||
if status == VOID_SAMPLE_STATUS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _schedule_no_of(d: BizProtocolPaymentDetail, *, pay_mode: str) -> int:
|
||||
if (pay_mode or "").strip() != "fq":
|
||||
return 1
|
||||
no = int(d.installment_no or 0)
|
||||
return no if no > 0 else 1
|
||||
|
||||
|
||||
def _append_remark(existing: Optional[str], extra: str) -> str:
|
||||
base = (existing or "").strip()
|
||||
if not base:
|
||||
return extra[:512]
|
||||
joined = f"{base} | {extra}"
|
||||
return joined[:512]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProtocolCandidate:
|
||||
protocol: BizCollectionProtocol
|
||||
samples: List[BizProtocolSample]
|
||||
payment: Optional[BizProtocolPayment]
|
||||
details: List[BizProtocolPaymentDetail]
|
||||
skip_reason: Optional[str] = None
|
||||
has_open_payment: bool = False
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return len(self.samples)
|
||||
|
||||
@property
|
||||
def protocol_id(self) -> str:
|
||||
return str(self.protocol.id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrateStats:
|
||||
scanned: int = 0
|
||||
migrated: int = 0
|
||||
skipped_already_v2: int = 0
|
||||
listed: List[Dict[str, Any]] = field(default_factory=list)
|
||||
errors: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def load_v1_protocols(
|
||||
db,
|
||||
*,
|
||||
protocol_id: Optional[str] = None,
|
||||
) -> List[BizCollectionProtocol]:
|
||||
q = db.query(BizCollectionProtocol).filter(
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
BizCollectionProtocol.payment_model_version == 1,
|
||||
)
|
||||
if protocol_id:
|
||||
q = q.filter(BizCollectionProtocol.id == protocol_id)
|
||||
return q.order_by(BizCollectionProtocol.create_time.asc()).all()
|
||||
|
||||
|
||||
def has_v2_sample_payment(db, protocol_id: str) -> bool:
|
||||
row = (
|
||||
db.query(BizProtocolSamplePayment.id)
|
||||
.filter(
|
||||
BizProtocolSamplePayment.collection_protocol_id == protocol_id,
|
||||
BizProtocolSamplePayment.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
def build_candidate(db, protocol: BizCollectionProtocol) -> ProtocolCandidate:
|
||||
samples = (
|
||||
db.query(BizProtocolSample)
|
||||
.filter(
|
||||
BizProtocolSample.collection_protocol_id == protocol.id,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
)
|
||||
.order_by(BizProtocolSample.order_num.asc(), BizProtocolSample.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
active = [s for s in samples if _is_active_sample(s)]
|
||||
payment = (
|
||||
db.query(BizProtocolPayment)
|
||||
.filter(
|
||||
BizProtocolPayment.collection_protocol_id == protocol.id,
|
||||
BizProtocolPayment.del_flag == 0,
|
||||
)
|
||||
.order_by(BizProtocolPayment.create_time.asc())
|
||||
.first()
|
||||
)
|
||||
details: List[BizProtocolPaymentDetail] = []
|
||||
if payment:
|
||||
details = (
|
||||
db.query(BizProtocolPaymentDetail)
|
||||
.filter(
|
||||
BizProtocolPaymentDetail.payment_id == payment.id,
|
||||
BizProtocolPaymentDetail.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizProtocolPaymentDetail.installment_no.asc(),
|
||||
BizProtocolPaymentDetail.create_time.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
cand = ProtocolCandidate(
|
||||
protocol=protocol,
|
||||
samples=active,
|
||||
payment=payment,
|
||||
details=details,
|
||||
has_open_payment=any((d.pay_status or "").strip() in OPEN_STATUSES for d in details),
|
||||
)
|
||||
|
||||
if has_v2_sample_payment(db, protocol.id):
|
||||
cand.skip_reason = "already_has_v2"
|
||||
return cand
|
||||
if not payment:
|
||||
cand.skip_reason = "no_v1_payment"
|
||||
return cand
|
||||
if not active:
|
||||
cand.skip_reason = "no_sample"
|
||||
return cand
|
||||
if any(not str(s.sample_code or "").strip() for s in active):
|
||||
cand.skip_reason = "no_sample_code"
|
||||
return cand
|
||||
if any(detail_has_refund(d) for d in details):
|
||||
cand.skip_reason = "has_refund"
|
||||
return cand
|
||||
if len(active) != 1:
|
||||
cand.skip_reason = "multi_sample"
|
||||
return cand
|
||||
return cand
|
||||
|
||||
|
||||
def _list_row(cand: ProtocolCandidate) -> Dict[str, Any]:
|
||||
p = cand.protocol
|
||||
pay = cand.payment
|
||||
return {
|
||||
"protocol_id": p.id,
|
||||
"protocol_no": p.protocol_no,
|
||||
"tenant_no": p.tenant_no,
|
||||
"sample_count": cand.sample_count,
|
||||
"sample_codes": ",".join(str(s.sample_code or "") for s in cand.samples),
|
||||
"contract_amount": str(pay.contract_amount) if pay and pay.contract_amount is not None else "",
|
||||
"detail_count": len(cand.details),
|
||||
"has_open_payment": "1" if cand.has_open_payment else "0",
|
||||
"skip_reason": cand.skip_reason or "",
|
||||
}
|
||||
|
||||
|
||||
def _new_common(
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
create_dept_id: str,
|
||||
create_by: Optional[str],
|
||||
create_time: Optional[datetime],
|
||||
order_num: Optional[int],
|
||||
remark: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
now = datetime.now()
|
||||
return {
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": order_num,
|
||||
"remark": remark,
|
||||
"create_by": create_by or user_id,
|
||||
"create_dept_id": (create_dept_id or "").strip(),
|
||||
"create_time": create_time or now,
|
||||
"update_by": user_id,
|
||||
"update_time": now,
|
||||
"del_flag": 0,
|
||||
}
|
||||
|
||||
|
||||
def _group_details(
|
||||
details: Sequence[BizProtocolPaymentDetail], *, pay_mode: str
|
||||
) -> Dict[int, List[BizProtocolPaymentDetail]]:
|
||||
groups: Dict[int, List[BizProtocolPaymentDetail]] = defaultdict(list)
|
||||
for d in details:
|
||||
groups[_schedule_no_of(d, pay_mode=pay_mode)].append(d)
|
||||
return dict(sorted(groups.items(), key=lambda x: x[0]))
|
||||
|
||||
|
||||
def _due_from_group(
|
||||
rows: Sequence[BizProtocolPaymentDetail],
|
||||
*,
|
||||
payment: BizProtocolPayment,
|
||||
schedule_no: int,
|
||||
) -> Tuple[Optional[Decimal], Optional[str]]:
|
||||
due_amount = None
|
||||
due_date = None
|
||||
for d in rows:
|
||||
if due_amount is None and d.due_amount is not None:
|
||||
due_amount = _money(d.due_amount)
|
||||
if not due_date and str(d.installment_due_date or "").strip():
|
||||
due_date = str(d.installment_due_date).strip()
|
||||
if due_amount is None and schedule_no == 1 and (payment.pay_mode or "").strip() != "fq":
|
||||
due_amount = _money(payment.contract_amount)
|
||||
if not due_date and (payment.pay_mode or "").strip() != "fq":
|
||||
due_date = (payment.lump_sum_deadline or "").strip() or None
|
||||
return due_amount, due_date
|
||||
|
||||
|
||||
def migrate_single_sample(
|
||||
db,
|
||||
cand: ProtocolCandidate,
|
||||
*,
|
||||
user_id: Optional[str],
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
assert cand.payment is not None
|
||||
assert len(cand.samples) == 1
|
||||
sample = cand.samples[0]
|
||||
payment = cand.payment
|
||||
protocol = cand.protocol
|
||||
pay_mode = (payment.pay_mode or "dj").strip() or "dj"
|
||||
common = _new_common(
|
||||
tenant_no=protocol.tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
|
||||
create_by=payment.create_by,
|
||||
create_time=payment.create_time,
|
||||
order_num=payment.order_num,
|
||||
remark=payment.remark,
|
||||
)
|
||||
sp_id = generate_uuid()
|
||||
sp = BizProtocolSamplePayment(
|
||||
id=sp_id,
|
||||
collection_protocol_id=protocol.id,
|
||||
protocol_sample_id=sample.id,
|
||||
sample_code=str(sample.sample_code).strip(),
|
||||
pay_mode=pay_mode,
|
||||
contract_amount=payment.contract_amount,
|
||||
installment_count=payment.installment_count,
|
||||
installment_interval_months=payment.installment_interval_months,
|
||||
installment_start_date=payment.installment_start_date,
|
||||
installment_advance_days=payment.installment_advance_days,
|
||||
installment_ratios=payment.installment_ratios,
|
||||
lump_sum_deadline=payment.lump_sum_deadline,
|
||||
**common,
|
||||
)
|
||||
if not dry_run:
|
||||
db.add(sp)
|
||||
|
||||
groups = _group_details(cand.details, pay_mode=pay_mode)
|
||||
if not groups:
|
||||
# 无明细:仍建一期空 schedule,保证方案可编辑
|
||||
groups = {1: []}
|
||||
|
||||
for schedule_no, rows in groups.items():
|
||||
due_amount, due_date = _due_from_group(rows, payment=payment, schedule_no=schedule_no)
|
||||
sch_id = generate_uuid()
|
||||
sch = BizProtocolPaymentSchedule(
|
||||
id=sch_id,
|
||||
sample_payment_id=sp_id,
|
||||
collection_protocol_id=protocol.id,
|
||||
sample_code=str(sample.sample_code).strip(),
|
||||
schedule_no=schedule_no,
|
||||
due_amount=due_amount,
|
||||
due_date=due_date,
|
||||
schedule_status="open",
|
||||
**_new_common(
|
||||
tenant_no=protocol.tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
|
||||
create_by=payment.create_by,
|
||||
create_time=payment.create_time,
|
||||
order_num=schedule_no,
|
||||
remark=None,
|
||||
),
|
||||
)
|
||||
if not dry_run:
|
||||
db.add(sch)
|
||||
for d in rows:
|
||||
tr = BizProtocolPaymentTransfer(
|
||||
id=generate_uuid(),
|
||||
schedule_id=sch_id,
|
||||
sample_payment_id=sp_id,
|
||||
collection_protocol_id=protocol.id,
|
||||
sample_code=str(sample.sample_code).strip(),
|
||||
transfer_amount=d.transfer_amount,
|
||||
transfer_time=d.transfer_time,
|
||||
transfer_type=d.transfer_type,
|
||||
transfer_attachments=d.transfer_attachments,
|
||||
pay_status=(d.pay_status or "dfj").strip() or "dfj",
|
||||
finance_confirm_attachments=d.finance_confirm_attachments,
|
||||
finance_remark=d.finance_remark,
|
||||
**_new_common(
|
||||
tenant_no=protocol.tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=d.create_dept_id or payment.create_dept_id or "",
|
||||
create_by=d.create_by,
|
||||
create_time=d.create_time,
|
||||
order_num=d.order_num,
|
||||
remark=_append_remark(d.remark, f"migrated_from_v1_detail:{d.id}"),
|
||||
),
|
||||
)
|
||||
if not dry_run:
|
||||
db.add(tr)
|
||||
|
||||
if not dry_run:
|
||||
protocol.payment_model_version = 2
|
||||
protocol.update_by = user_id
|
||||
protocol.update_time = datetime.now()
|
||||
db.add(protocol)
|
||||
|
||||
|
||||
def migrate_multi_average(
|
||||
db,
|
||||
cand: ProtocolCandidate,
|
||||
*,
|
||||
user_id: Optional[str],
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""多样本:合同/应缴/转账金额均分;状态与凭证复制;备注标注来源明细。"""
|
||||
assert cand.payment is not None
|
||||
assert len(cand.samples) >= 2
|
||||
payment = cand.payment
|
||||
protocol = cand.protocol
|
||||
samples = cand.samples
|
||||
n = len(samples)
|
||||
pay_mode = (payment.pay_mode or "dj").strip() or "dj"
|
||||
contract_parts = split_amount_across(_money(payment.contract_amount), n)
|
||||
|
||||
groups = _group_details(cand.details, pay_mode=pay_mode)
|
||||
if not groups:
|
||||
groups = {1: []}
|
||||
|
||||
# 每期应缴:取组代表金额后均分
|
||||
due_parts_by_no: Dict[int, List[Optional[Decimal]]] = {}
|
||||
due_date_by_no: Dict[int, Optional[str]] = {}
|
||||
for schedule_no, rows in groups.items():
|
||||
due_amount, due_date = _due_from_group(rows, payment=payment, schedule_no=schedule_no)
|
||||
due_parts_by_no[schedule_no] = split_amount_across(due_amount, n)
|
||||
due_date_by_no[schedule_no] = due_date
|
||||
|
||||
for idx, sample in enumerate(samples):
|
||||
code = str(sample.sample_code).strip()
|
||||
sp_id = generate_uuid()
|
||||
sp = BizProtocolSamplePayment(
|
||||
id=sp_id,
|
||||
collection_protocol_id=protocol.id,
|
||||
protocol_sample_id=sample.id,
|
||||
sample_code=code,
|
||||
pay_mode=pay_mode,
|
||||
contract_amount=contract_parts[idx],
|
||||
installment_count=payment.installment_count,
|
||||
installment_interval_months=payment.installment_interval_months,
|
||||
installment_start_date=payment.installment_start_date,
|
||||
installment_advance_days=payment.installment_advance_days,
|
||||
installment_ratios=payment.installment_ratios,
|
||||
lump_sum_deadline=payment.lump_sum_deadline,
|
||||
**_new_common(
|
||||
tenant_no=protocol.tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
|
||||
create_by=payment.create_by,
|
||||
create_time=payment.create_time,
|
||||
order_num=idx + 1,
|
||||
remark=_append_remark(payment.remark, f"v1_multi_average:{n}"),
|
||||
),
|
||||
)
|
||||
if not dry_run:
|
||||
db.add(sp)
|
||||
|
||||
for schedule_no, rows in groups.items():
|
||||
sch_id = generate_uuid()
|
||||
sch = BizProtocolPaymentSchedule(
|
||||
id=sch_id,
|
||||
sample_payment_id=sp_id,
|
||||
collection_protocol_id=protocol.id,
|
||||
sample_code=code,
|
||||
schedule_no=schedule_no,
|
||||
due_amount=due_parts_by_no[schedule_no][idx],
|
||||
due_date=due_date_by_no[schedule_no],
|
||||
schedule_status="open",
|
||||
**_new_common(
|
||||
tenant_no=protocol.tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=payment.create_dept_id or protocol.create_dept_id or "",
|
||||
create_by=payment.create_by,
|
||||
create_time=payment.create_time,
|
||||
order_num=schedule_no,
|
||||
remark=None,
|
||||
),
|
||||
)
|
||||
if not dry_run:
|
||||
db.add(sch)
|
||||
for d in rows:
|
||||
amt_parts = split_amount_across(_money(d.transfer_amount), n)
|
||||
tr = BizProtocolPaymentTransfer(
|
||||
id=generate_uuid(),
|
||||
schedule_id=sch_id,
|
||||
sample_payment_id=sp_id,
|
||||
collection_protocol_id=protocol.id,
|
||||
sample_code=code,
|
||||
transfer_amount=amt_parts[idx],
|
||||
transfer_time=d.transfer_time,
|
||||
transfer_type=d.transfer_type,
|
||||
transfer_attachments=d.transfer_attachments,
|
||||
pay_status=(d.pay_status or "dfj").strip() or "dfj",
|
||||
finance_confirm_attachments=d.finance_confirm_attachments,
|
||||
finance_remark=d.finance_remark,
|
||||
**_new_common(
|
||||
tenant_no=protocol.tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=d.create_dept_id or payment.create_dept_id or "",
|
||||
create_by=d.create_by,
|
||||
create_time=d.create_time,
|
||||
order_num=d.order_num,
|
||||
remark=_append_remark(
|
||||
d.remark, f"拆自原明细:{d.id} ({idx + 1}/{n})"
|
||||
),
|
||||
),
|
||||
)
|
||||
if not dry_run:
|
||||
db.add(tr)
|
||||
|
||||
if not dry_run:
|
||||
protocol.payment_model_version = 2
|
||||
protocol.update_by = user_id
|
||||
protocol.update_time = datetime.now()
|
||||
db.add(protocol)
|
||||
|
||||
|
||||
def run_migration(
|
||||
db,
|
||||
*,
|
||||
dry_run: bool,
|
||||
list_only: bool,
|
||||
multi_average: bool,
|
||||
include_open_payments: bool,
|
||||
protocol_id: Optional[str],
|
||||
user_id: Optional[str],
|
||||
list_csv_path: Optional[str],
|
||||
) -> MigrateStats:
|
||||
stats = MigrateStats()
|
||||
protocols = load_v1_protocols(db, protocol_id=protocol_id)
|
||||
stats.scanned = len(protocols)
|
||||
|
||||
for protocol in protocols:
|
||||
if has_v2_sample_payment(db, protocol.id):
|
||||
stats.skipped_already_v2 += 1
|
||||
continue
|
||||
cand = build_candidate(db, protocol)
|
||||
|
||||
# 可自动迁:单样本无 skip;或多平均且 skip 仅为 multi_sample
|
||||
can_single = cand.skip_reason is None
|
||||
can_multi = (
|
||||
multi_average
|
||||
and cand.skip_reason == "multi_sample"
|
||||
and cand.payment is not None
|
||||
and cand.sample_count >= 2
|
||||
and all(str(s.sample_code or "").strip() for s in cand.samples)
|
||||
)
|
||||
if can_multi and cand.has_open_payment and not include_open_payments:
|
||||
cand.skip_reason = "multi_sample_open_payment"
|
||||
can_multi = False
|
||||
|
||||
if list_only or (not can_single and not can_multi):
|
||||
if cand.skip_reason and cand.skip_reason != "already_has_v2":
|
||||
stats.listed.append(_list_row(cand))
|
||||
continue
|
||||
|
||||
try:
|
||||
if can_single:
|
||||
migrate_single_sample(db, cand, user_id=user_id, dry_run=dry_run)
|
||||
else:
|
||||
# 临时清掉 skip,便于日志
|
||||
cand.skip_reason = None
|
||||
migrate_multi_average(db, cand, user_id=user_id, dry_run=dry_run)
|
||||
if not dry_run:
|
||||
db.commit()
|
||||
else:
|
||||
db.rollback()
|
||||
stats.migrated += 1
|
||||
print(
|
||||
f"[OK] {'DRY ' if dry_run else ''}migrated "
|
||||
f"{protocol.protocol_no} ({protocol.id}) samples={cand.sample_count}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
db.rollback()
|
||||
msg = f"[ERR] {protocol.protocol_no} ({protocol.id}): {exc}"
|
||||
stats.errors.append(msg)
|
||||
print(msg)
|
||||
|
||||
if stats.listed:
|
||||
print(f"\n=== 人工清单 {len(stats.listed)} 条 ===")
|
||||
writer = csv.DictWriter(
|
||||
sys.stdout,
|
||||
fieldnames=list(stats.listed[0].keys()),
|
||||
extrasaction="ignore",
|
||||
)
|
||||
writer.writeheader()
|
||||
writer.writerows(stats.listed)
|
||||
if list_csv_path:
|
||||
with open(list_csv_path, "w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=list(stats.listed[0].keys()))
|
||||
w.writeheader()
|
||||
w.writerows(stats.listed)
|
||||
print(f"\n清单已写入: {list_csv_path}")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate protocol payment v1 → v2")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只演练,不写库")
|
||||
parser.add_argument("--list-only", action="store_true", help="只输出人工清单,不迁移")
|
||||
parser.add_argument(
|
||||
"--multi-average",
|
||||
action="store_true",
|
||||
help="多样本无退费时按平均分自动迁移(默认:多样本仅进清单)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-open-payments",
|
||||
action="store_true",
|
||||
help="与 --multi-average 联用:允许 dfj/jfqrz 明细一并均分(有财务重复确认风险)",
|
||||
)
|
||||
parser.add_argument("--protocol-id", default=None, help="只处理指定协议 ID")
|
||||
parser.add_argument("--user-id", default="migrate_v1_v2", help="写入 update_by")
|
||||
parser.add_argument(
|
||||
"--list-csv",
|
||||
default=None,
|
||||
help="人工清单另存 CSV 路径",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stats = run_migration(
|
||||
db,
|
||||
dry_run=bool(args.dry_run),
|
||||
list_only=bool(args.list_only),
|
||||
multi_average=bool(args.multi_average),
|
||||
include_open_payments=bool(args.include_open_payments),
|
||||
protocol_id=(args.protocol_id or "").strip() or None,
|
||||
user_id=(args.user_id or "").strip() or None,
|
||||
list_csv_path=(args.list_csv or "").strip() or None,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
print(
|
||||
"\n=== 汇总 ===\n"
|
||||
f"scanned_v1={stats.scanned}\n"
|
||||
f"migrated={stats.migrated}\n"
|
||||
f"skipped_already_v2_data={stats.skipped_already_v2}\n"
|
||||
f"manual_list={len(stats.listed)}\n"
|
||||
f"errors={len(stats.errors)}"
|
||||
)
|
||||
return 1 if stats.errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sys_user 中密码明文等价于 111111 的账号统一改为 Zkxw@123。
|
||||
|
||||
因 bcrypt 每次盐不同,无法用 SQL WHERE password='111111' 命中,必须在应用侧校验。
|
||||
|
||||
连接方式(任选其一,优先顺序见下方):
|
||||
1) --database-url / 环境变量 DATABASE_URL
|
||||
2) --db-host + --db-user + --db-password + --db-name(可选 --db-port)
|
||||
3) --env-file / 环境变量 ENV_FILE(默认 backend/.env)
|
||||
|
||||
用法示例(在 backend 目录):
|
||||
# 显式指定库(推荐测试/生产,避免误连本地 .env)
|
||||
python scripts/reset_weak_password_111111.py \\
|
||||
--database-url 'mysql+pymysql://user:pass@host:3306/dbname' --dry-run
|
||||
|
||||
python scripts/reset_weak_password_111111.py \\
|
||||
--db-host 1.2.3.4 --db-user root --db-password 'xxx' --db-name zkxw --dry-run
|
||||
|
||||
# 使用指定环境文件
|
||||
python scripts/reset_weak_password_111111.py --env-file ./.env.test --dry-run
|
||||
|
||||
# 正式写入(去掉 --dry-run)
|
||||
python scripts/reset_weak_password_111111.py --database-url '...'
|
||||
|
||||
# 生产后端容器:脚本可放任意路径,容器内已有 DATABASE_URL 与 /app
|
||||
docker exec <backend容器> python /tmp/reset_weak_password_111111.py --dry-run
|
||||
|
||||
范围:del_flag=0(含停用账号 status!=1)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
WEAK_PASSWORD = "111111"
|
||||
NEW_PASSWORD = "Zkxw@123"
|
||||
|
||||
|
||||
def _candidate_dirs(start: Path):
|
||||
current = start.resolve()
|
||||
if current.is_file():
|
||||
current = current.parent
|
||||
while True:
|
||||
yield current
|
||||
if current.parent == current:
|
||||
break
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _has_app_package(directory: Path) -> bool:
|
||||
return (directory / "app" / "__init__.py").is_file()
|
||||
|
||||
|
||||
def _find_backend_root() -> Path:
|
||||
"""定位含 app 包的目录,不依赖脚本放在 backend/scripts 下。"""
|
||||
seen = set()
|
||||
starts = [Path(__file__), Path.cwd(), Path("/app")]
|
||||
for start in starts:
|
||||
if not start.exists():
|
||||
continue
|
||||
for directory in _candidate_dirs(start):
|
||||
key = str(directory)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if _has_app_package(directory):
|
||||
return directory
|
||||
raise SystemExit(
|
||||
"找不到后端 app 包。请在后端容器内执行,或在含 app 目录的环境中运行本脚本。"
|
||||
)
|
||||
|
||||
|
||||
def _mask_database_url(url: str) -> str:
|
||||
return re.sub(r":([^:@/]+)@", r":***@", url or "")
|
||||
|
||||
|
||||
def _build_url_from_parts(
|
||||
*,
|
||||
host: str,
|
||||
user: str,
|
||||
password: str,
|
||||
name: str,
|
||||
port: int,
|
||||
) -> str:
|
||||
return (
|
||||
f"mysql+pymysql://{quote_plus(user)}:{quote_plus(password)}"
|
||||
f"@{host}:{port}/{name}"
|
||||
)
|
||||
|
||||
|
||||
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="重置弱密码 111111 → Zkxw@123(支持变量指定目标库)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="只列出将修改的账号,不写库",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--env-file",
|
||||
default=None,
|
||||
help="环境文件路径(等同 ENV_FILE);未指定 database-url/分项时使用",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--database-url",
|
||||
default=None,
|
||||
help="完整 SQLAlchemy URL(等同 DATABASE_URL),优先于 env-file",
|
||||
)
|
||||
parser.add_argument("--db-host", default=None, help="MySQL 主机")
|
||||
parser.add_argument("--db-port", type=int, default=3306, help="MySQL 端口,默认 3306")
|
||||
parser.add_argument("--db-user", default=None, help="MySQL 用户")
|
||||
parser.add_argument("--db-password", default=None, help="MySQL 密码")
|
||||
parser.add_argument("--db-name", default=None, help="数据库名")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _resolve_connection(args: argparse.Namespace) -> tuple[str, str]:
|
||||
"""返回 (来源说明, DATABASE_URL)。在 import app 之前调用并写入 os.environ。"""
|
||||
parts = (args.db_host, args.db_user, args.db_password, args.db_name)
|
||||
if any(parts) and not all(p is not None and str(p) != "" for p in parts):
|
||||
raise SystemExit(
|
||||
"使用分项连接时须同时提供: --db-host --db-user --db-password --db-name"
|
||||
)
|
||||
|
||||
if all(p is not None and str(p) != "" for p in parts):
|
||||
url = _build_url_from_parts(
|
||||
host=args.db_host,
|
||||
user=args.db_user,
|
||||
password=args.db_password,
|
||||
name=args.db_name,
|
||||
port=args.db_port or 3306,
|
||||
)
|
||||
os.environ["DATABASE_URL"] = url
|
||||
return ("cli --db-*", url)
|
||||
|
||||
if args.database_url:
|
||||
url = args.database_url.strip()
|
||||
os.environ["DATABASE_URL"] = url
|
||||
return ("cli --database-url", url)
|
||||
|
||||
env_url = (os.environ.get("DATABASE_URL") or "").strip()
|
||||
if env_url:
|
||||
return ("env DATABASE_URL", env_url)
|
||||
|
||||
env_file = args.env_file or os.environ.get("ENV_FILE") or str(_find_backend_root() / ".env")
|
||||
env_file_path = Path(env_file).expanduser()
|
||||
if not env_file_path.is_absolute():
|
||||
env_file_path = (Path.cwd() / env_file_path).resolve()
|
||||
if not env_file_path.is_file():
|
||||
raise SystemExit(
|
||||
f"环境文件不存在: {env_file_path}\n"
|
||||
"请改用 --database-url 或 --db-host/--db-user/--db-password/--db-name"
|
||||
)
|
||||
os.environ["ENV_FILE"] = str(env_file_path)
|
||||
# DATABASE_URL 由 Settings 从该文件加载;此处仅记录来源
|
||||
return (f"env-file {env_file_path}", "")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(argv)
|
||||
source, early_url = _resolve_connection(args)
|
||||
|
||||
sys.path.insert(0, str(_find_backend_root()))
|
||||
|
||||
# 必须在设置 ENV_FILE / DATABASE_URL 之后再 import,否则 Settings 已冻结
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.models.models import SysUser
|
||||
|
||||
database_url = (os.environ.get("DATABASE_URL") or "").strip() or settings.DATABASE_URL
|
||||
if early_url:
|
||||
database_url = early_url
|
||||
|
||||
print(f"目标库来源: {source}")
|
||||
print(f"DATABASE_URL: {_mask_database_url(database_url)}")
|
||||
|
||||
engine = create_engine(
|
||||
database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
echo=False,
|
||||
)
|
||||
Session = sessionmaker(autocommit=False, autoflush=False, expire_on_commit=False, bind=engine)
|
||||
db = Session()
|
||||
try:
|
||||
rows = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.del_flag == 0)
|
||||
.order_by(SysUser.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
hits = []
|
||||
for u in rows:
|
||||
pwd = getattr(u, "password", None) or ""
|
||||
if verify_password(WEAK_PASSWORD, pwd):
|
||||
hits.append(u)
|
||||
|
||||
if not hits:
|
||||
print("未发现密码为 111111 的用户(del_flag=0)。")
|
||||
return 0
|
||||
|
||||
print(f"命中 {len(hits)} 个弱密码用户:")
|
||||
for u in hits:
|
||||
print(
|
||||
f" id={u.id} user_no={u.user_no!r} status={u.status!r} "
|
||||
f"tenant_no={u.tenant_no!r}"
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
print("dry-run:未写入数据库。去掉 --dry-run 后执行正式更新。")
|
||||
return 0
|
||||
|
||||
new_hash = get_password_hash(NEW_PASSWORD)
|
||||
now = datetime.now()
|
||||
for u in hits:
|
||||
u.password = new_hash
|
||||
u.update_time = now
|
||||
db.commit()
|
||||
print(f"已更新 {len(hits)} 个用户密码为强密码(明文 Zkxw@123)。请通知相关用户。")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
print(f"执行失败: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
db.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+279
-46
@@ -88,6 +88,7 @@ show_help() {
|
||||
- 读取 .env.test(非 .env.production)
|
||||
- 默认 127.0.0.1:10002(Nginx 反代);生产 start-prod.sh 使用 8111
|
||||
- 可与生产共用 venv,但 PID/日志文件独立,勿混用 start/stop 命令
|
||||
- stop/restart 除 PID 文件外,还会按端口清理本应用 uvicorn,避免僵尸进程占端口导致发版假成功
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -200,22 +201,253 @@ ensure_venv_and_runtime() {
|
||||
fi
|
||||
}
|
||||
|
||||
is_running() {
|
||||
# 读取 PID 文件中的进程号;必须存活且确认为本应用 uvicorn(防止脏 PID/PID=1 误杀)
|
||||
read_pid_file() {
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
return 1
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid="$(cat "$PID_FILE")"
|
||||
if [ -z "$pid" ]; then
|
||||
pid="$(tr -d '[:space:]' < "$PID_FILE" 2>/dev/null || true)"
|
||||
if [ -z "$pid" ] || ! [[ "$pid" =~ ^[0-9]+$ ]]; then
|
||||
return 0
|
||||
fi
|
||||
# 拒绝系统关键 PID 与明显非法值
|
||||
if [ "$pid" -le 1 ]; then
|
||||
return 0
|
||||
fi
|
||||
if ! ps -p "$pid" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if ! is_our_uvicorn_pid "$pid"; then
|
||||
return 0
|
||||
fi
|
||||
echo "$pid"
|
||||
}
|
||||
|
||||
# 进程命令行是否像本目录下的测试 uvicorn
|
||||
is_our_uvicorn_pid() {
|
||||
local pid="$1"
|
||||
local cmd=""
|
||||
if [ ! -r "/proc/$pid/cmdline" ]; then
|
||||
return 1
|
||||
fi
|
||||
cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)"
|
||||
case "$cmd" in
|
||||
*"${BASE_DIR}"*"uvicorn"*"${APP_MODULE}"*) return 0 ;;
|
||||
*"${VENV_DIR}/bin/python"*"uvicorn"*"${APP_MODULE}"*"--port ${PORT}"*) return 0 ;;
|
||||
*"${VENV_DIR}/bin/python"*"uvicorn"*"${APP_MODULE}"*"--port=${PORT}"*) return 0 ;;
|
||||
esac
|
||||
# 兼容无绝对路径、仅按 port + app 模块匹配(仍限制在本机测试端口)
|
||||
case "$cmd" in
|
||||
*"uvicorn"*"${APP_MODULE}"*"--port ${PORT}"*|*"uvicorn"*"${APP_MODULE}"*"--port=${PORT}"*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
if ps -p "$pid" >/dev/null 2>&1; then
|
||||
# 列出占用 PORT 的监听进程 PID
|
||||
list_listen_pids_on_port() {
|
||||
local pids=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
pids="$(ss -lptn "sport = :${PORT}" 2>/dev/null | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | sort -u)"
|
||||
fi
|
||||
if [ -z "$pids" ] && command -v lsof >/dev/null 2>&1; then
|
||||
pids="$(lsof -tiTCP:"${PORT}" -sTCP:LISTEN 2>/dev/null | sort -u || true)"
|
||||
fi
|
||||
if [ -z "$pids" ] && command -v fuser >/dev/null 2>&1; then
|
||||
pids="$(fuser "${PORT}/tcp" 2>/dev/null | tr -s '[:space:]' '\n' | grep -E '^[0-9]+$' | sort -u || true)"
|
||||
fi
|
||||
echo "$pids"
|
||||
}
|
||||
|
||||
# 收集应停止的 PID:PID 文件 + 占用本端口的本应用进程
|
||||
collect_managed_pids() {
|
||||
local pid p seen=""
|
||||
pid="$(read_pid_file || true)"
|
||||
if [ -n "${pid:-}" ]; then
|
||||
echo "$pid"
|
||||
seen=" $pid "
|
||||
fi
|
||||
for p in $(list_listen_pids_on_port); do
|
||||
if [ -z "$p" ]; then
|
||||
continue
|
||||
fi
|
||||
case "$seen" in
|
||||
*" $p "*) continue ;;
|
||||
esac
|
||||
if is_our_uvicorn_pid "$p"; then
|
||||
echo "$p"
|
||||
seen="${seen}${p} "
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
port_is_in_use() {
|
||||
local p
|
||||
p="$(list_listen_pids_on_port | head -n 1)"
|
||||
[ -n "$p" ]
|
||||
}
|
||||
|
||||
wait_port_free() {
|
||||
local i
|
||||
for i in $(seq 1 20); do
|
||||
if ! port_is_in_use; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_health_ok() {
|
||||
local i url="http://127.0.0.1:${PORT}/health"
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS --connect-timeout 1 --max-time 2 "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
is_running() {
|
||||
local pid
|
||||
pid="$(read_pid_file || true)"
|
||||
if [ -n "${pid:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
# PID 文件失效时,仍以「本端口上的本应用进程」为准,避免误判已停止
|
||||
for pid in $(list_listen_pids_on_port); do
|
||||
if is_our_uvicorn_pid "$pid"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 仅扩展「已确认属于本应用」的进程树;禁止对 PID<=1 或未校验进程做树展开
|
||||
expand_our_pid_tree() {
|
||||
local pid="$1"
|
||||
local c
|
||||
if [ -z "$pid" ] || [ "$pid" -le 1 ]; then
|
||||
return 0
|
||||
fi
|
||||
if ! ps -p "$pid" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if ! is_our_uvicorn_pid "$pid"; then
|
||||
return 0
|
||||
fi
|
||||
echo "$pid"
|
||||
for c in $(pgrep -P "$pid" 2>/dev/null || true); do
|
||||
# 子进程可能是纯 python worker,命令行仍含 uvicorn/app.main;否则跳过
|
||||
if [ -n "$c" ] && [ "$c" -gt 1 ] && is_our_uvicorn_pid "$c"; then
|
||||
expand_our_pid_tree "$c"
|
||||
elif [ -n "$c" ] && [ "$c" -gt 1 ] && ps -p "$c" >/dev/null 2>&1; then
|
||||
# worker 偶发 cmdline 不含完整模块名时:仅当父进程已是本应用才收其子进程
|
||||
echo "$c"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
kill_pids_graceful_then_force() {
|
||||
local pid
|
||||
local any=0
|
||||
local all_pids=""
|
||||
local p
|
||||
for pid in "$@"; do
|
||||
if [ -z "$pid" ] || [ "$pid" -le 1 ]; then
|
||||
continue
|
||||
fi
|
||||
for p in $(expand_our_pid_tree "$pid"); do
|
||||
if [ -n "$p" ] && [ "$p" -gt 1 ]; then
|
||||
all_pids="${all_pids}${p} "
|
||||
fi
|
||||
done
|
||||
done
|
||||
# shellcheck disable=SC2086
|
||||
set -- $all_pids
|
||||
|
||||
for pid in "$@"; do
|
||||
if [ -z "$pid" ] || [ "$pid" -le 1 ]; then
|
||||
continue
|
||||
fi
|
||||
if ! ps -p "$pid" >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
any=1
|
||||
log_info "正在停止测试服务 (PID: $pid)"
|
||||
kill -TERM "$pid" >/dev/null 2>&1 || true
|
||||
done
|
||||
if [ "$any" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
local i left
|
||||
for i in $(seq 1 10); do
|
||||
left=0
|
||||
for pid in "$@"; do
|
||||
if [ -n "$pid" ] && [ "$pid" -gt 1 ] && ps -p "$pid" >/dev/null 2>&1; then
|
||||
left=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$left" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
for pid in "$@"; do
|
||||
if [ -n "$pid" ] && [ "$pid" -gt 1 ] && ps -p "$pid" >/dev/null 2>&1; then
|
||||
log_warn "优雅停止超时,执行强制停止 (PID: $pid)"
|
||||
kill -9 "$pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 停止本应用在测试端口上的全部实例(含 PID 文件失效的僵尸进程)
|
||||
stop_server() {
|
||||
local pids
|
||||
pids="$(collect_managed_pids | sort -u | tr '\n' ' ')"
|
||||
# shellcheck disable=SC2086
|
||||
set -- $pids
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
# 端口仍被非本应用占用时给出明确提示
|
||||
if port_is_in_use; then
|
||||
log_warn "测试服务未由本脚本托管,但端口 ${PORT} 仍被占用: $(list_listen_pids_on_port | tr '\n' ' ')"
|
||||
log_warn "请人工确认后再启动,避免绑端口失败"
|
||||
else
|
||||
log_warn "测试服务未运行"
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
kill_pids_graceful_then_force "$@"
|
||||
|
||||
# 若仍有本应用占端口,再扫一遍强杀
|
||||
pids="$(collect_managed_pids | sort -u | tr '\n' ' ')"
|
||||
# shellcheck disable=SC2086
|
||||
set -- $pids
|
||||
if [ "$#" -gt 0 ]; then
|
||||
log_warn "仍检测到本应用进程,执行二次强制停止"
|
||||
for pid in "$@"; do
|
||||
kill -9 "$pid" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
|
||||
rm -f "$PID_FILE"
|
||||
|
||||
if ! wait_port_free; then
|
||||
log_error "端口 ${PORT} 仍被占用,无法确认已停止: $(list_listen_pids_on_port | tr '\n' ' ')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "测试服务已停止"
|
||||
return 0
|
||||
}
|
||||
|
||||
start_server() {
|
||||
@@ -223,17 +455,30 @@ start_server() {
|
||||
ensure_venv_and_runtime
|
||||
ensure_dirs
|
||||
|
||||
# 已有本应用在跑(PID 文件或端口探测)则直接返回
|
||||
if is_running; then
|
||||
local running_pid
|
||||
running_pid="$(cat "$PID_FILE")"
|
||||
log_warn "测试服务已在运行 (PID: $running_pid)"
|
||||
running_pid="$(read_pid_file || true)"
|
||||
if [ -z "${running_pid:-}" ]; then
|
||||
running_pid="$(collect_managed_pids | head -n 1)"
|
||||
fi
|
||||
log_warn "测试服务已在运行 (PID: ${running_pid:-unknown})"
|
||||
# 回写 PID 文件,避免后续 stop 找不到
|
||||
if [ -n "${running_pid:-}" ]; then
|
||||
echo "$running_pid" > "$PID_FILE"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
rm -f "$PID_FILE"
|
||||
# 端口被占用但不是本应用:拒绝启动,避免静默失败后健康检查打到别人的服务
|
||||
if port_is_in_use; then
|
||||
log_error "端口 ${PORT} 已被占用且无法识别为本测试服务: $(list_listen_pids_on_port | tr '\n' ' ')"
|
||||
log_error "请先释放端口后再 start / restart"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$PID_FILE"
|
||||
|
||||
log_info "启动测试环境服务..."
|
||||
log_info "监听地址: $HOST:$PORT"
|
||||
log_info "工作进程: $WORKERS"
|
||||
@@ -255,52 +500,40 @@ start_server() {
|
||||
>> "$LOG_FILE" 2>&1 &
|
||||
|
||||
echo $! > "$PID_FILE"
|
||||
sleep 2
|
||||
|
||||
if is_running; then
|
||||
log_info "测试服务启动成功 (PID: $(cat "$PID_FILE"))"
|
||||
log_info "健康检查: http://127.0.0.1:$PORT/health"
|
||||
log_info "API 文档: http://127.0.0.1:$PORT/docs"
|
||||
else
|
||||
log_error "测试服务启动失败,请检查日志: $LOG_FILE"
|
||||
rm -f "$PID_FILE"
|
||||
if ! wait_health_ok; then
|
||||
log_error "测试服务启动失败(健康检查超时),请检查日志: $LOG_FILE"
|
||||
# 启动失败时尽量清掉半拉子进程,避免留下占端口僵尸
|
||||
stop_server || true
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
stop_server() {
|
||||
if ! is_running; then
|
||||
log_warn "测试服务未运行"
|
||||
[ -f "$PID_FILE" ] && rm -f "$PID_FILE"
|
||||
exit 0
|
||||
# 优先把 PID 文件写成实际监听端口的进程,避免 nohup 壳/worker 不一致
|
||||
local listen_pid
|
||||
listen_pid="$(list_listen_pids_on_port | head -n 1 || true)"
|
||||
if [ -n "${listen_pid:-}" ]; then
|
||||
echo "$listen_pid" > "$PID_FILE"
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid="$(cat "$PID_FILE")"
|
||||
log_info "正在停止测试服务 (PID: $pid)"
|
||||
kill -TERM "$pid" >/dev/null 2>&1 || true
|
||||
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if ! ps -p "$pid" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ps -p "$pid" >/dev/null 2>&1; then
|
||||
log_warn "优雅停止超时,执行强制停止"
|
||||
kill -9 "$pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
rm -f "$PID_FILE"
|
||||
log_info "测试服务已停止"
|
||||
log_info "测试服务启动成功 (PID: $(cat "$PID_FILE"))"
|
||||
log_info "健康检查: http://127.0.0.1:$PORT/health"
|
||||
log_info "API 文档: http://127.0.0.1:$PORT/docs"
|
||||
}
|
||||
|
||||
status_server() {
|
||||
local pid
|
||||
if is_running; then
|
||||
echo -e "${GREEN}RUNNING (test)${NC} PID=$(cat "$PID_FILE") HOST=$HOST PORT=$PORT"
|
||||
pid="$(read_pid_file || true)"
|
||||
if [ -z "${pid:-}" ]; then
|
||||
pid="$(collect_managed_pids | head -n 1)"
|
||||
fi
|
||||
echo -e "${GREEN}RUNNING (test)${NC} PID=${pid:-unknown} HOST=$HOST PORT=$PORT"
|
||||
else
|
||||
echo -e "${RED}STOPPED (test)${NC}"
|
||||
if port_is_in_use; then
|
||||
echo -e "${YELLOW}PORT BUSY (test)${NC} PORT=$PORT pids=$(list_listen_pids_on_port | tr '\n' ' ')"
|
||||
else
|
||||
echo -e "${RED}STOPPED (test)${NC}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class ApiCryptoTests(unittest.TestCase):
|
||||
plain = api_crypto.decrypt_json(cipher, key)
|
||||
self.assertEqual(plain, payload)
|
||||
|
||||
def test_encrypt_decrypt_roundtrip_cbc(self):
|
||||
def test_decrypt_rejects_tampered_gcm_ciphertext(self):
|
||||
key = api_crypto.derive_key(
|
||||
fixed_secret="fixed-secret",
|
||||
user_id="u1",
|
||||
@@ -41,9 +41,13 @@ class ApiCryptoTests(unittest.TestCase):
|
||||
nonce="nonce-2",
|
||||
)
|
||||
payload = {"list": [1, 2, 3], "ok": True}
|
||||
cipher = api_crypto.encrypt_json(payload, key, alg="cbc")
|
||||
plain = api_crypto.decrypt_json(cipher, key, alg="cbc")
|
||||
self.assertEqual(plain, payload)
|
||||
cipher = api_crypto.encrypt_json(payload, key)
|
||||
raw = bytearray(api_crypto._b64url_decode(cipher))
|
||||
raw[-1] ^= 1
|
||||
tampered = api_crypto._b64url_encode(bytes(raw))
|
||||
|
||||
with self.assertRaises(api_crypto.ApiCryptoError):
|
||||
api_crypto.decrypt_json(tampered, key)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"""biz_no_generator 单元测试"""
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models.models import BizBizNoSequence
|
||||
|
||||
from app.crud.biz_no_generator import (
|
||||
MAX_SEQ,
|
||||
@@ -25,6 +31,29 @@ class TestBizNoGenerator(unittest.TestCase):
|
||||
|
||||
|
||||
class TestAllocateNextBizNo(unittest.TestCase):
|
||||
def test_sqlite_fallback_allocates_incrementing_numbers(self):
|
||||
from app.crud import biz_no_generator as gen
|
||||
|
||||
engine = create_engine('sqlite:///:memory:')
|
||||
Base.metadata.create_all(engine, tables=[BizBizNoSequence.__table__])
|
||||
db = sessionmaker(bind=engine)()
|
||||
with patch.object(gen, 'assert_biz_no_available'):
|
||||
first = gen.allocate_next_biz_no(
|
||||
db,
|
||||
tenant_no='zkxw',
|
||||
kind='collection',
|
||||
biz_date=gen.datetime(2026, 10, 13),
|
||||
)
|
||||
second = gen.allocate_next_biz_no(
|
||||
db,
|
||||
tenant_no='zkxw',
|
||||
kind='collection',
|
||||
biz_date=gen.datetime(2026, 10, 13),
|
||||
)
|
||||
|
||||
self.assertEqual(first, 'XBCC20261013000001')
|
||||
self.assertEqual(second, 'XBCC20261013000002')
|
||||
|
||||
def test_allocate_raises_when_seq_exhausted(self):
|
||||
from app.crud import biz_no_generator as gen
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import unittest
|
||||
|
||||
from app.crud.collection_region import map_staff_region_rows_to_list
|
||||
|
||||
|
||||
class RegionsByStaffUserTests(unittest.TestCase):
|
||||
def test_map_preserves_order_and_fields(self):
|
||||
rows = [
|
||||
("r2", "华东"),
|
||||
("r1", "华南"),
|
||||
]
|
||||
out = map_staff_region_rows_to_list(rows)
|
||||
self.assertEqual(
|
||||
out,
|
||||
[{"id": "r2", "regionName": "华东"}, {"id": "r1", "regionName": "华南"}],
|
||||
)
|
||||
|
||||
def test_map_skips_empty_id(self):
|
||||
rows = [("", "无"), ("r1", "华南")]
|
||||
out = map_staff_region_rows_to_list(rows)
|
||||
self.assertEqual(out, [{"id": "r1", "regionName": "华南"}])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""客户服务字典规范化与 CRUD 辅助。"""
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.crud.customer_service_dict import ensure_dict_value
|
||||
from app.services.sample_customer_service_register import normalize_dict_value
|
||||
|
||||
|
||||
class TestNormalizeDictValue(unittest.TestCase):
|
||||
def test_strips_whitespace(self):
|
||||
self.assertEqual(normalize_dict_value(" 已发 "), "已发")
|
||||
|
||||
def test_empty_string_returns_none(self):
|
||||
self.assertIsNone(normalize_dict_value(""))
|
||||
|
||||
def test_whitespace_only_returns_none(self):
|
||||
self.assertIsNone(normalize_dict_value(" "))
|
||||
|
||||
def test_none_returns_none(self):
|
||||
self.assertIsNone(normalize_dict_value(None))
|
||||
|
||||
|
||||
class TestEnsureDictValue(unittest.TestCase):
|
||||
def test_returns_none_for_blank_value(self):
|
||||
db = MagicMock()
|
||||
self.assertIsNone(
|
||||
ensure_dict_value(db, "zkxw", "sms_send_status", " ", user_id="u1")
|
||||
)
|
||||
db.add.assert_not_called()
|
||||
|
||||
def test_returns_existing_without_insert(self):
|
||||
db = MagicMock()
|
||||
existing = MagicMock()
|
||||
existing.dict_value = "已发"
|
||||
db.query.return_value.filter.return_value.first.return_value = existing
|
||||
|
||||
result = ensure_dict_value(
|
||||
db, "zkxw", "sms_send_status", " 已发 ", user_id="u1"
|
||||
)
|
||||
|
||||
self.assertEqual(result, "已发")
|
||||
db.add.assert_not_called()
|
||||
db.flush.assert_not_called()
|
||||
@@ -0,0 +1,709 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizCollectionRegionStaff,
|
||||
BizPresignOrder,
|
||||
BizProtocolCollectionInfo,
|
||||
SysPost,
|
||||
SysPostRole,
|
||||
SysRole,
|
||||
SysRoleDataScopeDetail,
|
||||
SysUserPost,
|
||||
SysUserRole,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonation,
|
||||
BizResearchDonationCollection,
|
||||
)
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
BizCollectionRegionStaff.__table__,
|
||||
BizPresignOrder.__table__,
|
||||
BizCollectionProtocol.__table__,
|
||||
BizProtocolCollectionInfo.__table__,
|
||||
BizResearchDonation.__table__,
|
||||
BizResearchDonationCollection.__table__,
|
||||
SysRole.__table__,
|
||||
SysRoleDataScopeDetail.__table__,
|
||||
SysUserRole.__table__,
|
||||
SysPost.__table__,
|
||||
SysPostRole.__table__,
|
||||
SysUserPost.__table__,
|
||||
],
|
||||
)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _presign(
|
||||
order_id,
|
||||
*,
|
||||
tenant_no="tenant-a",
|
||||
region_id="region-a",
|
||||
sales_user_id="sales-a",
|
||||
manager_user_id="manager-a",
|
||||
create_by="creator-a",
|
||||
del_flag=0,
|
||||
):
|
||||
return BizPresignOrder(
|
||||
id=order_id,
|
||||
tenant_no=tenant_no,
|
||||
region_id=region_id,
|
||||
sales_user_id=sales_user_id,
|
||||
region_manager_user_id=manager_user_id,
|
||||
create_by=create_by,
|
||||
create_dept_id="dept-a",
|
||||
del_flag=del_flag,
|
||||
)
|
||||
|
||||
|
||||
def _cell_protocol(
|
||||
protocol_id,
|
||||
*,
|
||||
tenant_no="tenant-a",
|
||||
collection_tenant_no=None,
|
||||
region_id="region-a",
|
||||
sales_user_id="sales-a",
|
||||
manager_user_id="manager-a",
|
||||
parent_del_flag=0,
|
||||
collection_del_flag=0,
|
||||
):
|
||||
collection_tenant_no = collection_tenant_no or tenant_no
|
||||
return [
|
||||
BizCollectionProtocol(
|
||||
id=protocol_id,
|
||||
protocol_no=f"CP-{protocol_id}",
|
||||
tenant_no=tenant_no,
|
||||
create_by=sales_user_id,
|
||||
create_dept_id="dept-a",
|
||||
del_flag=parent_del_flag,
|
||||
),
|
||||
BizProtocolCollectionInfo(
|
||||
id=f"ci-{protocol_id}",
|
||||
collection_protocol_id=protocol_id,
|
||||
tenant_no=collection_tenant_no,
|
||||
region_id=region_id,
|
||||
sales_user_id=sales_user_id,
|
||||
region_manager_user_id=manager_user_id,
|
||||
create_dept_id="dept-a",
|
||||
del_flag=collection_del_flag,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _donation(
|
||||
donation_id,
|
||||
*,
|
||||
tenant_no="tenant-a",
|
||||
collection_tenant_no=None,
|
||||
region_id="region-a",
|
||||
sales_user_id="sales-a",
|
||||
manager_user_id="manager-a",
|
||||
parent_del_flag=0,
|
||||
collection_del_flag=0,
|
||||
):
|
||||
collection_tenant_no = collection_tenant_no or tenant_no
|
||||
return [
|
||||
BizResearchDonation(
|
||||
id=donation_id,
|
||||
donation_no=f"RD-{donation_id}",
|
||||
tenant_no=tenant_no,
|
||||
create_by=sales_user_id,
|
||||
create_dept_id="dept-a",
|
||||
del_flag=parent_del_flag,
|
||||
),
|
||||
BizResearchDonationCollection(
|
||||
id=f"dc-{donation_id}",
|
||||
research_donation_id=donation_id,
|
||||
tenant_no=collection_tenant_no,
|
||||
region_id=region_id,
|
||||
sales_user_id=sales_user_id,
|
||||
region_manager_user_id=manager_user_id,
|
||||
create_dept_id="dept-a",
|
||||
del_flag=collection_del_flag,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _ids(rows):
|
||||
return {row.id for row in rows}
|
||||
|
||||
|
||||
def _user_info(user_id="u1", role="super_admin"):
|
||||
return {"user_id": user_id, "role": role, "dept_id": "dept-a"}
|
||||
|
||||
|
||||
def test_specialist_matches_sales_user_only():
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_presign_scope,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
_presign("own", sales_user_id="u1", manager_user_id="other"),
|
||||
_presign("managed", sales_user_id="other", manager_user_id="u1"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
|
||||
rows = apply_presign_scope(
|
||||
db.query(BizPresignOrder), db, ctx, user_info=_user_info()
|
||||
).all()
|
||||
|
||||
assert _ids(rows) == {"own"}
|
||||
|
||||
|
||||
def test_manager_matches_leader_region_or_document_manager():
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_presign_scope,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
db.add(
|
||||
BizCollectionRegionStaff(
|
||||
id="leader",
|
||||
region_id="region-led",
|
||||
user_id="u1",
|
||||
is_leader=1,
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
_presign("by-region", region_id="region-led", manager_user_id="other"),
|
||||
_presign("by-manager", region_id="region-other", manager_user_id="u1"),
|
||||
_presign("outside", region_id="region-other", manager_user_id="other"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
ctx = MarketFieldContext("tenant-a", "u1", "manager")
|
||||
rows = apply_presign_scope(
|
||||
db.query(BizPresignOrder), db, ctx, user_info=_user_info()
|
||||
).all()
|
||||
|
||||
assert _ids(rows) == {"by-region", "by-manager"}
|
||||
|
||||
|
||||
def test_manager_ignores_non_leader_staff_rows():
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_presign_scope,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
db.add(
|
||||
BizCollectionRegionStaff(
|
||||
id="staff",
|
||||
region_id="region-staff",
|
||||
user_id="u1",
|
||||
is_leader=0,
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(_presign("staff-region", region_id="region-staff", manager_user_id="other"))
|
||||
db.commit()
|
||||
|
||||
ctx = MarketFieldContext("tenant-a", "u1", "manager")
|
||||
|
||||
assert (
|
||||
apply_presign_scope(
|
||||
db.query(BizPresignOrder), db, ctx, user_info=_user_info()
|
||||
).all()
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_soft_deleted_or_cross_tenant_region_never_matches():
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_presign_scope,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
BizCollectionRegionStaff(
|
||||
id="deleted-leader",
|
||||
region_id="region-deleted",
|
||||
user_id="u1",
|
||||
is_leader=1,
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=1,
|
||||
),
|
||||
BizCollectionRegionStaff(
|
||||
id="other-tenant-leader",
|
||||
region_id="region-cross",
|
||||
user_id="u1",
|
||||
is_leader=1,
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=0,
|
||||
),
|
||||
_presign("deleted-region", region_id="region-deleted", manager_user_id="other"),
|
||||
_presign("cross-region", region_id="region-cross", manager_user_id="other"),
|
||||
_presign("soft-deleted-doc", manager_user_id="u1", del_flag=1),
|
||||
_presign("cross-tenant-doc", tenant_no="tenant-b", manager_user_id="u1"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
ctx = MarketFieldContext("tenant-a", "u1", "manager")
|
||||
|
||||
assert (
|
||||
apply_presign_scope(
|
||||
db.query(BizPresignOrder), db, ctx, user_info=_user_info()
|
||||
).all()
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_dual_role_contexts_do_not_union_specialist_and_manager_scope():
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_presign_scope,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
_presign("specialist-only", sales_user_id="u1", manager_user_id="other"),
|
||||
_presign("manager-only", sales_user_id="other", manager_user_id="u1"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
specialist = MarketFieldContext("tenant-a", "u1", "specialist")
|
||||
manager = MarketFieldContext("tenant-a", "u1", "manager")
|
||||
|
||||
assert _ids(apply_presign_scope(
|
||||
db.query(BizPresignOrder), db, specialist, user_info=_user_info()
|
||||
).all()) == {
|
||||
"specialist-only"
|
||||
}
|
||||
assert _ids(apply_presign_scope(
|
||||
db.query(BizPresignOrder), db, manager, user_info=_user_info()
|
||||
).all()) == {
|
||||
"manager-only"
|
||||
}
|
||||
|
||||
|
||||
def test_apply_scope_requires_general_data_scope_context():
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_presign_scope,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
apply_presign_scope(db.query(BizPresignOrder), db, ctx)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("apply_name", "model"),
|
||||
[
|
||||
("apply_presign_scope", BizPresignOrder),
|
||||
("apply_cell_protocol_scope", BizCollectionProtocol),
|
||||
("apply_donation_scope", BizResearchDonation),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"user_info",
|
||||
[{}, {"user_id": ""}, {"user_id": "other", "role": "super_admin"}],
|
||||
)
|
||||
def test_apply_scopes_reject_missing_or_mismatched_user_identity(
|
||||
apply_name, model, user_info
|
||||
):
|
||||
from app.services import market_field_scope
|
||||
|
||||
db = _session()
|
||||
ctx = market_field_scope.MarketFieldContext("tenant-a", "u1", "specialist")
|
||||
apply_scope = getattr(market_field_scope, apply_name)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
apply_scope(db.query(model), db, ctx, user_info=user_info)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"assert_name",
|
||||
[
|
||||
"assert_presign_access",
|
||||
"assert_cell_protocol_access",
|
||||
"assert_donation_access",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"user_info",
|
||||
[{}, {"user_id": ""}, {"user_id": "other", "role": "super_admin"}],
|
||||
)
|
||||
def test_assert_access_rejects_missing_or_mismatched_user_identity(
|
||||
assert_name, user_info
|
||||
):
|
||||
from app.services import market_field_scope
|
||||
|
||||
db = _session()
|
||||
ctx = market_field_scope.MarketFieldContext("tenant-a", "u1", "specialist")
|
||||
assert_access = getattr(market_field_scope, assert_name)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
assert_access(db, "any-id", ctx, user_info=user_info)
|
||||
|
||||
|
||||
def test_protocol_and_donation_queries_use_their_collection_info_scope():
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_cell_protocol_scope,
|
||||
apply_donation_scope,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
db.add_all(
|
||||
_cell_protocol("cell-own", sales_user_id="u1")
|
||||
+ _cell_protocol("cell-other", sales_user_id="other")
|
||||
+ _cell_protocol("cell-deleted-info", sales_user_id="u1", collection_del_flag=1)
|
||||
+ _donation("donation-own", sales_user_id="u1")
|
||||
+ _donation("donation-other", sales_user_id="other")
|
||||
+ _donation("donation-deleted-info", sales_user_id="u1", collection_del_flag=1)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
|
||||
|
||||
assert _ids(
|
||||
apply_cell_protocol_scope(
|
||||
db.query(BizCollectionProtocol), db, ctx, user_info=_user_info()
|
||||
).all()
|
||||
) == {"cell-own"}
|
||||
assert _ids(
|
||||
apply_donation_scope(
|
||||
db.query(BizResearchDonation), db, ctx, user_info=_user_info()
|
||||
).all()
|
||||
) == {"donation-own"}
|
||||
|
||||
|
||||
def test_cell_and_donation_manager_scope_uses_leader_region_or_document_manager():
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_cell_protocol_scope,
|
||||
apply_donation_scope,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
BizCollectionRegionStaff(
|
||||
id="leader",
|
||||
region_id="region-led",
|
||||
user_id="u1",
|
||||
is_leader=1,
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=0,
|
||||
),
|
||||
BizCollectionRegionStaff(
|
||||
id="non-leader",
|
||||
region_id="region-staff",
|
||||
user_id="u1",
|
||||
is_leader=0,
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
+ _cell_protocol("cell-region", region_id="region-led", manager_user_id="other")
|
||||
+ _cell_protocol("cell-manager", region_id="other", manager_user_id="u1")
|
||||
+ _cell_protocol("cell-non-leader", region_id="region-staff", manager_user_id="other")
|
||||
+ _cell_protocol(
|
||||
"cell-cross-info",
|
||||
collection_tenant_no="tenant-b",
|
||||
region_id="region-led",
|
||||
manager_user_id="u1",
|
||||
)
|
||||
+ _cell_protocol("cell-deleted-parent", manager_user_id="u1", parent_del_flag=1)
|
||||
+ _cell_protocol("cell-cross-parent", tenant_no="tenant-b", manager_user_id="u1")
|
||||
+ _donation("donation-region", region_id="region-led", manager_user_id="other")
|
||||
+ _donation("donation-manager", region_id="other", manager_user_id="u1")
|
||||
+ _donation("donation-non-leader", region_id="region-staff", manager_user_id="other")
|
||||
+ _donation(
|
||||
"donation-cross-info",
|
||||
collection_tenant_no="tenant-b",
|
||||
region_id="region-led",
|
||||
manager_user_id="u1",
|
||||
)
|
||||
+ _donation("donation-deleted-parent", manager_user_id="u1", parent_del_flag=1)
|
||||
+ _donation("donation-cross-parent", tenant_no="tenant-b", manager_user_id="u1")
|
||||
)
|
||||
db.commit()
|
||||
ctx = MarketFieldContext("tenant-a", "u1", "manager")
|
||||
|
||||
assert _ids(
|
||||
apply_cell_protocol_scope(
|
||||
db.query(BizCollectionProtocol), db, ctx, user_info=_user_info()
|
||||
).all()
|
||||
) == {"cell-region", "cell-manager"}
|
||||
assert _ids(
|
||||
apply_donation_scope(
|
||||
db.query(BizResearchDonation), db, ctx, user_info=_user_info()
|
||||
).all()
|
||||
) == {"donation-region", "donation-manager"}
|
||||
|
||||
|
||||
def test_assert_access_uses_the_same_scope_for_all_document_types():
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
assert_cell_protocol_access,
|
||||
assert_donation_access,
|
||||
assert_presign_access,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
_presign("presign-own", sales_user_id="u1"),
|
||||
_presign("presign-outside", sales_user_id="other"),
|
||||
_presign("presign-cross", tenant_no="tenant-b", sales_user_id="u1"),
|
||||
_presign("presign-deleted", sales_user_id="u1", del_flag=1),
|
||||
]
|
||||
)
|
||||
db.add_all(_cell_protocol("cell-own", sales_user_id="u1"))
|
||||
db.add_all(_cell_protocol("cell-outside", sales_user_id="other"))
|
||||
db.add_all(_cell_protocol("cell-cross", tenant_no="tenant-b", sales_user_id="u1"))
|
||||
db.add_all(_cell_protocol("cell-deleted", sales_user_id="u1", parent_del_flag=1))
|
||||
db.add_all(_donation("donation-own", sales_user_id="u1"))
|
||||
db.add_all(_donation("donation-outside", sales_user_id="other"))
|
||||
db.add_all(_donation("donation-cross", tenant_no="tenant-b", sales_user_id="u1"))
|
||||
db.add_all(_donation("donation-deleted", sales_user_id="u1", parent_del_flag=1))
|
||||
db.commit()
|
||||
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
|
||||
|
||||
assert assert_presign_access(
|
||||
db, "presign-own", ctx, user_info=_user_info()
|
||||
).id == "presign-own"
|
||||
assert assert_cell_protocol_access(
|
||||
db, "cell-own", ctx, user_info=_user_info()
|
||||
).id == "cell-own"
|
||||
assert assert_donation_access(
|
||||
db, "donation-own", ctx, user_info=_user_info()
|
||||
).id == "donation-own"
|
||||
|
||||
rejected = {
|
||||
assert_presign_access: [
|
||||
"presign-outside",
|
||||
"presign-cross",
|
||||
"presign-deleted",
|
||||
],
|
||||
assert_cell_protocol_access: [
|
||||
"cell-outside",
|
||||
"cell-cross",
|
||||
"cell-deleted",
|
||||
],
|
||||
assert_donation_access: [
|
||||
"donation-outside",
|
||||
"donation-cross",
|
||||
"donation-deleted",
|
||||
],
|
||||
}
|
||||
for checker, record_ids in rejected.items():
|
||||
for record_id in record_ids:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
checker(db, record_id, ctx, user_info=_user_info())
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
def test_general_data_scope_and_field_scope_are_intersected():
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_presign_scope,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
SysRole(
|
||||
id="self-role",
|
||||
role_name="仅本人",
|
||||
data_scope="4",
|
||||
status="1",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserRole(
|
||||
id="user-role",
|
||||
user_id="u1",
|
||||
role_id="self-role",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=0,
|
||||
),
|
||||
_presign("both", sales_user_id="u1", create_by="u1"),
|
||||
_presign("field-only", sales_user_id="u1", create_by="other"),
|
||||
_presign("data-only", sales_user_id="other", create_by="u1"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
|
||||
|
||||
rows = apply_presign_scope(
|
||||
db.query(BizPresignOrder),
|
||||
db,
|
||||
ctx,
|
||||
user_info={"user_id": "u1", "role": "admin", "dept_id": "dept-a"},
|
||||
).all()
|
||||
|
||||
assert _ids(rows) == {"both"}
|
||||
|
||||
|
||||
def test_multi_role_union_is_still_intersected_with_field_scope():
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_presign_scope,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
SysRole(
|
||||
id="self-role",
|
||||
role_name="仅本人",
|
||||
data_scope="4",
|
||||
status="1",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=0,
|
||||
),
|
||||
SysRole(
|
||||
id="all-role",
|
||||
role_name="全部数据",
|
||||
data_scope="1",
|
||||
status="1",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserRole(
|
||||
id="self-user-role",
|
||||
user_id="u1",
|
||||
role_id="self-role",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserRole(
|
||||
id="all-user-role",
|
||||
user_id="u1",
|
||||
role_id="all-role",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="dept-a",
|
||||
del_flag=0,
|
||||
),
|
||||
_presign("both", sales_user_id="u1", create_by="u1"),
|
||||
_presign("field-only", sales_user_id="u1", create_by="other"),
|
||||
_presign("data-only", sales_user_id="other", create_by="u1"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
|
||||
|
||||
rows = apply_presign_scope(
|
||||
db.query(BizPresignOrder),
|
||||
db,
|
||||
ctx,
|
||||
user_info=_user_info(role="admin"),
|
||||
).all()
|
||||
|
||||
assert _ids(rows) == {"both", "field-only"}
|
||||
|
||||
|
||||
def test_market_field_scope_index_migration_is_idempotent_and_complete():
|
||||
migration = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "docs/sql/migrations/2026-10-10-market-field-scope-indexes.sql"
|
||||
)
|
||||
sql = migration.read_text(encoding="utf-8")
|
||||
compact = re.sub(r"\s+", " ", re.sub(r"--[^\n]*", "", sql)).lower()
|
||||
expected = {
|
||||
"idx_crs_market_leader_scope": (
|
||||
"biz_collection_region_staff",
|
||||
"tenant_no,user_id,is_leader,del_flag,region_id",
|
||||
),
|
||||
"idx_po_market_sales_scope": (
|
||||
"biz_presign_order",
|
||||
"tenant_no,del_flag,sales_user_id",
|
||||
),
|
||||
"idx_po_market_region_scope": (
|
||||
"biz_presign_order",
|
||||
"tenant_no,del_flag,region_id",
|
||||
),
|
||||
"idx_po_market_manager_scope": (
|
||||
"biz_presign_order",
|
||||
"tenant_no,del_flag,region_manager_user_id",
|
||||
),
|
||||
"idx_pci_market_parent_scope": (
|
||||
"biz_protocol_collection_info",
|
||||
"tenant_no,del_flag,collection_protocol_id",
|
||||
),
|
||||
"idx_pci_market_sales_scope": (
|
||||
"biz_protocol_collection_info",
|
||||
"tenant_no,del_flag,sales_user_id,collection_protocol_id",
|
||||
),
|
||||
"idx_pci_market_region_scope": (
|
||||
"biz_protocol_collection_info",
|
||||
"tenant_no,del_flag,region_id,collection_protocol_id",
|
||||
),
|
||||
"idx_pci_market_manager_scope": (
|
||||
"biz_protocol_collection_info",
|
||||
"tenant_no,del_flag,region_manager_user_id,collection_protocol_id",
|
||||
),
|
||||
"idx_rdc_market_parent_scope": (
|
||||
"biz_research_donation_collection",
|
||||
"tenant_no,del_flag,research_donation_id",
|
||||
),
|
||||
"idx_rdc_market_sales_scope": (
|
||||
"biz_research_donation_collection",
|
||||
"tenant_no,del_flag,sales_user_id,research_donation_id",
|
||||
),
|
||||
"idx_rdc_market_region_scope": (
|
||||
"biz_research_donation_collection",
|
||||
"tenant_no,del_flag,region_id,research_donation_id",
|
||||
),
|
||||
"idx_rdc_market_manager_scope": (
|
||||
"biz_research_donation_collection",
|
||||
"tenant_no,del_flag,region_manager_user_id,research_donation_id",
|
||||
),
|
||||
}
|
||||
|
||||
assert compact.count("information_schema.statistics") == len(expected)
|
||||
assert compact.count("prepare stmt from @sql") == len(expected)
|
||||
assert compact.count("deallocate prepare stmt") == len(expected)
|
||||
for index_name, (table_name, columns) in expected.items():
|
||||
assert f"table_name = '{table_name}'" in compact
|
||||
assert f"index_name = '{index_name}'" in compact
|
||||
column_sql = ",".join(f"`{column}`" for column in columns.split(","))
|
||||
assert re.search(
|
||||
rf"alter table `{table_name}` add index `{index_name}` \({column_sql}\)",
|
||||
compact,
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,188 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MIGRATION = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "docs/sql/migrations/2026-10-10-market-mobile-menus.sql"
|
||||
)
|
||||
|
||||
ROLES = {
|
||||
"specialist": "市场专员",
|
||||
"manager": "地区经理",
|
||||
}
|
||||
MODULES = {
|
||||
"presign": "预签约订单",
|
||||
"protocol": "协议单",
|
||||
"payment-chase": "催缴信息",
|
||||
"payment": "缴费信息",
|
||||
"freight": "运费信息",
|
||||
}
|
||||
|
||||
|
||||
def _sql() -> str:
|
||||
return MIGRATION.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", " ", sql)
|
||||
|
||||
|
||||
def _without_comments(sql: str) -> str:
|
||||
return re.sub(r"--[^\n]*", "", sql)
|
||||
|
||||
|
||||
def _insert_statements(sql: str) -> list[str]:
|
||||
return [
|
||||
_compact(statement)
|
||||
for statement in _without_comments(sql).split(";")
|
||||
if re.search(r"\bINSERT\s+INTO\s+sys_menu\b", statement, re.IGNORECASE)
|
||||
]
|
||||
|
||||
|
||||
def test_market_mobile_menu_migration_defines_two_empty_path_directories():
|
||||
sql = _compact(_without_comments(_sql()))
|
||||
|
||||
for directory_name in ROLES.values():
|
||||
assert re.search(
|
||||
rf"'{directory_name}'\s*,\s*root\.id\s*,\s*'M'\s*,\s*''",
|
||||
sql,
|
||||
)
|
||||
|
||||
|
||||
def test_market_mobile_menu_migration_defines_exactly_ten_fixed_leaves():
|
||||
sql = _without_comments(_sql())
|
||||
expected_paths = {
|
||||
f"/pages/market/webview/index?entry={role}-{module}"
|
||||
for role in ROLES
|
||||
for module in MODULES
|
||||
}
|
||||
expected_perms = {
|
||||
f"mini:market:{role}:{module}"
|
||||
for role in ROLES
|
||||
for module in MODULES
|
||||
}
|
||||
|
||||
paths = re.findall(
|
||||
r"['\"](/pages/market/webview/index\?entry=[a-z-]+)['\"]", sql
|
||||
)
|
||||
perms = re.findall(r"['\"](mini:market:[a-z-]+:[a-z-]+)['\"]", sql)
|
||||
|
||||
assert len(paths) == 10
|
||||
assert set(paths) == expected_paths
|
||||
assert len(perms) == 10
|
||||
assert set(perms) == expected_perms
|
||||
for role in ROLES:
|
||||
for module in MODULES:
|
||||
path = f"/pages/market/webview/index?entry={role}-{module}"
|
||||
permission = f"mini:market:{role}:{module}"
|
||||
assert re.search(
|
||||
rf"'{re.escape(path)}'\s*(?:AS\s+path)?\s*,\s*"
|
||||
rf"'{re.escape(permission)}'\s*(?:AS\s+perms)?",
|
||||
sql,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert re.search(
|
||||
r"entry\.menu_name\s*,\s*directory\.id\s*,\s*'C'\s*,\s*entry\.path",
|
||||
_compact(sql),
|
||||
)
|
||||
|
||||
|
||||
def test_market_mobile_menu_migration_is_idempotent_and_tenant_aware():
|
||||
sql = _sql()
|
||||
compact = _compact(_without_comments(sql))
|
||||
inserts = _insert_statements(sql)
|
||||
|
||||
assert len(inserts) == 3
|
||||
assert all(re.search(r"\bNOT\s+EXISTS\b", item, re.IGNORECASE) for item in inserts)
|
||||
assert re.search(r"root\.parent_id\s*=\s*'0'", compact)
|
||||
assert re.search(r"root\.menu_name\s*=\s*'移动端菜单'", compact)
|
||||
assert re.search(r"root\.del_flag\s*=\s*0", compact)
|
||||
assert compact.count("root.tenant_no") >= len(inserts)
|
||||
assert "'zkxw'" not in sql
|
||||
assert re.search(r"existing\.parent_id\s*=\s*directory\.id", compact)
|
||||
assert re.search(r"existing\.path\s*=\s*entry\.path", compact)
|
||||
assert re.search(r"existing\.menu_type\s*=\s*'C'", compact)
|
||||
|
||||
|
||||
def test_each_insert_uses_the_complete_active_natural_key_without_bare_id_or():
|
||||
specialist, manager, leaves = _insert_statements(_sql())
|
||||
|
||||
for directory, name in ((specialist, "市场专员"), (manager, "地区经理")):
|
||||
assert "existing.id" not in directory
|
||||
assert re.search(r"existing\.tenant_no\s*=\s*root\.tenant_no", directory)
|
||||
assert re.search(r"existing\.parent_id\s*=\s*root\.id", directory)
|
||||
assert re.search(r"existing\.menu_name\s*=\s*'" + name + r"'", directory)
|
||||
assert re.search(r"existing\.menu_type\s*=\s*'M'", directory)
|
||||
assert re.search(r"existing\.path\s*=\s*''", directory)
|
||||
assert re.search(r"existing\.del_flag\s*=\s*0", directory)
|
||||
|
||||
assert "existing.id" not in leaves
|
||||
assert re.search(r"existing\.tenant_no\s*=\s*root\.tenant_no", leaves)
|
||||
assert re.search(r"existing\.parent_id\s*=\s*directory\.id", leaves)
|
||||
assert re.search(r"existing\.menu_name\s*=\s*entry\.menu_name", leaves)
|
||||
assert re.search(r"existing\.menu_type\s*=\s*'C'", leaves)
|
||||
assert re.search(r"existing\.path\s*=\s*entry\.path", leaves)
|
||||
assert re.search(r"existing\.perms\s*=\s*entry\.perms", leaves)
|
||||
assert re.search(r"existing\.del_flag\s*=\s*0", leaves)
|
||||
|
||||
|
||||
def test_ids_and_directory_join_are_scoped_to_the_expected_tree_location():
|
||||
specialist, manager, leaves = _insert_statements(_sql())
|
||||
|
||||
for directory in (specialist, manager):
|
||||
id_expression = re.search(
|
||||
r"\bSELECT\s+(MD5\(.*?\))\s*,\s*'(?:市场专员|地区经理)'",
|
||||
directory,
|
||||
).group(1)
|
||||
assert "root.tenant_no" in id_expression
|
||||
assert "root.id" in id_expression
|
||||
|
||||
leaf_id_expression = re.search(
|
||||
r"\bSELECT\s+(MD5\(.*?\))\s*,\s*entry\.menu_name", leaves
|
||||
).group(1)
|
||||
assert "root.tenant_no" in leaf_id_expression
|
||||
assert "root.id" in leaf_id_expression
|
||||
assert "directory.id" in leaf_id_expression
|
||||
assert "entry.path" in leaf_id_expression
|
||||
assert re.search(r"directory\.path\s*=\s*''", leaves)
|
||||
|
||||
|
||||
def test_same_id_wrong_or_soft_deleted_rows_are_repaired_instead_of_blocking_insert():
|
||||
specialist, manager, leaves = _insert_statements(_sql())
|
||||
|
||||
for statement in (specialist, manager, leaves):
|
||||
assert re.search(r"\bON\s+DUPLICATE\s+KEY\s+UPDATE\b", statement)
|
||||
repair = re.split(
|
||||
r"\bON\s+DUPLICATE\s+KEY\s+UPDATE\b", statement, maxsplit=1
|
||||
)[1]
|
||||
for field in (
|
||||
"tenant_no",
|
||||
"parent_id",
|
||||
"menu_name",
|
||||
"menu_type",
|
||||
"path",
|
||||
"perms",
|
||||
"del_flag",
|
||||
):
|
||||
assert re.search(
|
||||
rf"\b{field}\s*=\s*VALUES\s*\(\s*{field}\s*\)", repair
|
||||
), field
|
||||
|
||||
|
||||
def test_market_mobile_menu_migration_does_not_grant_roles_automatically():
|
||||
assert "sys_role_menu" not in _without_comments(_sql())
|
||||
|
||||
|
||||
def test_hidden_sample_menu_migration_revokes_existing_role_links():
|
||||
migration = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ 'docs/sql/migrations/2026-10-13-hide-market-mobile-sample-menus.sql'
|
||||
).read_text(encoding='utf-8')
|
||||
compact = _compact(_without_comments(migration))
|
||||
|
||||
assert re.search(r'UPDATE\s+sys_role_menu', compact, re.IGNORECASE)
|
||||
assert re.search(r'UPDATE\s+sys_role_menu\s+SET\s+del_flag\s*=\s*1', compact, re.IGNORECASE)
|
||||
assert re.search(r'menu_id\s+IN\s*\(\s*SELECT\s+id\s+FROM\s+sys_menu', compact, re.IGNORECASE)
|
||||
assert 'mini:market:specialist:sample' in compact
|
||||
assert 'mini:market:manager:sample' in compact
|
||||
@@ -0,0 +1,784 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.mobile_menu_guard import (
|
||||
require_market_menu_code,
|
||||
require_market_menu_permission,
|
||||
user_has_tenant_menu_perm,
|
||||
)
|
||||
from app.models.base import Base
|
||||
from app.models.models import (
|
||||
BizPresignOrder,
|
||||
BizPresignOrderSampleType,
|
||||
BizPresignTransfer,
|
||||
SysMenu,
|
||||
SysDept,
|
||||
SysPost,
|
||||
SysPostRole,
|
||||
SysRole,
|
||||
SysRoleDataScopeDetail,
|
||||
SysRoleMenu,
|
||||
SysUser,
|
||||
SysUserPost,
|
||||
SysUserRole,
|
||||
)
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
SysMenu.__table__,
|
||||
SysDept.__table__,
|
||||
SysPost.__table__,
|
||||
SysPostRole.__table__,
|
||||
SysRole.__table__,
|
||||
SysRoleDataScopeDetail.__table__,
|
||||
SysRoleMenu.__table__,
|
||||
SysUser.__table__,
|
||||
SysUserPost.__table__,
|
||||
SysUserRole.__table__,
|
||||
BizPresignOrder.__table__,
|
||||
BizPresignOrderSampleType.__table__,
|
||||
BizPresignTransfer.__table__,
|
||||
],
|
||||
)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _add_role_menu(
|
||||
db,
|
||||
*,
|
||||
tenant_no,
|
||||
role_id,
|
||||
menu_id,
|
||||
perm_code,
|
||||
role_name="市场角色",
|
||||
data_scope="1",
|
||||
):
|
||||
db.add(
|
||||
SysRole(
|
||||
id=role_id,
|
||||
role_name=role_name,
|
||||
data_scope=data_scope,
|
||||
status="1",
|
||||
tenant_no=tenant_no,
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
SysMenu(
|
||||
id=menu_id,
|
||||
menu_name=perm_code,
|
||||
parent_id="root",
|
||||
menu_type="C",
|
||||
path=f"/{menu_id}",
|
||||
perms=perm_code,
|
||||
visible="1",
|
||||
status="1",
|
||||
tenant_no=tenant_no,
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
SysRoleMenu(
|
||||
id=f"rm-{role_id}-{menu_id}",
|
||||
role_id=role_id,
|
||||
menu_id=menu_id,
|
||||
tenant_no=tenant_no,
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_specialist_permission_does_not_grant_manager_access():
|
||||
assert not require_market_menu_permission(
|
||||
user_id="u1",
|
||||
tenant_no="t1",
|
||||
required="mini:market:manager:presign",
|
||||
granted={"mini:market:specialist:presign"},
|
||||
)
|
||||
|
||||
|
||||
def test_tenant_menu_permission_does_not_fall_back_to_default_tenant():
|
||||
db = _session()
|
||||
_add_role_menu(
|
||||
db,
|
||||
tenant_no="tenant-a",
|
||||
role_id="role-a",
|
||||
menu_id="menu-a",
|
||||
perm_code="mini:market:manager:presign",
|
||||
)
|
||||
_add_role_menu(
|
||||
db,
|
||||
tenant_no="tenant-b",
|
||||
role_id="role-b",
|
||||
menu_id="menu-b",
|
||||
perm_code="mini:market:specialist:presign",
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
SysUserRole(
|
||||
id="ur-a",
|
||||
user_id="u1",
|
||||
role_id="role-a",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserRole(
|
||||
id="ur-b",
|
||||
user_id="u1",
|
||||
role_id="role-b",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert user_has_tenant_menu_perm(
|
||||
db,
|
||||
user_id="u1",
|
||||
tenant_no="tenant-b",
|
||||
perm_code="mini:market:specialist:presign",
|
||||
)
|
||||
assert not user_has_tenant_menu_perm(
|
||||
db,
|
||||
user_id="u1",
|
||||
tenant_no="tenant-b",
|
||||
perm_code="mini:market:manager:presign",
|
||||
)
|
||||
|
||||
|
||||
def test_tenant_menu_permission_includes_post_inherited_roles():
|
||||
db = _session()
|
||||
_add_role_menu(
|
||||
db,
|
||||
tenant_no="tenant-b",
|
||||
role_id="role-b",
|
||||
menu_id="menu-b",
|
||||
perm_code="mini:market:specialist:presign",
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
SysPost(
|
||||
id="post-b",
|
||||
post_name="市场专员",
|
||||
post_code="specialist",
|
||||
status="1",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserPost(
|
||||
id="up-b",
|
||||
user_id="u1",
|
||||
post_id="post-b",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysPostRole(
|
||||
id="pr-b",
|
||||
post_id="post-b",
|
||||
role_id="role-b",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert user_has_tenant_menu_perm(
|
||||
db,
|
||||
user_id="u1",
|
||||
tenant_no="tenant-b",
|
||||
perm_code="mini:market:specialist:presign",
|
||||
)
|
||||
|
||||
|
||||
def test_strict_guard_rejects_missing_tenant_identity():
|
||||
dependency = require_market_menu_code("mini:market:specialist:presign")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(dependency(user={"user_id": "u1", "tenant_no": ""}, db=None))
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.detail == "用户租户或身份信息缺失"
|
||||
|
||||
|
||||
def test_strict_guard_does_not_trust_cross_tenant_super_admin_role():
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
SysRole(
|
||||
id="tenant-b-role",
|
||||
role_name="市场专员",
|
||||
status="1",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserRole(
|
||||
id="tenant-b-user-role",
|
||||
user_id="u1",
|
||||
role_id="tenant-b-role",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
dependency = require_market_menu_code("mini:market:specialist:presign")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
dependency(
|
||||
user={
|
||||
"user_id": "u1",
|
||||
"tenant_no": "tenant-b",
|
||||
"role": "super_admin",
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.detail == "无移动端菜单权限"
|
||||
|
||||
|
||||
def test_strict_guard_uses_current_tenant_super_admin_role():
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
SysRole(
|
||||
id="tenant-b-super-role",
|
||||
role_name="超级管理员",
|
||||
status="1",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserRole(
|
||||
id="tenant-b-super-user-role",
|
||||
user_id="u1",
|
||||
role_id="tenant-b-super-role",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
dependency = require_market_menu_code("mini:market:specialist:presign")
|
||||
user = {"user_id": "u1", "tenant_no": "tenant-b", "role": "admin"}
|
||||
|
||||
result = asyncio.run(dependency(user=user, db=db))
|
||||
|
||||
assert result is user
|
||||
|
||||
|
||||
def _add_auth_user(db, *, tenant_no="tenant-b"):
|
||||
db.add(
|
||||
SysUser(
|
||||
id="u1",
|
||||
user_no="u1",
|
||||
nick_name="租户用户",
|
||||
password="unused",
|
||||
status="1",
|
||||
tenant_no=tenant_no,
|
||||
create_dept_id="dept-b",
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _add_cross_tenant_roles(db):
|
||||
from app.core.config import settings
|
||||
|
||||
default_tenant = settings.DEFAULT_TENANT_NO
|
||||
_add_role_menu(
|
||||
db,
|
||||
tenant_no=default_tenant,
|
||||
role_id="default-super",
|
||||
menu_id="default-menu",
|
||||
perm_code="default:anything",
|
||||
role_name="超级管理员",
|
||||
data_scope="1",
|
||||
)
|
||||
_add_role_menu(
|
||||
db,
|
||||
tenant_no="tenant-b",
|
||||
role_id="tenant-b-specialist",
|
||||
menu_id="tenant-b-presign",
|
||||
perm_code="mini:market:specialist:presign",
|
||||
role_name="市场专员",
|
||||
data_scope="4",
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
SysUserRole(
|
||||
id="default-user-role",
|
||||
user_id="u1",
|
||||
role_id="default-super",
|
||||
tenant_no=default_tenant,
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserRole(
|
||||
id="tenant-b-user-role",
|
||||
user_id="u1",
|
||||
role_id="tenant-b-specialist",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _add_presign(db, order_id, *, create_by):
|
||||
db.add(
|
||||
BizPresignOrder(
|
||||
id=order_id,
|
||||
mother_name=order_id,
|
||||
sales_user_id="u1",
|
||||
order_status="active",
|
||||
tenant_no="tenant-b",
|
||||
create_by=create_by,
|
||||
create_dept_id="dept-b",
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _authenticate(db, monkeypatch):
|
||||
from app.core import auth
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"decode_token",
|
||||
lambda token: {"sub": "u1", "sid": "session-u1"},
|
||||
)
|
||||
|
||||
async def valid_session(session_id):
|
||||
return session_id == "session-u1"
|
||||
|
||||
async def extend_session(session_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(auth, "is_session_valid", valid_session)
|
||||
monkeypatch.setattr(auth, "extend_session", extend_session)
|
||||
return asyncio.run(auth.get_current_user_by_token("token", db))
|
||||
|
||||
|
||||
def test_authenticated_tenant_role_drives_menu_and_presign_data_scope(
|
||||
monkeypatch,
|
||||
):
|
||||
from app.services.market_field_scope import MarketFieldContext
|
||||
from app.services.market_presign import list_presign_orders
|
||||
|
||||
db = _session()
|
||||
_add_auth_user(db)
|
||||
_add_cross_tenant_roles(db)
|
||||
_add_presign(db, "own", create_by="u1")
|
||||
_add_presign(db, "field-only", create_by="other")
|
||||
db.commit()
|
||||
|
||||
current_user = _authenticate(db, monkeypatch)
|
||||
dependency = require_market_menu_code("mini:market:specialist:presign")
|
||||
guarded_user = asyncio.run(dependency(user=current_user, db=db))
|
||||
rows, total = list_presign_orders(
|
||||
db,
|
||||
ctx=MarketFieldContext("tenant-b", "u1", "specialist"),
|
||||
user_info=guarded_user,
|
||||
)
|
||||
|
||||
assert current_user["tenant_no"] == "tenant-b"
|
||||
assert current_user["role"] == "admin"
|
||||
assert current_user["role_names"] == ["市场专员"]
|
||||
assert total == 1
|
||||
assert [row["id"] for row in rows] == ["own"]
|
||||
|
||||
|
||||
def test_authenticated_current_tenant_super_admin_remains_unrestricted(
|
||||
monkeypatch,
|
||||
):
|
||||
from app.services.market_field_scope import MarketFieldContext
|
||||
from app.services.market_presign import list_presign_orders
|
||||
|
||||
db = _session()
|
||||
_add_auth_user(db)
|
||||
db.add_all(
|
||||
[
|
||||
SysRole(
|
||||
id="tenant-b-super",
|
||||
role_name="超级管理员",
|
||||
data_scope="1",
|
||||
status="1",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserRole(
|
||||
id="tenant-b-super-user-role",
|
||||
user_id="u1",
|
||||
role_id="tenant-b-super",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
_add_presign(db, "own", create_by="u1")
|
||||
_add_presign(db, "field-only", create_by="other")
|
||||
db.commit()
|
||||
|
||||
current_user = _authenticate(db, monkeypatch)
|
||||
dependency = require_market_menu_code("mini:market:specialist:presign")
|
||||
guarded_user = asyncio.run(dependency(user=current_user, db=db))
|
||||
rows, total = list_presign_orders(
|
||||
db,
|
||||
ctx=MarketFieldContext("tenant-b", "u1", "specialist"),
|
||||
user_info=guarded_user,
|
||||
)
|
||||
|
||||
assert current_user["role"] == "super_admin"
|
||||
assert current_user["role_names"] == ["超级管理员"]
|
||||
assert total == 2
|
||||
assert {row["id"] for row in rows} == {"own", "field-only"}
|
||||
|
||||
|
||||
def test_token_issuance_uses_the_users_current_tenant_roles(monkeypatch):
|
||||
from app.services import issue_user_tokens
|
||||
|
||||
db = _session()
|
||||
_add_auth_user(db)
|
||||
_add_cross_tenant_roles(db)
|
||||
db.commit()
|
||||
|
||||
async def no_op(**kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(issue_user_tokens, "store_session", no_op)
|
||||
monkeypatch.setattr(issue_user_tokens, "store_refresh_token", no_op)
|
||||
|
||||
issued = asyncio.run(
|
||||
issue_user_tokens.issue_tokens_for_user(
|
||||
db, db.query(SysUser).filter_by(id="u1").one()
|
||||
)
|
||||
)
|
||||
|
||||
assert issued["userInfo"]["role"] == "admin"
|
||||
assert issued["userInfo"]["roleNames"] == ["市场专员"]
|
||||
|
||||
|
||||
def test_public_auth_entrypoints_pass_the_users_tenant_to_role_queries(
|
||||
monkeypatch,
|
||||
):
|
||||
from app.api import public
|
||||
from app.schemas.auth import ProfileUpdateRequest
|
||||
|
||||
db = _session()
|
||||
_add_auth_user(db)
|
||||
db.commit()
|
||||
user = db.query(SysUser).filter_by(id="u1").one()
|
||||
calls = {"active": [], "role": [], "names": [], "menus": []}
|
||||
|
||||
def has_active_roles(db_arg, user_id, tenant_no=None):
|
||||
calls["active"].append((user_id, tenant_no))
|
||||
return True
|
||||
|
||||
def resolve_role(db_arg, user_id, tenant_no=None):
|
||||
calls["role"].append((user_id, tenant_no))
|
||||
return "admin"
|
||||
|
||||
def role_names(db_arg, user_id, tenant_no=None):
|
||||
calls["names"].append((user_id, tenant_no))
|
||||
return ["市场专员"]
|
||||
|
||||
def menu_codes(db_arg, user_id, tenant_no=None):
|
||||
calls["menus"].append((user_id, tenant_no))
|
||||
return {"mini:market:specialist:presign"}
|
||||
|
||||
async def no_op(*args, **kwargs):
|
||||
return None
|
||||
|
||||
async def valid(*args, **kwargs):
|
||||
return True
|
||||
|
||||
current_user = {
|
||||
"user_id": "u1",
|
||||
"tenant_no": "tenant-b",
|
||||
"role": "admin",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(public.login_crypto, "ensure_initialized", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
public.login_crypto,
|
||||
"extract_login_credentials",
|
||||
lambda body: ("u1", "password", None),
|
||||
)
|
||||
monkeypatch.setattr(public.user_crud, "get_by_user_no", lambda db_arg, no: user)
|
||||
monkeypatch.setattr(public, "verify_password", lambda raw, hashed: True)
|
||||
monkeypatch.setattr(public, "user_has_active_roles", has_active_roles)
|
||||
monkeypatch.setattr(public, "resolve_web_role_for_user", resolve_role)
|
||||
monkeypatch.setattr(public, "get_user_role_names", role_names)
|
||||
monkeypatch.setattr(public, "iter_user_menu_perm_codes", menu_codes)
|
||||
monkeypatch.setattr(public, "create_access_token", lambda **kwargs: "access")
|
||||
monkeypatch.setattr(public, "create_refresh_token", lambda **kwargs: "refresh")
|
||||
monkeypatch.setattr(public, "store_session", no_op)
|
||||
monkeypatch.setattr(public, "store_refresh_token", no_op)
|
||||
monkeypatch.setattr(public, "revoke_refresh_token", no_op)
|
||||
monkeypatch.setattr(public, "extend_session", no_op)
|
||||
monkeypatch.setattr(public, "is_session_valid", valid)
|
||||
monkeypatch.setattr(public, "is_refresh_token_valid", valid)
|
||||
monkeypatch.setattr(
|
||||
public,
|
||||
"decode_token",
|
||||
lambda token: {
|
||||
"type": "refresh",
|
||||
"sid": "session-u1",
|
||||
"jti": "refresh-u1",
|
||||
"sub": "u1",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(public, "update_user", lambda *args, **kwargs: None)
|
||||
|
||||
async def resolve_current(request, db_arg):
|
||||
return current_user
|
||||
|
||||
async def current_by_token(token, db):
|
||||
return current_user
|
||||
|
||||
monkeypatch.setattr(public, "_resolve_current_user_from_request", resolve_current)
|
||||
monkeypatch.setattr(public, "get_current_user_by_token", current_by_token)
|
||||
|
||||
class RequestStub:
|
||||
headers = {"Authorization": "Bearer token"}
|
||||
|
||||
asyncio.run(public.web_login(request=None, body={}, db=db))
|
||||
asyncio.run(public.refresh_token({"refreshToken": "refresh"}, db=db))
|
||||
asyncio.run(
|
||||
public.update_current_user_profile(
|
||||
request=RequestStub(),
|
||||
body=ProfileUpdateRequest(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
asyncio.run(public.get_web_auth_menu_codes(request=RequestStub(), db=db))
|
||||
|
||||
assert calls == {
|
||||
"active": [("u1", "tenant-b")],
|
||||
"role": [("u1", "tenant-b")] * 3,
|
||||
"names": [("u1", "tenant-b")] * 2,
|
||||
"menus": [("u1", "tenant-b")],
|
||||
}
|
||||
|
||||
|
||||
def test_org_labels_are_tenant_scoped_in_auth_profile_and_token_payloads(
|
||||
monkeypatch,
|
||||
):
|
||||
from app.api import public
|
||||
from app.core.config import settings
|
||||
from app.crud.user import list_post_ids_for_user
|
||||
from app.schemas.auth import ProfileUpdateRequest
|
||||
from app.services import issue_user_tokens
|
||||
|
||||
db = _session()
|
||||
_add_auth_user(db)
|
||||
_add_role_menu(
|
||||
db,
|
||||
tenant_no="tenant-b",
|
||||
role_id="tenant-b-role",
|
||||
menu_id="tenant-b-menu",
|
||||
perm_code="mini:market:specialist:presign",
|
||||
role_name="市场专员",
|
||||
data_scope="4",
|
||||
)
|
||||
user = db.query(SysUser).filter_by(id="u1").one()
|
||||
user.dept_id = "default-dept"
|
||||
db.add_all(
|
||||
[
|
||||
SysUserRole(
|
||||
id="tenant-b-role-link",
|
||||
user_id="u1",
|
||||
role_id="tenant-b-role",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysDept(
|
||||
id="default-dept",
|
||||
dept_name="默认租户部门",
|
||||
tenant_no=settings.DEFAULT_TENANT_NO,
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysDept(
|
||||
id="tenant-b-dept",
|
||||
dept_name="租户B部门",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysDept(
|
||||
id="tenant-b-deleted-dept",
|
||||
dept_name="租户B已删除部门",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=1,
|
||||
),
|
||||
SysPost(
|
||||
id="default-post",
|
||||
post_name="默认租户职位",
|
||||
post_code="default-post",
|
||||
status="1",
|
||||
tenant_no=settings.DEFAULT_TENANT_NO,
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysPost(
|
||||
id="tenant-b-post",
|
||||
post_name="租户B有效职位",
|
||||
post_code="tenant-b-post",
|
||||
status="1",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysPost(
|
||||
id="tenant-b-disabled-post",
|
||||
post_name="租户B停用职位",
|
||||
post_code="tenant-b-disabled-post",
|
||||
status="0",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysPost(
|
||||
id="tenant-b-deleted-post",
|
||||
post_name="租户B已删除职位",
|
||||
post_code="tenant-b-deleted-post",
|
||||
status="1",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=1,
|
||||
),
|
||||
SysPost(
|
||||
id="tenant-b-unbound-post",
|
||||
post_name="租户B已解绑职位",
|
||||
post_code="tenant-b-unbound-post",
|
||||
status="1",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserPost(
|
||||
id="default-user-post",
|
||||
user_id="u1",
|
||||
post_id="default-post",
|
||||
tenant_no=settings.DEFAULT_TENANT_NO,
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserPost(
|
||||
id="tenant-b-user-post",
|
||||
user_id="u1",
|
||||
post_id="tenant-b-post",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserPost(
|
||||
id="tenant-b-disabled-user-post",
|
||||
user_id="u1",
|
||||
post_id="tenant-b-disabled-post",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserPost(
|
||||
id="tenant-b-deleted-user-post",
|
||||
user_id="u1",
|
||||
post_id="tenant-b-deleted-post",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserPost(
|
||||
id="tenant-b-unbound-user-post",
|
||||
user_id="u1",
|
||||
post_id="tenant-b-unbound-post",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=1,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
dept_name, post_names = issue_user_tokens.resolve_user_org_labels(db, user)
|
||||
assert dept_name is None
|
||||
assert post_names == ["租户B有效职位"]
|
||||
assert set(list_post_ids_for_user(db, "u1")) == {
|
||||
"default-post",
|
||||
"tenant-b-post",
|
||||
"tenant-b-disabled-post",
|
||||
"tenant-b-deleted-post",
|
||||
}
|
||||
assert set(list_post_ids_for_user(db, "u1", tenant_no="tenant-b")) == {
|
||||
"tenant-b-post",
|
||||
"tenant-b-disabled-post",
|
||||
"tenant-b-deleted-post",
|
||||
}
|
||||
|
||||
user.dept_id = "tenant-b-deleted-dept"
|
||||
db.commit()
|
||||
dept_name, post_names = issue_user_tokens.resolve_user_org_labels(db, user)
|
||||
assert dept_name is None
|
||||
assert post_names == ["租户B有效职位"]
|
||||
|
||||
user.dept_id = "tenant-b-dept"
|
||||
db.commit()
|
||||
authenticated = _authenticate(db, monkeypatch)
|
||||
assert authenticated["dept_name"] == "租户B部门"
|
||||
assert authenticated["post_names"] == ["租户B有效职位"]
|
||||
|
||||
async def no_op(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(issue_user_tokens, "store_session", no_op)
|
||||
monkeypatch.setattr(issue_user_tokens, "store_refresh_token", no_op)
|
||||
issued = asyncio.run(issue_user_tokens.issue_tokens_for_user(db, user))
|
||||
assert issued["userInfo"]["deptName"] == "租户B部门"
|
||||
assert issued["userInfo"]["postNames"] == ["租户B有效职位"]
|
||||
|
||||
current_user = {"user_id": "u1", "tenant_no": "tenant-b"}
|
||||
|
||||
async def resolve_current(request, db_arg):
|
||||
return current_user
|
||||
|
||||
monkeypatch.setattr(public, "_resolve_current_user_from_request", resolve_current)
|
||||
monkeypatch.setattr(public, "update_user", lambda *args, **kwargs: None)
|
||||
profiled = asyncio.run(
|
||||
public.update_current_user_profile(
|
||||
request=None,
|
||||
body=ProfileUpdateRequest(),
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
assert profiled["data"]["deptName"] == "租户B部门"
|
||||
assert profiled["data"]["postNames"] == ["租户B有效职位"]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SQL_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ 'docs/sql/migrations/2026-10-11-market-payment-indexes.sql'
|
||||
)
|
||||
|
||||
|
||||
def test_market_payment_index_migration_is_mysql57_idempotent_and_repairs_names():
|
||||
sql = SQL_PATH.read_text(encoding='utf-8')
|
||||
|
||||
assert 'CREATE INDEX IF NOT EXISTS' not in sql.upper()
|
||||
assert sql.count('information_schema.statistics') >= 2
|
||||
assert sql.count('GROUP_CONCAT(column_name ORDER BY seq_in_index') >= 2
|
||||
assert sql.count('PREPARE stmt FROM @ddl') >= 2
|
||||
assert sql.count('MIN(non_unique)') >= 2
|
||||
assert sql.count('MAX(non_unique)') >= 2
|
||||
assert sql.count("SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END)") >= 2
|
||||
assert sql.count('COUNT(DISTINCT index_type)') >= 2
|
||||
assert sql.count('MIN(index_type)') >= 2
|
||||
assert sql.count("@index_non_unique_min = 1") >= 2
|
||||
assert sql.count("@index_non_unique_max = 1") >= 2
|
||||
assert sql.count("@index_prefix_columns = 0") >= 2
|
||||
assert sql.count("@index_type_count = 1") >= 2
|
||||
assert sql.count("@index_type = 'BTREE'") >= 2
|
||||
assert 'DROP INDEX idx_biz_protocol_payment_schedule_market_scope' in sql
|
||||
assert 'DROP INDEX idx_biz_protocol_payment_transfer_market_scope' in sql
|
||||
|
||||
|
||||
def test_market_payment_index_migration_has_exact_required_column_orders():
|
||||
sql = SQL_PATH.read_text(encoding='utf-8')
|
||||
|
||||
assert "'tenant_no,sample_payment_id,del_flag'" in sql
|
||||
assert "'tenant_no,schedule_id,del_flag'" in sql
|
||||
assert (
|
||||
'idx_biz_protocol_payment_schedule_market_scope '
|
||||
'(tenant_no, sample_payment_id, del_flag)'
|
||||
) in sql
|
||||
assert (
|
||||
'idx_biz_protocol_payment_transfer_market_scope '
|
||||
'(tenant_no, schedule_id, del_flag)'
|
||||
) in sql
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 684 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user