Compare commits

...
Author SHA1 Message Date
zlwuandCursor 8020e667f1 fix: 多张大图并行上传时不再拆掉进行中的请求,并放宽附件上传超时。
审批附件在第一张成功后会重建上传组件,其余请求被取消并显示超时。现保留进行中的文件,附件请求与 Nginx 上传超时放宽到 5 分钟,存储写入不再堵住事件循环。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 10:42:07 +08:00
zlwuandCursor 50d56ce0bb chore: 添加仓库根目录实验室场景图 fmtp.png。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 09:37:50 +08:00
zlwuandCursor 4efd423d2d chore: 同步提交后自动更新的知识图谱。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:52:38 +08:00
zlwuandCursor 5e563a6669 fix: 子表保存改为就地同步,避免整批换 id 打断外键。
协议样本、生产产出和盘点明细按行更新并保留原 id;补齐存储证明质检预填、离职人员显示名,以及样本采集通知模板字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:51:53 +08:00
zlwuandCursor d66e55af65 fix: 小程序新增样本发微信通知,并放宽部分 Web 菜单强校验。
移动端协议保存后调度样本采集服务号通知;收款确认/储存证等写接口改为运营端登录即可。同步日期字段规范化与 OA 日期选择器复开修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 16:02:47 +08:00
zlwuandCursor 045ee8d0e3 fix: Drone 打包改用阿里云 Alpine 源,避免 apk 偶发失败
国内机访问 dl-cdn.alpinelinux.org 会 Permission denied,导致 bash/python3 装不上整包失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:18:58 +08:00
zlwuandCursor 4583c54568 chore: 同步 web-admin graphify manifest
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:02:43 +08:00
zlwuandCursor 5dad1bb0e3 fix: 协议子资源单事务保存,并补齐样本编码/OA/质检等近期能力
锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:02:25 +08:00
zlwuandCursor 19bc66ba45 docs: Drone runner 增加 hzdyjs 发版目录与 Maven 公共仓库挂载
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 11:39:47 +08:00
zlwuandCursor 076e892df9 fix: Drone 打包命令对自定义环境变量使用 $${},避免版本被预处理成空
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 11:09:24 +08:00
zlwuandCursor 07508e677b fix: Drone 流水线去掉与 runner 重复的 host volume 挂载
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 10:57:01 +08:00
zlwuandCursor bab304abd3 fix: Drone 打包步骤去掉 privileged,避免未 trusted 仓库被拒绝
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 10:53:05 +08:00
252 changed files with 136105 additions and 94514 deletions

No files matched your search

+15 -20
View File
@@ -2,6 +2,12 @@
# 镜像与包名固定为 v1.1.0(与现网 .env / 部署习惯一致),便于生产升级时不必改镜像标签
# 产物:/root/zkxw-releases/zkxw-docker-deploy-v1.1.0.tar.gz
# 脚本:./scripts/package-docker-deploy.sh
#
# docker.sock 与 /releases 由 runner 环境变量 DRONE_RUNNER_VOLUMES 注入,
# 勿在本文件再声明同路径 host volume,否则会 Duplicate mount point。
#
# 注意:Drone 会对 commands 里的 ${VAR} 做预处理替换;自定义环境变量请用 $${VAR}
# 或直接写死版本号,避免变成空字符串。
kind: pipeline
type: docker
@@ -17,35 +23,24 @@ trigger:
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
# 国内机访问 dl-cdn.alpinelinux.org 偶发 Permission denied,改用阿里云镜像
- sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
- apk add --no-cache bash git python3
- echo "==> git tag=${DRONE_TAG:-none} deploy_version=${ZKXW_DEPLOY_VERSION} commit=${DRONE_COMMIT}"
# 固定 v1.1.0;$${ } 防止 Drone 预处理把自定义变量掏空
- export ZKXW_DEPLOY_VERSION="$${ZKXW_DEPLOY_VERSION:-v1.1.0}"
- echo "==> git tag=${DRONE_TAG:-none} deploy_version=$${ZKXW_DEPLOY_VERSION} commit=${DRONE_COMMIT}"
- chmod +x scripts/package-docker-deploy.sh scripts/build-docker-images.sh
- |
if [ -f /releases/.deploy-docker.env ]; then
@@ -53,8 +48,8 @@ steps:
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}"
- ./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
- 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
+2
View File
@@ -70,6 +70,7 @@ Full specs: `openspec/changes/zkxw-engineering-standards/specs/*/spec.md`. Below
- **DB changes**: SHALL maintain SQL in `docs/sql/`. Schema changes SHALL align with `database-conventions` public field rules.
- **Commit messages**: SHALL be complete sentences describing "what changed and why". Follow Conventional Commits (`feat|fix|docs|…`).
- **Field consistency (CRUD ↔ Schema ↔ Frontend)**: When implementing CRUD for any entity, the `_row_to_dict` function is the **single source of truth** for all readable fields. The CreateBody/UpdateBody Pydantic schemas and the frontend `formData` initialization SHALL include every writable field present in `_row_to_dict` (excluding computed/read-only fields like `id`, `createTime`, `updateTime`, `stockQty`). Run `python scripts/check_field_consistency.py` after implementation to verify. See `docs/dev-field-consistency.md` for details.
- **Child-row save(禁止整表删除再新增)**: 保存子表时 SHALL 按行处理:已有行就地更新,只插入新增行,只软删除用户去掉的行。客户端可以提交稳定 `id`,字段变化时该行必须保留原 `id`。请求未带 `id` 时,先用稳定业务键(样本编码、物料编码、条码、组分 id 等)匹配一条尚未占用的有效行,匹配不到再插入新 UUID。属于其他父记录或其他租户的 `id` SHALL 拒绝。SHALL NOT 在保存时把子表全部软删除再整批插入新行——其他表会拿这些 id 做外键(`production_step_output_id`、`protocol_sample_id`、`donation_sample_id`),整批换 id 会把关联打断。删除父记录时可以级联软删除其子行。复制或克隆父记录时 SHALL 为子行分配新 id。实现入口:`backend/app/crud/child_row_sync.py` 的 `sync_child_rows`。
### Permission Conventions
@@ -220,4 +221,5 @@ pnpm clean:dev # strip demo data for fresh start
- **Table column widths**: All `el-table-column` SHALL use `min-width` (not `width`) for proportional scaling when viewport resizes.
- **Toolbar**: "已选 N 项" first, then primary action button (only one `type="primary"` per toolbar row), no `space-between` push.
- **Database changes**: always pair with schema SQL in `docs/sql/` and evaluate API/TypeScript type impact.
- **Child-row save**: SHALL NOT soft-delete every child row and insert new rows on save. Update in place, insert new rows, soft-delete only removed rows. See Coding Conventions.
- **进程重启**:修改 bug 时,如果前端或后端需要重启,Agent 只告知用户,由用户自行重启。Agent 不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。
+15
View File
@@ -86,3 +86,18 @@ SECRET_KEY=
# WX_MP_TPL_PROGRESS_FIELD_APPROVER=thing6
# WX_MP_TPL_PROGRESS_FIELD_RESULT=const4
# WX_MP_TPL_PROGRESS_FIELD_TIME=time11
# 基础实验交付日前一天提醒(出库提醒模板)
# WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY=oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM
# WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER=thing6
# WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT=thing10
# WX_MP_TPL_BE_DELIVERY_FIELD_QTY=character_string8
# WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT=thing7
# WX_MP_TPL_BE_DELIVERY_FIELD_TIME=time14
# 协议新增样本采集通知(工单类模板)
# WX_MP_TEMPLATE_SAMPLE_COLLECT=iOnQ9k2mavAOwFbp_6yH4t1SHqpEkaiwNWKueY0kpwE
# WX_MP_SAMPLE_COLLECT_ROLE_NAME=样本采集通知人员
# WX_MP_TPL_SC_FIELD_TITLE=thing2
# WX_MP_TPL_SC_FIELD_APPLICANT=thing8
# WX_MP_TPL_SC_FIELD_TIME=time3
# WX_MP_TPL_SC_FIELD_TYPE=phrase10
# WX_MP_TPL_SC_FIELD_SAMPLE_TYPE=thing17
+2
View File
@@ -44,6 +44,7 @@ from app.api.market_mobile import router as market_mobile_router
from app.api.sample_storage_cert_qc import router as sample_storage_cert_qc_router
from app.api.customer_service_dict import router as customer_service_dict_router
from app.api.sample_cert_insurance import router as sample_cert_insurance_router
from app.api.sample_code_correction import router as sample_code_correction_router
__all__ = [
"public_router",
@@ -91,4 +92,5 @@ __all__ = [
"sample_storage_cert_qc_router",
"customer_service_dict_router",
"sample_cert_insurance_router",
"sample_code_correction_router",
]
+5 -1
View File
@@ -1,4 +1,5 @@
"""运营端管理接口(租户、角色下拉等)"""
import asyncio
import os
import uuid
from typing import Any, List, Optional
@@ -1616,7 +1617,10 @@ async def upload_attachment(
return error_response(400, "请选择文件")
try:
file_content = await file.read()
full_url = storage.upload_file(file_content, file.filename, folder or "")
# MinIO/本地写入是同步的。多张大图并行时若占住事件循环,其它上传会一直等不到响应,前端表现为超时后文件消失。
full_url = await asyncio.to_thread(
storage.upload_file, file_content, file.filename, folder or ""
)
base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
if base_url and full_url.startswith(base_url + "/"):
file_url = full_url[len(base_url) + 1:]
+69 -18
View File
@@ -22,6 +22,12 @@ from app.models.models import (
SysUser,
)
from app.services.party_customer_sync import sync_party_customers
from app.services.sample_collect_wx_notify import (
PROTOCOL_KIND_CELL,
sample_codes_from_orm_rows,
sample_codes_from_payload,
schedule_sample_collect_notify,
)
router = APIRouter(prefix="/web/admin", tags=["细胞存储"])
@@ -279,26 +285,40 @@ def _persist_protocol_sub_resources(
sample_payments: Optional[list] = None,
storage_cert: Optional[dict],
payment_model_version: int = 2,
commit: bool = True,
) -> None:
"""持久化协议子资源。
version=1:缴费(含明细校验)须在样本之前,避免校验失败后已占用样本编码。
version=2:样本先落库占码,再按 sample_code 挂样本缴费方案。
默认整段子资源共用一次事务:内部一律 commit=False,成功后再统一 commit,
避免样本已提交、缴费校验失败导致「提示失败但部分已保存」。
"""
if party_customer:
_parse_datetime_fields(party_customer, ["newbornDeliveryTime"])
party_customer = sync_party_customers(
db, party_customer, tenant_no=tenant_no, user_id=user_id
db,
party_customer,
tenant_no=tenant_no,
user_id=user_id,
commit=False,
)
cp_svc.upsert_party_customer(
db, protocol_id, tenant_no, user_id, commit=False, **party_customer
)
cp_svc.upsert_party_customer(db, protocol_id, tenant_no, user_id, **party_customer)
if collection_info:
_parse_datetime_fields(collection_info, ["sampleCollectTime", "maternalBloodCollectTime"])
cp_svc.upsert_collection_info(db, protocol_id, tenant_no, user_id, **collection_info)
cp_svc.upsert_collection_info(
db, protocol_id, tenant_no, user_id, commit=False, **collection_info
)
version = int(payment_model_version or 2)
if version >= 2:
if samples is not None:
cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples)
cp_svc.save_samples(
db, protocol_id, tenant_no, user_id, samples, commit=False
)
if sample_payments is not None:
from app.crud import protocol_sample_payment as sp_pay_svc
@@ -308,6 +328,7 @@ def _persist_protocol_sub_resources(
tenant_no=tenant_no,
user_id=user_id,
sample_payments=sample_payments,
commit=False,
)
else:
# 旧模型:缴费先于样本
@@ -319,12 +340,20 @@ def _persist_protocol_sub_resources(
user_id,
payment=payment,
payment_details=payment_details,
commit=False,
)
if samples is not None:
cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples)
cp_svc.save_samples(
db, protocol_id, tenant_no, user_id, samples, commit=False
)
if storage_cert:
cp_svc.upsert_storage_cert(db, protocol_id, tenant_no, user_id, **storage_cert)
cp_svc.upsert_storage_cert(
db, protocol_id, tenant_no, user_id, commit=False, **storage_cert
)
if commit:
db.commit()
@router.post("/collection-protocols")
@@ -372,6 +401,7 @@ async def create_collection_protocol(
remark=body.remark,
tenant_no=tenant_no,
user_id=user_id,
commit=False,
)
protocol_id = obj.id
_persist_protocol_sub_resources(
@@ -389,20 +419,23 @@ async def create_collection_protocol(
payment_model_version=int(
getattr(obj, "payment_model_version", None) or 2
),
commit=True,
)
db.refresh(obj)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=protocol_id,
protocol_kind=PROTOCOL_KIND_CELL,
before_codes=[],
submitted_codes=(
sample_codes_from_payload(body.samples)
if body.samples is not None
else None
),
)
return success_response(cp_svc._protocol_to_dict(obj), message="创建成功")
except ValueError as e:
# 子表分段 commit 后校验失败时,补偿软删主单及样本,释放样本编码占用
if protocol_id:
try:
cp_svc.soft_delete_collection_protocol(
db,
protocol_id,
tenant_no=tenant_no,
user_id=user_id,
)
except Exception:
pass
db.rollback()
return error_response(400, str(e))
@@ -447,11 +480,15 @@ async def update_collection_protocol(
)
if resolved is not None:
main_payload["health_survey_form_urls"] = resolved
before_codes = sample_codes_from_orm_rows(
cp_svc.list_samples(db, protocol_id, tenant_no)
)
obj = cp_svc.update_collection_protocol(
db,
protocol_id,
tenant_no=tenant_no,
user_id=user_id,
commit=False,
**{
k: v
for k, v in main_payload.items()
@@ -473,9 +510,23 @@ async def update_collection_protocol(
payment_model_version=int(
getattr(obj, "payment_model_version", None) or 1
),
commit=True,
)
db.refresh(obj)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=protocol_id,
protocol_kind=PROTOCOL_KIND_CELL,
before_codes=before_codes,
submitted_codes=(
sample_codes_from_payload(body.samples)
if body.samples is not None
else None
),
)
return success_response(cp_svc._protocol_to_dict(obj), message="更新成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@@ -1177,7 +1228,7 @@ 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()
u = db.query(SysUser).filter(SysUser.id == uid).first()
name = (u.nick_name or "") if u else ""
payload["handlerUserName"] = name
return payload
+1
View File
@@ -171,6 +171,7 @@ class StockCheckCreateBody(BaseModel):
class StockCheckUnusedLineItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
usage_id: Optional[str] = Field(None, alias="usageId")
batch_id: Optional[str] = Field(None, alias="batchId")
material_code: Optional[str] = Field(None, alias="materialCode")
+40
View File
@@ -1265,6 +1265,46 @@ async def oa_cc_unread_count(
return error_response(400, str(e))
@router.get("/mentions/unread-count")
async def oa_mention_unread_count(
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services import oa_instance_comment as oa_comment
from app.services.oa_engine import OaEngineError
try:
tenant_no = _get_tenant_no(db, user)
n = oa_comment.count_mention_unread(
db, tenant_no=tenant_no, user_id=user.get("user_id") or ""
)
return success_response({"count": int(n)})
except OaEngineError as e:
return error_response(400, str(e))
@router.post("/mentions/{instance_id}/read")
async def oa_mention_mark_read(
instance_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services import oa_instance_comment as oa_comment
from app.services.oa_engine import OaEngineError
try:
tenant_no = _get_tenant_no(db, user)
data = oa_comment.mark_mention_instance_read(
db,
tenant_no=tenant_no,
user_id=user.get("user_id") or "",
instance_id=instance_id,
)
return success_response(data, message="已标记已读")
except OaEngineError as e:
return error_response(400, str(e))
@router.post("/tasks/cc/read-batch")
async def oa_cc_read_batch(
body: OaCcReadBatchBody,
+6
View File
@@ -151,6 +151,8 @@ class ScheduleProductNestedBody(BaseModel):
project_name: Optional[str] = Field(None, alias="projectName")
cell_type_name: Optional[str] = Field(None, alias="cellTypeName")
spec_text: Optional[str] = Field(None, alias="specText")
package_id: Optional[str] = Field(None, alias="packageId")
package_text: Optional[str] = Field(None, alias="packageText")
agreement_price: Optional[float] = Field(None, alias="agreementPrice")
actual_price: Optional[float] = Field(None, alias="actualPrice")
quantity: Optional[int] = 1
@@ -167,6 +169,8 @@ class InfusionScheduleNestedBody(BaseModel):
infusion_location: Optional[str] = Field(None, alias="infusionLocation")
infusion_method: Optional[str] = Field(None, alias="infusionMethod")
amount: Optional[float] = None
package_id: Optional[str] = Field(None, alias="packageId")
package_text: Optional[str] = Field(None, alias="packageText")
cell_products: Optional[list[ScheduleProductNestedBody]] = Field(None, alias="cellProducts")
@@ -500,6 +504,8 @@ class SaleScheduleProductLineBody(BaseModel):
project_name: Optional[str] = Field(None, alias="projectName")
cell_type_name: Optional[str] = Field(None, alias="cellTypeName")
spec_text: Optional[str] = Field(None, alias="specText")
package_id: Optional[str] = Field(None, alias="packageId")
package_text: Optional[str] = Field(None, alias="packageText")
agreement_price: Optional[float] = Field(None, alias="agreementPrice")
actual_price: Optional[float] = Field(None, alias="actualPrice")
quantity: Optional[int] = 1
+102
View File
@@ -228,6 +228,108 @@ async def delete_cell_product_spec(
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 2b. 细胞产品包装
# ---------------------------------------------------------------------------
class CellProductPackageCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
package_code: str = Field(..., alias="packageCode")
package_name: str = Field(..., alias="packageName")
order_num: Optional[int] = Field(0, alias="orderNum")
remark: Optional[str] = None
class CellProductPackageUpdateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
package_code: Optional[str] = Field(None, alias="packageCode")
package_name: Optional[str] = Field(None, alias="packageName")
order_num: Optional[int] = Field(None, alias="orderNum")
remark: Optional[str] = None
@router.get("/cell-product-packages")
async def list_cell_product_packages(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items, total = ps_svc.list_cell_product_packages(
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no, user_info=user
)
tp = ps_svc.total_pages(total, pageSize)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}
)
@router.post("/cell-product-packages")
async def create_cell_product_package(
body: CellProductPackageCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
obj = ps_svc.create_cell_product_package(
db,
package_code=body.package_code,
package_name=body.package_name,
tenant_no=tenant_no,
order_num=body.order_num,
remark=body.remark,
user_id=user.get("user_id"),
)
return success_response(ps_svc._row_to_dict_cell_product_package(obj), message="创建成功")
except ValueError as e:
return error_response(400, str(e))
@router.put("/cell-product-packages/{row_id}")
async def update_cell_product_package(
row_id: str,
body: CellProductPackageUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
obj = ps_svc.update_cell_product_package(
db,
row_id,
package_code=payload.get("package_code"),
package_name=payload.get("package_name"),
order_num=payload.get("order_num"),
remark=payload.get("remark"),
user_id=user.get("user_id"),
tenant_no=tenant_no,
)
return success_response(ps_svc._row_to_dict_cell_product_package(obj), message="更新成功")
except ValueError as e:
return error_response(400, str(e))
@router.delete("/cell-product-packages/{row_id}")
async def delete_cell_product_package(
row_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
ps_svc.soft_delete_cell_product_package(db, row_id, tenant_no=tenant_no)
return success_response(None, message="删除成功")
except ValueError as e:
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 3. 细胞产品价格
# ---------------------------------------------------------------------------
+3 -5
View File
@@ -11,15 +11,13 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.data_scope import get_user_dept_id
from app.core.menu_guard import require_admin_or_super_admin, require_strict_menu_code
from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import error_response, success_response
from app.crud import presign_order as po_svc
from app.models.models import SysUser
router = APIRouter(prefix="/web/admin", tags=["细胞存储-预签约订单"])
PAYMENT_CONFIRM_MENU = "biz:cellStoragePaymentConfirm:list"
def _get_tenant_no(db: Session, user: dict) -> str:
user_id = user.get("user_id")
@@ -400,7 +398,7 @@ async def list_presign_receipt_confirmed(
async def confirm_presign_transfer(
transfer_id: str,
body: Optional[FinanceConfirmBody] = None,
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
@@ -429,7 +427,7 @@ 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_strict_menu_code(PAYMENT_CONFIRM_MENU)),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
+11 -2
View File
@@ -1277,6 +1277,7 @@ async def delete_consumable_pack(
class ProcessReagentInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
reagent_code: Optional[str] = Field(None, alias="reagentCode")
reagent_name: Optional[str] = Field(None, alias="reagentName")
spec: Optional[str] = None
@@ -1288,6 +1289,7 @@ class ProcessReagentInput(BaseModel):
class ProcessConsumableInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
consumable_code: Optional[str] = Field(None, alias="consumableCode")
consumable_name: Optional[str] = Field(None, alias="consumableName")
spec_model: Optional[str] = Field(None, alias="specModel")
@@ -1299,6 +1301,7 @@ class ProcessConsumableInput(BaseModel):
class ProcessOutputInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
output_type_id: Optional[str] = Field(None, alias="outputTypeId")
output_name: Optional[str] = Field(None, alias="outputName")
output_barcode: Optional[str] = Field(None, alias="outputBarcode")
@@ -1307,6 +1310,7 @@ class ProcessOutputInput(BaseModel):
class ProcessDataCollectionInput(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
collection_name: str = Field(..., alias="collectionName")
unit: Optional[str] = None
order_num: Optional[int] = Field(0, alias="orderNum")
@@ -2734,6 +2738,7 @@ async def replace_task_step_reagents(
for it in body:
d = it.model_dump(by_alias=False)
items_data.append({
"id": d.get("id"),
"reagent_code": d.get("material_code"),
"reagent_name": d.get("material_name"),
"spec": d.get("spec"),
@@ -2767,6 +2772,7 @@ async def replace_task_step_consumables(
for it in body:
d = it.model_dump(by_alias=False)
items_data.append({
"id": d.get("id"),
"consumable_code": d.get("material_code"),
"consumable_name": d.get("material_name"),
"spec_model": d.get("spec_model"),
@@ -2800,6 +2806,7 @@ async def replace_task_step_configured_reagents(
for it in body:
d = it.model_dump(by_alias=False)
items_data.append({
"id": d.get("id"),
"configured_reagent_code": d.get("material_code"),
"configured_reagent_name": d.get("material_name"),
"spec": d.get("spec"),
@@ -2851,8 +2858,10 @@ async def replace_task_step_outputs(
try:
tenant_no = _get_tenant_no(db, user)
items_data = [it.model_dump(by_alias=False) for it in body]
production_task_svc.replace_step_outputs(db, step_id, tenant_no, user.get("user_id"), items_data)
return success_response(None, message="更新成功")
saved = production_task_svc.replace_step_outputs(
db, step_id, tenant_no, user.get("user_id"), items_data
)
return success_response(saved, message="更新成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
+2
View File
@@ -739,6 +739,7 @@ class StockCheckCreateBody(BaseModel):
class StockCheckUnusedLineItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
usage_id: Optional[str] = Field(None, alias="usageId")
batch_id: Optional[str] = Field(None, alias="batchId")
material_code: Optional[str] = Field(None, alias="materialCode")
@@ -926,6 +927,7 @@ async def cancel_production_material_stock_check(
class FormulaLineBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
component_material_id: str = Field(..., alias="componentMaterialId")
component_material_type: str = Field(..., alias="componentMaterialType")
standard_qty: float = Field(..., gt=0, alias="standardQty")
+3 -5
View File
@@ -9,15 +9,13 @@ from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.menu_guard import require_admin_or_super_admin, require_strict_menu_code
from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import error_response, success_response
from app.crud import protocol_sample_payment as psp_svc
from app.models.models import BizCollectionProtocol, SysUser
router = APIRouter(prefix="/web/admin", tags=["细胞存储-按样本缴费"])
PAYMENT_CONFIRM_MENU = "biz:cellStoragePaymentConfirm:list"
def _get_tenant_no(db: Session, user: dict) -> str:
user_id = user.get("user_id")
@@ -587,7 +585,7 @@ async def list_receipt_confirmed(
async def confirm_payment_transfer(
transfer_id: str,
body: Optional[FinanceConfirmBody] = None,
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
@@ -620,7 +618,7 @@ 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_strict_menu_code(PAYMENT_CONFIRM_MENU)),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
+5
View File
@@ -826,6 +826,9 @@ async def list_qc_work_orders(
infusionTimeEnd: Optional[str] = Query(None, alias="infusionTimeEnd"),
triggerSource: Optional[str] = Query(None, alias="triggerSource"),
sampleTypeId: Optional[str] = Query(None, alias="sampleTypeId"),
protocolStatus: Optional[str] = Query(
None, alias="protocolStatus", description="协议/捐赠状态,多个用英文逗号分隔"
),
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code("biz:qcWorkOrder:list")),
):
@@ -842,6 +845,7 @@ async def list_qc_work_orders(
reception_source=receptionSource,
trigger_source=triggerSource,
sample_type_id=sampleTypeId,
protocol_status=protocolStatus,
create_time_start=createTimeStart,
create_time_end=createTimeEnd,
infusion_time_start=infusionTimeStart,
@@ -2448,6 +2452,7 @@ class StockCheckCreateBody(BaseModel):
class StockCheckUnusedLineItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
usage_id: Optional[str] = Field(None, alias="usageId")
batch_id: Optional[str] = Field(None, alias="batchId")
material_code: Optional[str] = Field(None, alias="materialCode")
+37 -28
View File
@@ -6,21 +6,20 @@ from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.menu_guard import (
assert_strict_menu_code,
require_admin_or_super_admin,
require_strict_menu_code,
)
from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import error_response, success_response
from app.crud import research_donation as rd_svc
from app.models.models import SysUser
from app.models.research_donation import BizResearchDonation
from app.services.sample_collect_wx_notify import (
PROTOCOL_KIND_DONATION,
sample_codes_from_orm_rows,
sample_codes_from_payload,
schedule_sample_collect_notify,
)
router = APIRouter(prefix="/web/admin", tags=["科研捐赠管理"])
PROTOCOL_LIST_MENU = "biz:cellStorageProtocol:list"
PROTOCOL_AUDIT_MENU = "biz:cellStorageProtocolAudit:list"
def _get_tenant_no(db: Session, user: dict) -> str:
user_id = user.get("user_id")
@@ -133,24 +132,8 @@ def assert_donation_sensitive_fields(
*,
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)
"""保留钩子;不再校验菜单权限(与采集协议敏感字段策略对齐)。"""
_ = (db, user, body, creating)
# ---------------------------------------------------------------------------
@@ -260,7 +243,7 @@ async def get_research_donation_detail(
@router.post("/research-donations")
async def create_research_donation(
body: DonationCreateBody,
user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
assert_donation_sensitive_fields(db, user, body, creating=True)
@@ -277,6 +260,17 @@ async def create_research_donation(
tenant_no=tenant_no,
user_id=user_id,
)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=obj.id,
protocol_kind=PROTOCOL_KIND_DONATION,
before_codes=[],
submitted_codes=(
sample_codes_from_payload(body.samples)
if body.samples is not None
else None
),
)
return success_response(
rd_svc._donation_to_dict(obj, db, tenant_no),
message="创建成功",
@@ -294,7 +288,7 @@ async def create_research_donation(
async def update_research_donation(
donation_id: str,
body: DonationUpdateBody,
user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
assert_donation_sensitive_fields(db, user, body, creating=False)
@@ -302,6 +296,10 @@ async def update_research_donation(
user_id = _get_current_user_id(user)
try:
data = body.model_dump(exclude_unset=True, by_alias=True)
before_codes = sample_codes_from_orm_rows(
rd_svc.list_samples(db, donation_id, tenant_no)
)
unset_fields = body.model_dump(exclude_unset=True)
obj = rd_svc.update_donation(
db,
donation_id,
@@ -309,6 +307,17 @@ async def update_research_donation(
tenant_no=tenant_no,
user_id=user_id,
)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=obj.id,
protocol_kind=PROTOCOL_KIND_DONATION,
before_codes=before_codes,
submitted_codes=(
sample_codes_from_payload(body.samples)
if "samples" in unset_fields
else None
),
)
return success_response(
rd_svc._donation_to_dict(obj, db, tenant_no),
message="更新成功",
+2 -2
View File
@@ -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, require_strict_menu_code
from app.core.menu_guard import require_admin_or_super_admin, require_any_menu_code
from app.core.response import error_response, success_response
from app.core.utils import parse_datetime
from app.crud import sample_insurance as insurance_crud
@@ -16,7 +16,7 @@ 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")
_WRITE_MENU = require_admin_or_super_admin
def _get_tenant_no(db: Session, user: dict) -> str:
+98
View File
@@ -0,0 +1,98 @@
"""样本编码全链路纠错 API"""
from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import error_response, success_response
from app.models.models import SysUser
from app.services import sample_code_correction as svc
from app.services.sample_code_correction import SampleCodeCorrectionError
router = APIRouter(prefix="/web/admin", tags=["样本编码纠错"])
_MENU = require_admin_or_super_admin
def _get_tenant_no(db: Session, user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失,无法确定租户")
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
if not sys_user or not sys_user.tenant_no:
raise PermissionError("用户租户信息缺失")
return sys_user.tenant_no
def _get_user_id(user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失")
return str(user_id)
class SampleCodeCorrectionBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
reception_source: str = Field(..., alias="receptionSource")
old_sample_code: str = Field(..., alias="oldSampleCode")
new_sample_code: str = Field(..., alias="newSampleCode")
new_sample_code_id: Optional[str] = Field(None, alias="newSampleCodeId")
reason: Optional[str] = None
@router.post("/sample-code-corrections/preview")
async def preview_sample_code_correction(
body: SampleCodeCorrectionBody,
db: Session = Depends(get_db),
user: dict = Depends(_MENU),
):
try:
tenant_no = _get_tenant_no(db, user)
data = svc.preview_correction(
db,
tenant_no=tenant_no,
reception_source=body.reception_source,
old_sample_code=body.old_sample_code,
new_sample_code=body.new_sample_code,
new_sample_code_id=body.new_sample_code_id,
)
return success_response(data)
except PermissionError as e:
return error_response(str(e))
except Exception as e:
return error_response(f"预览失败: {e}")
@router.post("/sample-code-corrections/apply")
async def apply_sample_code_correction(
body: SampleCodeCorrectionBody,
db: Session = Depends(get_db),
user: dict = Depends(_MENU),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_user_id(user)
data = svc.apply_correction(
db,
tenant_no=tenant_no,
reception_source=body.reception_source,
old_sample_code=body.old_sample_code,
new_sample_code=body.new_sample_code,
new_sample_code_id=body.new_sample_code_id,
reason=body.reason,
user_id=user_id,
)
return success_response(data, message="样本编码已更正")
except SampleCodeCorrectionError as e:
db.rollback()
return error_response(str(e))
except PermissionError as e:
db.rollback()
return error_response(str(e))
except Exception as e:
db.rollback()
return error_response(f"更正失败: {e}")
+4 -4
View File
@@ -13,15 +13,15 @@ from app.models.models import SysUser
router = APIRouter(prefix="/web/admin", tags=["储存证质检信息"])
# 市场 / 质检 / 生产三端共用读写(方案 A:完整表后写覆盖)
READ_MENUS = (
"biz:qcWorkOrder:list",
"biz:myQcTask:list",
"production:work-order:list",
"production:my-task:list",
"cell-storage:sample-info:list",
)
WRITE_MENUS = (
"biz:qcWorkOrder:list",
"biz:myQcTask:list",
)
WRITE_MENUS = READ_MENUS
def _get_tenant_no(db: Session, user: dict) -> str:
+23 -1
View File
@@ -133,13 +133,23 @@ class Settings(BaseSettings):
WX_TEMPLATE_DELIVERY_WARNING: str = "" # 配送时效预警模板ID
# 微信服务号(OA 审批/抄送通知)
# 总开关:false 时不发送审批/抄送/申请人进度模板消息(模板未就绪或微信能力不可用时关闭)
# 总开关:false 时不发送审批/抄送/申请人进度/评论@提到/基础实验交付提醒/样本采集通知模板消息
WX_MP_NOTIFY_ENABLED: bool = False
WX_MP_APPID: str = ""
WX_MP_SECRET: str = ""
WX_MP_TEMPLATE_APPROVAL: str = ""
WX_MP_TEMPLATE_CC: str = ""
WX_MP_TEMPLATE_APPLICANT_PROGRESS: str = ""
# 基础实验交付日前一天提醒(出库提醒模板)
WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY: str = (
"oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM"
)
# 协议新增样本采集通知(工单类模板)
# 工单标题 thing2 / 发起人 thing8 / 发起时间 time3 / 任务类型 phrase10 / 工单类型 thing17(样本类型名称)
WX_MP_TEMPLATE_SAMPLE_COLLECT: str = (
"iOnQ9k2mavAOwFbp_6yH4t1SHqpEkaiwNWKueY0kpwE"
)
WX_MP_SAMPLE_COLLECT_ROLE_NAME: str = "样本采集通知人员"
# 审批模板:申请事项/操作人(申请人)/当前环节/操作时间/申请类型(流程名)
# 生产字段:thing12 / thing18 / thing7 / time17 / thing8
WX_MP_TPL_FIELD_TITLE: str = "thing12"
@@ -158,6 +168,18 @@ class Settings(BaseSettings):
WX_MP_TPL_PROGRESS_FIELD_APPROVER: str = "thing6"
WX_MP_TPL_PROGRESS_FIELD_RESULT: str = "const4"
WX_MP_TPL_PROGRESS_FIELD_TIME: str = "time11"
# 基础实验交付提醒:客户名称/产品名称/件数/收货联系人/出库时间
WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER: str = "thing6"
WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT: str = "thing10"
WX_MP_TPL_BE_DELIVERY_FIELD_QTY: str = "character_string8"
WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT: str = "thing7"
WX_MP_TPL_BE_DELIVERY_FIELD_TIME: str = "time14"
# 样本采集通知:工单标题/发起人/发起时间/任务类型/工单类型(样本类型名称)
WX_MP_TPL_SC_FIELD_TITLE: str = "thing2"
WX_MP_TPL_SC_FIELD_APPLICANT: str = "thing8"
WX_MP_TPL_SC_FIELD_TIME: str = "time3"
WX_MP_TPL_SC_FIELD_TYPE: str = "phrase10"
WX_MP_TPL_SC_FIELD_SAMPLE_TYPE: str = "thing17"
# 与 docs/sql/zkxw.sql 基线一致:登录按租户 + sys_user.user_no
DEFAULT_TENANT_NO: str = "zkxw"
+22 -1
View File
@@ -1,7 +1,7 @@
"""协议状态展示(含作废类型后缀)。"""
from __future__ import annotations
from typing import Dict, Optional
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
@@ -12,6 +12,27 @@ VOID_TYPE_DICT = "xyzflx"
# 协议/捐赠单无效态(与样本接收、生产工单列表过滤口径一致)
INACTIVE_PROTOCOL_STATUSES = ("yzf", "sdfq")
# 科研捐赠质检列表/角标默认协议状态:待生效、生效中
DEFAULT_QC_DONATION_PROTOCOL_STATUSES = ("dsx", "sxz")
def resolve_research_donation_protocol_status_filter(
protocol_status: Optional[str],
) -> Tuple[str, List[str]]:
"""解析科研捐赠质检列表的协议状态筛选语义。
Returns:
("in", statuses) — 按所选状态 IN 过滤
("none", []) — 显式空选择,无匹配结果
("default", DEFAULT_QC_DONATION_PROTOCOL_STATUSES) — 未传参,与列表默认一致
"""
if protocol_status is None:
return ("default", list(DEFAULT_QC_DONATION_PROTOCOL_STATUSES))
statuses = [s.strip() for s in str(protocol_status).split(",") if s.strip()]
if not statuses:
return ("none", [])
return ("in", statuses)
_PROTOCOL_STATUS_LABELS = {
"dsx": "待生效",
"sxz": "生效中",
+14 -6
View File
@@ -21,6 +21,7 @@ from app.models.models import (
BizStorageFacilitySlot,
)
from app.services.storage_facility_code import build_full_slot_code
from app.crud.storage_facility import search_slot_ids_by_keyword
_CELL_PRODUCT_STATUSES = frozenset(
{"frozen", "in_production", "sold", "used", "voided", "thawed"}
@@ -289,20 +290,27 @@ def list_cell_products(
source_type: Optional[str] = None,
user_info=None,
) -> Dict[str, Any]:
kw = (keyword or "").strip()
matched_slot_ids: List[str] = (
search_slot_ids_by_keyword(db, tenant_no, kw) if kw else []
)
def _filtered():
q0 = db.query(BizCellProduct).filter(
BizCellProduct.del_flag == 0,
BizCellProduct.tenant_no == tenant_no,
)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q0 = q0.filter(
or_(
BizCellProduct.product_code.ilike(like),
BizCellProduct.product_name.ilike(like),
keyword_conds = [
BizCellProduct.product_code.ilike(like),
BizCellProduct.product_name.ilike(like),
]
if matched_slot_ids:
keyword_conds.append(
BizCellProduct.storage_slot_id.in_(matched_slot_ids)
)
)
q0 = q0.filter(or_(*keyword_conds))
pc = (product_code or "").strip()
if pc:
q0 = q0.filter(BizCellProduct.product_code.like(f"%{pc}%"))
+257
View File
@@ -0,0 +1,257 @@
"""按行同步子表。
带 ID 的行原地修改,没有 ID 时按业务键对上尚未占用的原行,其余才新增。
本次提交里没有的活动行才软删除。不要整表软删后再插入,否则外键会指向已删除的行。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
def _item_id(item: Dict[str, Any]) -> str:
return str(item.get("id") or "").strip()
def _sort_key(row: Any):
order_num = getattr(row, "order_num", None)
create_time = getattr(row, "create_time", None)
return (
order_num if order_num is not None else 0,
create_time or datetime.min,
str(getattr(row, "id", "") or ""),
)
def sync_child_rows(
db: Session,
model,
*,
parent_attr: str,
parent_id: str,
items: List[Dict[str, Any]],
build_row: Callable[[str], Any],
apply_fields: Callable[[Any, Dict[str, Any], int, datetime], None],
tenant_no: Optional[str] = None,
tenant_attr: str = "tenant_no",
user_id: Optional[str] = None,
now: Optional[datetime] = None,
natural_key: Optional[Callable[[Dict[str, Any]], str]] = None,
row_natural_key: Optional[Callable[[Any], str]] = None,
duplicate_message: str = "编号重复",
foreign_message: str = "记录不属于当前数据",
extra_filters: Optional[Dict[str, Any]] = None,
reuse_deleted: bool = False,
) -> List[Any]:
now = now or datetime.now()
payload = list(items or [])
seen_ids = set()
for item in payload:
row_id = _item_id(item)
if not row_id:
continue
if row_id in seen_ids:
raise ValueError(duplicate_message)
seen_ids.add(row_id)
def _scoped(query):
query = query.filter(getattr(model, parent_attr) == parent_id)
if tenant_no is not None and hasattr(model, tenant_attr):
query = query.filter(getattr(model, tenant_attr) == tenant_no)
for key, value in (extra_filters or {}).items():
query = query.filter(getattr(model, key) == value)
return query
active_rows = _scoped(db.query(model).filter(model.del_flag == 0)).all()
active_by_id = {row.id: row for row in active_rows}
for row_id in seen_ids:
if row_id in active_by_id:
continue
other = db.query(model).filter(model.id == row_id).first()
if other is None:
continue
other_parent = getattr(other, parent_attr)
other_tenant = getattr(other, tenant_attr, None) if tenant_no is not None else tenant_no
if other_parent != parent_id or (
tenant_no is not None and (other_tenant or "") != tenant_no
):
raise ValueError(foreign_message)
queues: Dict[str, List[Any]] = {}
deleted_queues: Dict[str, List[Any]] = {}
if natural_key is not None and row_natural_key is not None:
for row in sorted(active_rows, key=_sort_key):
key = (row_natural_key(row) or "").strip()
if not key:
continue
queues.setdefault(key, []).append(row)
if reuse_deleted:
deleted_rows = _scoped(db.query(model).filter(model.del_flag == 1)).all()
for row in sorted(deleted_rows, key=_sort_key):
key = (row_natural_key(row) or "").strip()
if not key:
continue
deleted_queues.setdefault(key, []).append(row)
claimed = set()
assigned: List[Optional[Any]] = [None] * len(payload)
def _take(row: Any) -> Any:
if row.id in claimed:
raise ValueError(duplicate_message)
claimed.add(row.id)
return row
for idx, item in enumerate(payload):
row_id = _item_id(item)
if not row_id:
continue
if row_id in active_by_id:
assigned[idx] = _take(active_by_id[row_id])
continue
row = db.query(model).filter(model.id == row_id).first()
if row is None:
row = build_row(row_id)
db.add(row)
assigned[idx] = _take(row)
for bucket in queues.values():
bucket[:] = [row for row in bucket if row.id not in claimed]
for idx, item in enumerate(payload):
if assigned[idx] is not None:
continue
row = None
if natural_key is not None:
key = (natural_key(item) or "").strip()
bucket = queues.get(key) if key else None
while bucket:
candidate = bucket.pop(0)
if candidate.id not in claimed:
row = candidate
break
if row is None and reuse_deleted and natural_key is not None:
key = (natural_key(item) or "").strip()
bucket = deleted_queues.get(key) if key else None
while bucket:
candidate = bucket.pop(0)
if candidate.id not in claimed:
row = candidate
break
if row is None:
row = build_row(generate_uuid())
db.add(row)
assigned[idx] = _take(row)
result: List[Any] = []
for idx, item in enumerate(payload):
row = assigned[idx]
setattr(row, parent_attr, parent_id)
if tenant_no is not None and hasattr(row, tenant_attr):
setattr(row, tenant_attr, tenant_no)
apply_fields(row, item, idx, now)
row.del_flag = 0
if hasattr(row, "update_by"):
row.update_by = user_id
if hasattr(row, "update_time"):
row.update_time = now
db.add(row)
result.append(row)
if reuse_deleted and row_natural_key is not None:
keys_to_clear = set()
for row in result:
keys_to_clear.add((row_natural_key(row) or "").strip())
for row in active_rows:
if row.id not in claimed:
keys_to_clear.add((row_natural_key(row) or "").strip())
removed_tombstone = False
for key in keys_to_clear:
if not key:
continue
for tomb in deleted_queues.get(key, []):
if tomb.id in claimed:
continue
db.delete(tomb)
removed_tombstone = True
if removed_tombstone:
db.flush()
for row in active_rows:
if row.id in claimed:
continue
row.del_flag = 1
if hasattr(row, "update_by"):
row.update_by = user_id
if hasattr(row, "update_time"):
row.update_time = now
db.add(row)
return result
def sync_link_rows(
db: Session,
model,
*,
parent_attr: str,
parent_id: str,
link_attr: str,
link_ids: List[str],
tenant_no: Optional[str] = None,
user_id: Optional[str] = None,
duplicate_message: str = "关联重复",
foreign_message: str = "关联不属于当前数据",
) -> List[Any]:
"""按关联 id 更新关系行。重新加回已删除的关联时恢复原行,避免唯一索引冲突。"""
now = datetime.now()
ordered: List[Dict[str, Any]] = []
seen = set()
for raw in link_ids or []:
link_id = str(raw or "").strip()
if not link_id or link_id in seen:
continue
seen.add(link_id)
ordered.append({link_attr: link_id})
def build_row(row_id: str):
data = {
"id": row_id,
parent_attr: parent_id,
link_attr: "",
"create_by": user_id,
"create_dept_id": "",
"create_time": now,
}
if tenant_no is not None and hasattr(model, "tenant_no"):
data["tenant_no"] = tenant_no
return model(**data)
def apply_row(row: Any, item: Dict[str, Any], idx: int, _now: datetime) -> None:
setattr(row, link_attr, item[link_attr])
if tenant_no is not None and hasattr(row, "tenant_no"):
row.tenant_no = tenant_no
if hasattr(row, "order_num"):
row.order_num = idx
return sync_child_rows(
db,
model,
parent_attr=parent_attr,
parent_id=parent_id,
tenant_no=tenant_no,
items=ordered,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: item[link_attr],
row_natural_key=lambda row: str(getattr(row, link_attr) or "").strip(),
duplicate_message=duplicate_message,
foreign_message=foreign_message,
reuse_deleted=True,
)
+301 -107
View File
@@ -12,6 +12,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date
from app.crud.child_row_sync import sync_child_rows
from app.crud.base import camel_to_snake
from app.crud.biz_no_generator import allocate_next_biz_no
from app.crud.protocol_donation_convert import (
@@ -45,8 +46,11 @@ from app.models.models import (
BizProtocolPayment,
BizProtocolPaymentDetail,
BizProtocolSample,
BizProtocolSamplePayment,
BizProtocolSampleTransport,
BizProtocolStorageCert,
BizProductionWorkOrder,
BizQcSmsNotification,
BizQcWorkOrder,
BizSampleReception,
BizStorageReleaseReview,
@@ -169,6 +173,35 @@ def validate_sample_codes_not_used(
raise ValueError(f"样本编码「{first}」已被占用")
def validate_persisted_sample_codes_immutable(
existing_samples: list,
samples: list,
) -> None:
"""已落库样本(payload 带本单现有 id)的编码不可在协议编辑中修改。"""
by_id = {
str(getattr(s, "id", "") or "").strip(): s
for s in (existing_samples or [])
if str(getattr(s, "id", "") or "").strip()
}
if not by_id:
return
for raw_item in samples or []:
raw = _normalize_fields(raw_item if isinstance(raw_item, dict) else {})
sid = (raw.get("id") or "").strip()
if not sid or sid not in by_id:
continue
old = by_id[sid]
old_code = (getattr(old, "sample_code", None) or "").strip()
old_code_id = (getattr(old, "sample_code_id", None) or "").strip()
new_code = (raw.get("sample_code") or "").strip()
new_code_id = (raw.get("sample_code_id") or "").strip()
if new_code != old_code or new_code_id != old_code_id:
label = old_code or sid
raise ValueError(
f"已落库样本「{label}」的编码不可在协议编辑中修改,请使用客户服务「更正编码」"
)
def total_pages(total: int, page_size: int) -> int:
if total <= 0:
return 0
@@ -2551,7 +2584,7 @@ def create_collection_protocol(
protocol_attachments=(protocol_attachments or "").strip() or None,
consent_form_urls=(consent_form_urls or "").strip() or None,
health_survey_form_urls=(health_survey_form_urls or "").strip() or None,
register_date=(register_date or "").strip() or None,
register_date=_normalize_yyyymmdd_date(register_date),
collector_user_id=(collector_user_id or "").strip() or None,
protocol_total_amount=total_amt,
source_presign_order_id=(source_presign_order_id or "").strip() or None,
@@ -2655,7 +2688,7 @@ def update_collection_protocol(
if health_survey_form_urls is not None:
obj.health_survey_form_urls = health_survey_form_urls.strip() or None
if register_date is not None:
obj.register_date = register_date.strip() or None
obj.register_date = _normalize_yyyymmdd_date(register_date)
if effective_time is not None:
obj.effective_time = effective_time
if effective_desc is not None:
@@ -2944,6 +2977,39 @@ def list_samples(db: Session, collection_protocol_id: str, tenant_no: str) -> Li
)
def _retarget_protocol_sample_id(
db: Session,
model,
*,
old_ids: List[str],
old_id_to_code: Dict[str, str],
new_code_to_id: Dict[str, str],
tenant_no: str,
user_id: Optional[str],
now: datetime,
del_flag_alive=0,
) -> None:
rows = (
db.query(model)
.filter(
model.protocol_sample_id.in_(old_ids),
model.tenant_no == tenant_no,
model.del_flag == del_flag_alive,
)
.all()
)
for row in rows:
old_sid = (row.protocol_sample_id or "").strip()
code = old_id_to_code.get(old_sid) or (getattr(row, "sample_code", None) or "").strip()
new_sid = new_code_to_id.get(code) if code else None
if not new_sid or new_sid == old_sid:
continue
row.protocol_sample_id = new_sid
row.update_by = user_id
row.update_time = now
db.add(row)
def _migrate_sample_child_refs_on_replace(
db: Session,
*,
@@ -2952,31 +3018,92 @@ def _migrate_sample_child_refs_on_replace(
new_code_to_id: Dict[str, str],
user_id: Optional[str],
) -> None:
"""协议样本行重建后,将接收/质检工单的 protocol_sample_id 迁移到新样本 ID。"""
"""样本行 ID 变化时,把仍指向旧 ID 的下游改到同一编码的新样本。"""
if not old_id_to_code or not new_code_to_id:
return
old_ids = list(old_id_to_code.keys())
now = datetime.now()
for model in (BizSampleReception, BizQcWorkOrder):
rows = (
db.query(model)
.filter(
model.protocol_sample_id.in_(old_ids),
model.tenant_no == tenant_no,
model.del_flag == 0,
)
.all()
for model in (
BizSampleReception,
BizQcWorkOrder,
BizProductionWorkOrder,
BizProtocolSamplePayment,
):
_retarget_protocol_sample_id(
db,
model,
old_ids=old_ids,
old_id_to_code=old_id_to_code,
new_code_to_id=new_code_to_id,
tenant_no=tenant_no,
user_id=user_id,
now=now,
)
for row in rows:
old_sid = (row.protocol_sample_id or "").strip()
code = old_id_to_code.get(old_sid) or (row.sample_code or "").strip()
new_sid = new_code_to_id.get(code) if code else None
if not new_sid:
continue
row.protocol_sample_id = new_sid
row.update_by = user_id
row.update_time = now
db.add(row)
_retarget_protocol_sample_id(
db,
BizQcSmsNotification,
old_ids=old_ids,
old_id_to_code=old_id_to_code,
new_code_to_id=new_code_to_id,
tenant_no=tenant_no,
user_id=user_id,
now=now,
del_flag_alive="0",
)
def _owned_child_row(
db: Session,
model,
row_id: str,
*,
parent_attr: str,
parent_id: str,
tenant_no: str,
message: str,
):
row = db.query(model).filter(model.id == row_id).first()
if row is None:
return None
if getattr(row, parent_attr) != parent_id or (row.tenant_no or "") != tenant_no:
raise ValueError(message)
return row
def _apply_protocol_sample_fields(
obj: BizProtocolSample,
raw: dict,
*,
order_num: int,
user_id: Optional[str],
now: datetime,
) -> None:
str_fields = [
"sample_type_id", "sample_code_id", "sample_code", "unit_id",
"sample_status", "storage_in_date", "detect_start_date",
"void_date", "storage_expire_date",
]
obj.order_num = order_num
obj.remark = (raw.get("remark") or "").strip() or None
obj.update_by = user_id
obj.update_time = now
obj.del_flag = 0
for key in str_fields:
if key == "sample_status":
continue
if key in raw:
setattr(obj, key, (raw[key] or "").strip() or None)
if "sample_status" in raw:
status_val = raw.get("sample_status")
obj.sample_status = (
str(status_val).strip() if status_val is not None and str(status_val).strip() else None
)
for key in ("collect_amount", "price", "original_price"):
if key in raw and raw[key] is not None:
setattr(obj, key, Decimal(str(raw[key])))
if "storage_years" in raw:
obj.storage_years = raw["storage_years"]
apply_sample_special_fields(obj, raw)
def save_samples(
@@ -2989,77 +3116,103 @@ def save_samples(
commit: bool = True,
) -> List[BizProtocolSample]:
existing = list_samples(db, collection_protocol_id, tenant_no)
old_id_to_code = {
s.id: (s.sample_code or "").strip()
for s in existing
if (s.sample_code or "").strip()
validate_persisted_sample_codes_immutable(existing, samples)
by_id = {s.id: s for s in existing}
by_code: Dict[str, List[BizProtocolSample]] = {}
for sample in existing:
code = (sample.sample_code or "").strip()
if code:
by_code.setdefault(code, []).append(sample)
claimed = set()
planned: List[tuple] = []
for idx, raw_item in enumerate(samples or []):
raw = _normalize_fields(raw_item)
validate_sample_special_raw(raw, index=idx)
sid = (raw.get("id") or "").strip()
code = (raw.get("sample_code") or "").strip()
row = None
if sid and sid in by_id:
row = by_id[sid]
elif sid:
row = _owned_child_row(
db,
BizProtocolSample,
sid,
parent_attr="collection_protocol_id",
parent_id=collection_protocol_id,
tenant_no=tenant_no,
message="样本不属于当前协议",
)
if row is None and code:
for candidate in by_code.get(code, []):
if candidate.id not in claimed:
row = candidate
break
if row is not None:
if row.id in claimed:
raise ValueError("样本编号重复")
claimed.add(row.id)
planned.append((idx, raw, row))
kept_codes = {
(sample.sample_code or "").strip()
for sample in existing
if sample.id in claimed and (sample.sample_code or "").strip()
}
for s in existing:
s.del_flag = 1
s.update_by = user_id
s.update_time = datetime.now()
db.add(s)
if existing:
db.flush()
validate_sample_codes_not_used(
db,
tenant_no=tenant_no,
samples=samples,
samples=[
raw
for _idx, raw, row in planned
if row is None or (raw.get("sample_code") or "").strip() not in kept_codes
],
exclude_protocol_id=collection_protocol_id,
)
_STR_FIELDS = [
"sample_type_id", "sample_code_id", "sample_code", "unit_id",
"sample_status", "storage_in_date", "detect_start_date",
"void_date", "storage_expire_date",
]
_DEC_FIELDS = ["collect_amount", "price", "original_price"]
_INT_FIELDS = ["storage_years"]
result = []
for idx, raw in enumerate(samples):
raw = _normalize_fields(raw)
validate_sample_special_raw(raw, index=idx)
obj = BizProtocolSample(
id=generate_uuid(),
collection_protocol_id=collection_protocol_id,
tenant_no=tenant_no,
order_num=idx,
remark=(raw.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
)
for k in _STR_FIELDS:
if k == "sample_status":
continue
if k in raw:
setattr(obj, k, (raw[k] or "").strip() or None)
# 样本状态:显式支持 null/空字符串,避免 ORM/库默认值 ycj 回填
if "sample_status" in raw:
status_val = raw.get("sample_status")
obj.sample_status = (
str(status_val).strip() if status_val is not None and str(status_val).strip() else None
now = datetime.now()
result: List[BizProtocolSample] = []
for idx, raw, row in planned:
is_new = row is None
if is_new:
row = BizProtocolSample(
id=(raw.get("id") or "").strip() or generate_uuid(),
collection_protocol_id=collection_protocol_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
)
for k in _DEC_FIELDS:
if k in raw and raw[k] is not None:
setattr(obj, k, Decimal(str(raw[k])))
for k in _INT_FIELDS:
if k in raw:
setattr(obj, k, raw[k])
apply_sample_special_fields(obj, raw)
db.add(obj)
result.append(obj)
_apply_protocol_sample_fields(
row,
raw,
order_num=idx,
user_id=user_id,
now=now,
)
row.collection_protocol_id = collection_protocol_id
row.tenant_no = tenant_no
db.add(row)
result.append(row)
for sample in existing:
if sample.id in claimed:
continue
sample.del_flag = 1
sample.update_by = user_id
sample.update_time = now
db.add(sample)
removed_id_to_code = {
sample.id: (sample.sample_code or "").strip()
for sample in existing
if sample.id not in {row.id for row in result} and (sample.sample_code or "").strip()
}
new_code_to_id = {
(o.sample_code or "").strip(): o.id
for o in result
if (o.sample_code or "").strip()
(row.sample_code or "").strip(): row.id
for row in result
if (row.sample_code or "").strip()
}
_migrate_sample_child_refs_on_replace(
db,
tenant_no=tenant_no,
old_id_to_code=old_id_to_code,
old_id_to_code=removed_id_to_code,
new_code_to_id=new_code_to_id,
user_id=user_id,
)
@@ -3142,7 +3295,8 @@ def save_sample_transports(
commit: bool = True,
) -> None:
"""写回样本运输明细。运费批次改造后仍保留预计到达等字段(可仅有 eta_time)。"""
_soft_delete_transports_for_protocol(db, collection_protocol_id, tenant_no, user_id)
now = datetime.now()
payload = []
for idx, sample_obj in enumerate(saved_samples):
if idx >= len(input_samples):
break
@@ -3151,7 +3305,6 @@ def save_sample_transports(
if not isinstance(transport_raw, dict):
transport_raw = {}
tnorm = _normalize_fields(transport_raw)
# 移动端可能把 etaTime 放在样本顶层
if not tnorm.get("eta_time") and raw.get("eta_time"):
tnorm["eta_time"] = raw.get("eta_time")
@@ -3161,37 +3314,71 @@ def save_sample_transports(
eta_time = parse_datetime(eta_time)
if not transport_type and not eta_time:
continue
express_fee = tnorm.get("express_fee")
if express_fee is not None and express_fee != "":
express_fee = Decimal(str(express_fee))
else:
express_fee = None
payload.append(
{
"id": (tnorm.get("id") or "").strip(),
"sample_id": sample_obj.id,
"sample_code": sample_obj.sample_code or None,
"transport_type": transport_type,
"express_company_id": (tnorm.get("express_company_id") or "").strip() or None,
"waybill_no": (tnorm.get("waybill_no") or "").strip() or None,
"dest_site_id": (tnorm.get("dest_site_id") or "").strip() or None,
"express_fee": express_fee,
"eta_time": eta_time,
"carrier_name": (tnorm.get("carrier_name") or "").strip() or None,
"carrier_phone": (tnorm.get("carrier_phone") or "").strip() or None,
"remark": (tnorm.get("remark") or "").strip() or None,
"order_num": idx,
}
)
obj = BizProtocolSampleTransport(
id=generate_uuid(),
def build_row(row_id):
return BizProtocolSampleTransport(
id=row_id,
collection_protocol_id=collection_protocol_id,
sample_id=sample_obj.id,
sample_code=sample_obj.sample_code or None,
transport_type=transport_type,
express_company_id=(tnorm.get("express_company_id") or "").strip() or None,
waybill_no=(tnorm.get("waybill_no") or "").strip() or None,
dest_site_id=(tnorm.get("dest_site_id") or "").strip() or None,
express_fee=express_fee,
eta_time=eta_time,
carrier_name=(tnorm.get("carrier_name") or "").strip() or None,
carrier_phone=(tnorm.get("carrier_phone") or "").strip() or None,
sample_id="",
transport_type="",
tenant_no=tenant_no,
order_num=idx,
remark=(tnorm.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
create_time=now,
)
db.add(obj)
def apply_row(row, item, _idx, _now):
row.sample_id = item["sample_id"]
row.sample_code = item["sample_code"]
row.transport_type = item["transport_type"]
row.express_company_id = item["express_company_id"]
row.waybill_no = item["waybill_no"]
row.dest_site_id = item["dest_site_id"]
row.express_fee = item["express_fee"]
row.eta_time = item["eta_time"]
row.carrier_name = item["carrier_name"]
row.carrier_phone = item["carrier_phone"]
row.remark = item["remark"]
row.order_num = item["order_num"]
sync_child_rows(
db,
BizProtocolSampleTransport,
parent_attr="collection_protocol_id",
parent_id=collection_protocol_id,
tenant_no=tenant_no,
items=payload,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: item["sample_id"],
row_natural_key=lambda row: row.sample_id or "",
duplicate_message="样本运输重复",
foreign_message="运输记录不属于当前协议",
)
if commit:
db.commit()
else:
@@ -4036,6 +4223,7 @@ def upsert_storage_cert(
collection_protocol_id: str,
tenant_no: str,
user_id: Optional[str],
commit: bool = True,
**fields,
) -> BizProtocolStorageCert:
fields = _normalize_fields(fields)
@@ -4052,8 +4240,11 @@ def upsert_storage_cert(
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.commit()
db.refresh(obj)
if commit:
db.commit()
db.refresh(obj)
else:
db.flush()
return obj
obj = BizProtocolStorageCert(
id=generate_uuid(),
@@ -4075,8 +4266,11 @@ def upsert_storage_cert(
if "handle_time" in fields:
obj.handle_time = fields.get("handle_time")
db.add(obj)
db.commit()
db.refresh(obj)
if commit:
db.commit()
db.refresh(obj)
else:
db.flush()
return obj
+2 -2
View File
@@ -24,7 +24,7 @@ def _resolve_issued_user_display_name(
uid = (user_id or "").strip()
if not uid:
return None
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
u = db.query(SysUser).filter(SysUser.id == uid).first()
if not u:
return None
return (u.nick_name or u.user_no or "").strip() or None
@@ -41,7 +41,7 @@ def enrich_issued_user_names(db: Session, rows: List[Dict[str, Any]]) -> None:
return
users = (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(missing_uids), SysUser.del_flag == 0)
.filter(SysUser.id.in_(missing_uids))
.all()
)
name_map = {(u.id or ""): ((u.nick_name or u.user_no or "").strip()) for u in users}
+32 -23
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import BizConsumablePack, BizConsumablePackItem
@@ -230,37 +231,45 @@ def update_consumable_pack(
db.add(obj)
if items is not None:
existing = (
db.query(BizConsumablePackItem)
.filter(and_(BizConsumablePackItem.pack_id == pack_id, BizConsumablePackItem.del_flag == 0))
.all()
)
now = datetime.now()
for ei in existing:
ei.del_flag = 1
ei.update_time = now
db.add(ei)
for it in items:
item_obj = BizConsumablePackItem(
id=generate_uuid(),
def build_item(row_id):
return BizConsumablePackItem(
id=row_id,
pack_id=pack_id,
consumable_code=(it.get("consumable_code") or "").strip(),
consumable_name=(it.get("consumable_name") or "").strip(),
spec_model=(it.get("spec_model") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
consumable_code="",
consumable_name="",
tenant_no=obj.tenant_no,
order_num=0,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(item_obj)
def apply_item(row, it, _idx, _now):
row.consumable_code = (it.get("consumable_code") or "").strip()
row.consumable_name = (it.get("consumable_name") or "").strip()
row.spec_model = (it.get("spec_model") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = 0
sync_child_rows(
db,
BizConsumablePackItem,
parent_attr="pack_id",
parent_id=pack_id,
tenant_no=obj.tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_item,
apply_fields=apply_item,
natural_key=lambda it: (it.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材包明细重复",
foreign_message="耗材包明细不属于当前耗材包",
)
db.commit()
db.refresh(obj)
+82 -54
View File
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
from app.models.models import SysUser
from app.models.material import (
@@ -442,6 +443,44 @@ def _resolve_unused_item_fields(
return out
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
usage = (usage_id or "").strip()
if usage:
return f"usage:{usage}"
batch = (batch_id or "").strip()
if batch:
return f"batch:{batch}"
return ""
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
return {
"id": str(it.get("id") or "").strip(),
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
"material_name": name,
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
"unit": (it.get("unit") or "").strip() or None,
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
"manufacturer": (it.get("manufacturer") or "").strip() or None,
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
"holder": (it.get("holder") or "").strip() or None,
"unused_qty": Decimal(str(qty)),
"remark": (it.get("remark") or "").strip() or None,
}
def save_unused_lines(
db: Session,
check_id: str,
@@ -458,72 +497,61 @@ def save_unused_lines(
raise ValueError("仅进行中的盘点单可登记未用完物料")
now = datetime.now()
old_rows = (
db.query(BizMaterialStockCheckUnusedLine)
.filter(
BizMaterialStockCheckUnusedLine.check_id == check_id,
BizMaterialStockCheckUnusedLine.del_flag == 0,
)
.all()
)
for r in old_rows:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
db.add(r)
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
count = 0
for it in items or []:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
qty_dec = Decimal(str(qty))
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
row = BizMaterialStockCheckUnusedLine(
id=generate_uuid(),
def build_row(row_id: str):
return BizMaterialStockCheckUnusedLine(
id=row_id,
check_id=check_id,
usage_id=usage_id,
batch_id=batch_id,
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
material_name=name,
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
stock_in_date=stock_in_date,
expire_date=expire_date,
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
manufacturer=(it.get("manufacturer") or "").strip() or None,
unit_price=(
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
else None
),
holder=(it.get("holder") or "").strip() or None,
unused_qty=qty_dec,
material_name="",
unused_qty=0,
tenant_no=tenant_no,
remark=(it.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
count += 1
def apply_row(row, it, idx, _now):
row.usage_id = it["usage_id"]
row.batch_id = it["batch_id"]
row.material_code = it["material_code"]
row.material_name = it["material_name"]
row.spec_model = it["spec_model"]
row.unit = it["unit"]
row.stock_in_no = it["stock_in_no"]
row.stock_in_date = it["stock_in_date"]
row.expire_date = it["expire_date"]
row.catalog_no = it["catalog_no"]
row.batch_no = it["batch_no"]
row.manufacturer = it["manufacturer"]
row.unit_price = it["unit_price"]
row.holder = it["holder"]
row.unused_qty = it["unused_qty"]
row.remark = it["remark"]
row.order_num = idx
saved = sync_child_rows(
db,
BizMaterialStockCheckUnusedLine,
parent_attr="check_id",
parent_id=check_id,
tenant_no=tenant_no,
items=prepared,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
duplicate_message="未用完明细重复",
foreign_message="未用完明细不属于当前盘点单",
)
check.update_by = user_id
check.update_time = now
db.add(check)
db.commit()
return count
return len(saved)
def complete_check(
+49 -27
View File
@@ -9,6 +9,7 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud import cell_product as cell_product_svc
from app.models.models import (
BizCellProduct,
@@ -396,13 +397,6 @@ def save_outbound_material(
db, schedule_id, tenant_no, incoming_qc_ids, user_id, now
)
db.query(BizOutboundReleasePaper).filter(
BizOutboundReleasePaper.infusion_schedule_id == schedule_id,
BizOutboundReleasePaper.tenant_no == tenant_no,
BizOutboundReleasePaper.del_flag == 0,
).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
db.flush()
for idx, item in enumerate(cells or []):
cp_id = (item.get("cellProductId") or item.get("cell_product_id") or "").strip()
if not cp_id:
@@ -532,7 +526,8 @@ def save_outbound_material(
)
)
for idx, item in enumerate(paper_materials or []):
paper_payload = []
for item in paper_materials or []:
paper_name = (item.get("paperName") or item.get("paper_name") or "").strip()
attachment_url = (item.get("attachmentUrl") or item.get("attachment_url") or "").strip()
copies_raw = item.get("copies")
@@ -546,26 +541,53 @@ def save_outbound_material(
raise HTTPException(status_code=400, detail="纸质材料份数格式无效")
if copies <= 0:
raise HTTPException(status_code=400, detail="纸质材料份数必须大于0")
db.add(
BizOutboundReleasePaper(
id=generate_uuid(),
material_id=header.id,
infusion_schedule_id=schedule_id,
paper_name=paper_name,
copies=copies,
attachment_url=attachment_url,
tenant_no=tenant_no,
order_num=idx + 1,
remark=(item.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
paper_payload.append(
{
"paper_name": paper_name,
"copies": copies,
"attachment_url": attachment_url,
"remark": (item.get("remark") or "").strip() or None,
}
)
def build_paper(row_id):
return BizOutboundReleasePaper(
id=row_id,
material_id=header.id,
infusion_schedule_id=schedule_id,
paper_name="",
copies=1,
attachment_url="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
)
def apply_paper(row, item, idx, _now):
row.material_id = header.id
row.paper_name = item["paper_name"]
row.copies = item["copies"]
row.attachment_url = item["attachment_url"]
row.order_num = idx + 1
row.remark = item["remark"]
sync_child_rows(
db,
BizOutboundReleasePaper,
parent_attr="infusion_schedule_id",
parent_id=schedule_id,
tenant_no=tenant_no,
items=paper_payload,
user_id=user_id,
now=now,
build_row=build_paper,
apply_fields=apply_paper,
natural_key=lambda item: item["attachment_url"],
row_natural_key=lambda row: row.attachment_url or "",
duplicate_message="纸质材料重复",
foreign_message="纸质材料不属于当前输注计划",
)
db.flush()
return get_outbound_material(db, schedule_id, tenant_no)
@@ -524,6 +524,8 @@ def list_reviews_paginated(
"projectName": product.project_name or "",
"cellTypeName": product.cell_type_name or "",
"specText": product.spec_text or "",
"packageId": getattr(product, "package_id", None) or "",
"packageText": getattr(product, "package_text", None) or "",
"agreementPrice": _dec(product.agreement_price),
"actualPrice": _dec(product.actual_price),
"quantity": product.quantity,
@@ -604,6 +606,8 @@ def list_reviews_paginated(
"patientName": (patient.patient_name or "") if patient else "",
"cellType": s.cell_type or (plan.cell_type or "") if plan else (s.cell_type or ""),
"specText": s.spec_text or (plan.spec_text or "") if plan else (s.spec_text or ""),
"packageId": getattr(s, "package_id", None) or "",
"packageText": getattr(s, "package_text", None) or "",
"infusionTime": _fmt_dt(s.infusion_time),
"scheduleStatus": s.schedule_status or "",
"scheduleStatusLabel": _SCHEDULE_STATUS_LABELS.get(
+20
View File
@@ -272,6 +272,8 @@ def _row_to_dict_schedule_product(row) -> Dict[str, Any]:
"projectName": row.project_name or "",
"cellTypeName": row.cell_type_name or "",
"specText": row.spec_text or "",
"packageId": getattr(row, "package_id", None) or "",
"packageText": getattr(row, "package_text", None) or "",
"agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None,
"actualPrice": float(row.actual_price) if row.actual_price is not None else None,
"quantity": row.quantity if row.quantity is not None else 1,
@@ -289,6 +291,8 @@ def _row_to_dict_infusion_schedule(row, cell_products: Optional[List[Dict[str, A
"infusionPlanId": row.infusion_plan_id or "",
"cellType": row.cell_type or "",
"specText": row.spec_text or "",
"packageId": getattr(row, "package_id", None) or "",
"packageText": getattr(row, "package_text", None) or "",
"infusionTime": infusion_time,
"infusionLocation": row.infusion_location or "",
"infusionMethod": row.infusion_method or "",
@@ -1218,6 +1222,8 @@ def _create_schedule_products(
project_name=item.get("projectName") or "",
cell_type_name=item.get("cellTypeName") or "",
spec_text=item.get("specText") or "",
package_id=item.get("packageId") or "",
package_text=item.get("packageText") or "",
agreement_price=item.get("agreementPrice"),
actual_price=item.get("actualPrice"),
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
@@ -1273,6 +1279,8 @@ def create_schedule(
infusion_plan_id=plan_id,
cell_type=data.get("cellType") or first_product.get("cellTypeName") or "",
spec_text=data.get("specText") or first_product.get("specText") or "",
package_id=data.get("packageId") or first_product.get("packageId") or "",
package_text=data.get("packageText") or first_product.get("packageText") or "",
infusion_time=_parse_infusion_datetime(data.get("infusionTime")),
infusion_location=data.get("infusionLocation") or "",
infusion_method=data.get("infusionMethod") or "",
@@ -1364,6 +1372,10 @@ def _update_schedule_editable(
if cell_products:
row.cell_type = data.get("cellType") or first_product.get("cellTypeName") or row.cell_type
row.spec_text = data.get("specText") or first_product.get("specText") or row.spec_text
row.package_id = data.get("packageId") or first_product.get("packageId") or row.package_id
row.package_text = (
data.get("packageText") or first_product.get("packageText") or row.package_text
)
if data.get("remark") is not None:
row.remark = data.get("remark") or ""
row.update_by = user_id
@@ -1407,6 +1419,8 @@ def _upsert_schedule_products(
prow.project_name = item.get("projectName") or ""
prow.cell_type_name = item.get("cellTypeName") or ""
prow.spec_text = item.get("specText") or ""
prow.package_id = item.get("packageId") or ""
prow.package_text = item.get("packageText") or ""
if item.get("agreementPrice") is not None:
prow.agreement_price = item.get("agreementPrice")
if item.get("actualPrice") is not None:
@@ -1429,6 +1443,8 @@ def _upsert_schedule_products(
project_name=item.get("projectName") or "",
cell_type_name=item.get("cellTypeName") or "",
spec_text=item.get("specText") or "",
package_id=item.get("packageId") or "",
package_text=item.get("packageText") or "",
agreement_price=item.get("agreementPrice"),
actual_price=item.get("actualPrice"),
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
@@ -1645,6 +1661,8 @@ def create_basic_experiment_sale_schedules(
schedule_data = {
"cellType": line.get("cellTypeName") or "",
"specText": line.get("specText") or "",
"packageId": line.get("packageId") or "",
"packageText": line.get("packageText") or "",
"infusionTime": line.get("pickupTime"),
"amount": line.get("amount"),
"sortNo": idx + 1,
@@ -1655,6 +1673,8 @@ def create_basic_experiment_sale_schedules(
"projectName": line.get("projectName"),
"cellTypeName": line.get("cellTypeName"),
"specText": line.get("specText"),
"packageId": line.get("packageId"),
"packageText": line.get("packageText"),
"agreementPrice": line.get("agreementPrice"),
"actualPrice": line.get("actualPrice"),
"quantity": line.get("quantity") or 1,
+67 -58
View File
@@ -8,6 +8,9 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizCheckupInstitution,
BizPilotApplication,
@@ -429,6 +432,52 @@ def list_reserve_items(
return [_row_to_dict_reserve_item(r) for r in rows]
def _checkup_project_name(item: Dict[str, Any]) -> str:
return (item.get("projectName") or item.get("project_name") or "").strip()
def _checkup_project_fee(item: Dict[str, Any]):
if item.get("projectFee") is not None:
return item.get("projectFee")
return item.get("project_fee")
def _sync_checkup_items(db, model, checkup_id, items_json, tenant_no, user_id, now):
def build_row(row_id):
return model(
id=row_id,
pilot_checkup_id=checkup_id,
project_name="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row, item, idx, _now):
row.project_name = _checkup_project_name(item)
row.project_fee = _checkup_project_fee(item)
row.order_num = idx + 1
row.remark = item.get("remark") or ""
sync_child_rows(
db,
model,
parent_attr="pilot_checkup_id",
parent_id=checkup_id,
tenant_no=tenant_no,
items=list(items_json or []),
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_checkup_project_name,
row_natural_key=lambda row: (row.project_name or "").strip(),
duplicate_message="体检项目重复",
foreign_message="体检项目不属于当前体检单",
)
def save_reserve_items(
db: Session,
checkup_id: str,
@@ -437,35 +486,15 @@ def save_reserve_items(
user_id: str,
) -> List[Dict[str, Any]]:
now = datetime.now()
# Soft-delete existing items
db.query(BizPilotCheckupReserveItem).filter(
BizPilotCheckupReserveItem.pilot_checkup_id == checkup_id,
BizPilotCheckupReserveItem.tenant_no == tenant_no,
BizPilotCheckupReserveItem.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
# Insert new items
for idx, item in enumerate(items_json):
row = BizPilotCheckupReserveItem(
id=uuid.uuid4().hex,
pilot_checkup_id=checkup_id,
project_name=item.get("projectName") or item.get("project_name") or "",
project_fee=item.get("projectFee") if item.get("projectFee") is not None else (
item.get("project_fee") if item.get("project_fee") is not None else None
),
tenant_no=tenant_no,
order_num=idx + 1,
remark=item.get("remark") or "",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
_sync_checkup_items(
db,
BizPilotCheckupReserveItem,
checkup_id,
items_json,
tenant_no,
user_id,
now,
)
db.commit()
return list_reserve_items(db, checkup_id, tenant_no)
@@ -520,35 +549,15 @@ def save_actual_items(
user_id: str,
) -> List[Dict[str, Any]]:
now = datetime.now()
# Soft-delete existing items
db.query(BizPilotCheckupActualItem).filter(
BizPilotCheckupActualItem.pilot_checkup_id == checkup_id,
BizPilotCheckupActualItem.tenant_no == tenant_no,
BizPilotCheckupActualItem.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
# Insert new items
for idx, item in enumerate(items_json):
row = BizPilotCheckupActualItem(
id=uuid.uuid4().hex,
pilot_checkup_id=checkup_id,
project_name=item.get("projectName") or item.get("project_name") or "",
project_fee=item.get("projectFee") if item.get("projectFee") is not None else (
item.get("project_fee") if item.get("project_fee") is not None else None
),
tenant_no=tenant_no,
order_num=idx + 1,
remark=item.get("remark") or "",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
_sync_checkup_items(
db,
BizPilotCheckupActualItem,
checkup_id,
items_json,
tenant_no,
user_id,
now,
)
db.commit()
return list_actual_items(db, checkup_id, tenant_no)
+30 -24
View File
@@ -8,6 +8,8 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizChannelAccount,
BizChannelAgreementProductPrice,
@@ -539,7 +541,7 @@ def save_delivery_materials(
tenant_no: str,
user_id: str,
) -> List[Dict[str, Any]]:
"""Bulk save: soft-delete existing + insert new."""
"""按物料名称更新已有行,只插入新物料,只软删去掉的物料。"""
now = datetime.now()
# Validate delivery exists
@@ -555,36 +557,40 @@ def save_delivery_materials(
if not delivery:
raise ValueError("交付信息不存在")
# Soft-delete existing materials
db.query(BizPilotDeliveryMaterial).filter(
BizPilotDeliveryMaterial.delivery_id == delivery_id,
BizPilotDeliveryMaterial.tenant_no == tenant_no,
BizPilotDeliveryMaterial.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
db.flush()
# Insert new
created = []
for idx, item in enumerate(items_json):
material = BizPilotDeliveryMaterial(
id=str(uuid.uuid4()),
def build_row(row_id):
return BizPilotDeliveryMaterial(
id=row_id,
delivery_id=delivery_id,
material_name=item.get("material_name") or "",
qty=item.get("qty"),
unit_id=item.get("unit_id") or "",
material_name="",
tenant_no=tenant_no,
order_num=idx + 1,
remark=item.get("remark") or "",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(material)
created.append(material)
def apply_row(row, item, idx, _now):
row.material_name = item.get("material_name") or ""
row.qty = item.get("qty")
row.unit_id = item.get("unit_id") or ""
row.order_num = idx + 1
row.remark = item.get("remark") or ""
created = sync_child_rows(
db,
BizPilotDeliveryMaterial,
parent_attr="delivery_id",
parent_id=delivery_id,
tenant_no=tenant_no,
items=list(items_json or []),
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("material_name") or "").strip(),
row_natural_key=lambda row: (row.material_name or "").strip(),
duplicate_message="交付物料重复",
foreign_message="交付物料不属于当前交付单",
)
db.commit()
for m in created:
db.refresh(m)
+1 -1
View File
@@ -129,7 +129,7 @@ def get_confirm_by_schedule(
if uid:
user = (
db.query(SysUser)
.filter(SysUser.id == uid, SysUser.del_flag == 0)
.filter(SysUser.id == uid)
.first()
)
data["confirmUserName"] = (user.nick_name or user.user_no or "") if user else ""
@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -10,6 +9,7 @@ from sqlalchemy import and_, desc, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizChannelAgreementProductPrice,
BizChannelServiceOrder,
@@ -69,6 +69,8 @@ def _row_to_dict_schedule(
"totalCourseCount": course.get("totalCourseCount"),
"cellType": row.cell_type or "",
"specText": row.spec_text or "",
"packageId": getattr(row, "package_id", None) or "",
"packageText": getattr(row, "package_text", None) or "",
"infusionTime": row.infusion_time.strftime("%Y-%m-%d %H:%M:%S") if row.infusion_time else "",
"infusionLocation": row.infusion_location or "",
"infusionMethod": row.infusion_method or "",
@@ -142,6 +144,8 @@ def _row_to_dict_product(row) -> Dict[str, Any]:
"projectName": row.project_name or "",
"cellTypeName": row.cell_type_name or "",
"specText": row.spec_text or "",
"packageId": getattr(row, "package_id", None) or "",
"packageText": getattr(row, "package_text", None) or "",
"agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None,
"actualPrice": float(row.actual_price) if row.actual_price is not None else None,
"quantity": row.quantity if row.quantity is not None else 1,
@@ -516,6 +520,8 @@ def get_infusion_schedule_detail(
sched["totalCourseCount"] = flat.get("totalCourseCount")
sched["cellType"] = flat.get("cellType") or sched.get("cellType") or ""
sched["specText"] = flat.get("specText") or sched.get("specText") or ""
sched["packageId"] = flat.get("packageId") or sched.get("packageId") or ""
sched["packageText"] = flat.get("packageText") or sched.get("packageText") or ""
sched["infusionTime"] = flat.get("infusionTime") or sched.get("infusionTime") or ""
sched["infusionLocation"] = flat.get("infusionLocation") or sched.get("infusionLocation") or ""
sched["infusionMethod"] = flat.get("infusionMethod") or sched.get("infusionMethod") or ""
@@ -1114,6 +1120,8 @@ def update_infusion_schedule_mgmt(
fields = {
"cellType": "cell_type",
"specText": "spec_text",
"packageId": "package_id",
"packageText": "package_text",
"infusionLocation": "infusion_location",
"infusionMethod": "infusion_method",
}
@@ -1134,44 +1142,68 @@ def update_infusion_schedule_mgmt(
row.update_by = user_id
row.update_time = now
# Update products if provided
products_data = data.get("products")
if products_data is not None:
# Soft-delete existing products
db.query(BizPilotInfusionScheduleProduct).filter(
BizPilotInfusionScheduleProduct.infusion_schedule_id == schedule_id,
BizPilotInfusionScheduleProduct.tenant_no == tenant_no,
BizPilotInfusionScheduleProduct.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
def _price_id(item):
return (item.get("priceId") or item.get("price_id") or "").strip()
for idx, item in enumerate(products_data):
p_row = BizPilotInfusionScheduleProduct(
id=str(uuid.uuid4()),
def _product_key(item):
price_id = _price_id(item)
if price_id:
return "price:" + price_id
project_code = (item.get("projectCode") or item.get("project_code") or "").strip()
spec_text = (item.get("specText") or item.get("spec_text") or "").strip()
return f"proj:{project_code}|{spec_text}"
def build_product(row_id):
return BizPilotInfusionScheduleProduct(
id=row_id,
infusion_schedule_id=schedule_id,
price_id=item.get("priceId") or item.get("price_id") or "",
project_code=item.get("projectCode") or item.get("project_code") or "",
project_name=item.get("projectName") or item.get("project_name") or "",
cell_type_name=item.get("cellTypeName") or item.get("cell_type_name") or "",
spec_text=item.get("specText") or item.get("spec_text") or "",
agreement_price=item.get("agreementPrice") if item.get("agreementPrice") is not None else (
item.get("agreement_price") if item.get("agreement_price") is not None else None
),
actual_price=item.get("actualPrice") if item.get("actualPrice") is not None else (
item.get("actual_price") if item.get("actual_price") is not None else None
),
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
product_status=status,
tenant_no=tenant_no,
order_num=idx + 1,
remark=item.get("remark") or "",
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(p_row)
def apply_product(row, item, idx, _now):
row.price_id = _price_id(item)
row.project_code = item.get("projectCode") or item.get("project_code") or ""
row.project_name = item.get("projectName") or item.get("project_name") or ""
row.cell_type_name = item.get("cellTypeName") or item.get("cell_type_name") or ""
row.spec_text = item.get("specText") or item.get("spec_text") or ""
row.package_id = item.get("packageId") or item.get("package_id") or ""
row.package_text = item.get("packageText") or item.get("package_text") or ""
row.agreement_price = item.get("agreementPrice") if item.get("agreementPrice") is not None else (
item.get("agreement_price") if item.get("agreement_price") is not None else None
)
row.actual_price = item.get("actualPrice") if item.get("actualPrice") is not None else (
item.get("actual_price") if item.get("actual_price") is not None else None
)
row.quantity = item.get("quantity") if item.get("quantity") is not None else 1
row.product_status = status
row.order_num = idx + 1
row.remark = item.get("remark") or ""
sync_child_rows(
db,
BizPilotInfusionScheduleProduct,
parent_attr="infusion_schedule_id",
parent_id=schedule_id,
tenant_no=tenant_no,
items=list(products_data or []),
user_id=user_id,
now=now,
build_row=build_product,
apply_fields=apply_product,
natural_key=_product_key,
row_natural_key=lambda row: (
"price:" + (row.price_id or "").strip()
if (row.price_id or "").strip()
else f"proj:{(row.project_code or '').strip()}|{(row.spec_text or '').strip()}"
),
duplicate_message="输注产品重复",
foreign_message="输注产品不属于当前计划",
)
db.commit()
db.refresh(row)
+227 -58
View File
@@ -10,10 +10,12 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.collection_protocol import urls_to_attachment_list
from app.models.models import (
BizCellProductType,
BizCellProductSpec,
BizCellProductPackage,
BizCellProductPrice,
BizCellProductProjectSystem,
BizCellProductProjectSystemItem,
@@ -281,6 +283,141 @@ def soft_delete_cell_product_spec(db, spec_id, tenant_no=None):
db.commit()
# ────────────────────────────────────────────
# Entity 2b: BizCellProductPackage 细胞产品包装
# ────────────────────────────────────────────
def _row_to_dict_cell_product_package(row) -> Dict[str, Any]:
return {
"id": row.id,
"packageCode": row.package_code or "",
"packageName": row.package_name or "",
"tenantNo": row.tenant_no or "",
"orderNum": row.order_num if row.order_num is not None else 0,
"remark": row.remark or "",
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
}
def list_cell_product_packages(db, *, page=1, page_size=10, keyword=None, tenant_no=None, user_info=None):
def _filtered():
q0 = db.query(BizCellProductPackage).filter(BizCellProductPackage.del_flag == 0)
if tenant_no:
q0 = q0.filter(BizCellProductPackage.tenant_no == tenant_no)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q0 = q0.filter(
or_(
BizCellProductPackage.package_code.like(like),
BizCellProductPackage.package_name.like(like),
)
)
if user_info:
from app.core.data_scope import apply_data_scope
q0 = apply_data_scope(q0, BizCellProductPackage, user_info, db, tenant_no)
return q0
total = _filtered().order_by(None).with_entities(func.count(BizCellProductPackage.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
_filtered()
.order_by(BizCellProductPackage.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return [_row_to_dict_cell_product_package(x) for x in rows], int(total)
def get_cell_product_package(db, package_id, tenant_no=None):
q = db.query(BizCellProductPackage).filter(
and_(BizCellProductPackage.id == package_id, BizCellProductPackage.del_flag == 0)
)
if tenant_no:
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
return q.first()
def cell_product_package_code_exists(db, code, tenant_no, exclude_id=None):
q = db.query(BizCellProductPackage).filter(
and_(BizCellProductPackage.package_code == code, BizCellProductPackage.del_flag == 0)
)
if tenant_no:
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
if exclude_id:
q = q.filter(BizCellProductPackage.id != exclude_id)
return db.query(q.exists()).scalar()
def create_cell_product_package(
db, *, package_code, package_name, tenant_no="zkxw", order_num=None, remark=None, user_id=None
):
if cell_product_package_code_exists(db, package_code, tenant_no):
raise ValueError(f"包装编码 {package_code} 已存在")
obj = BizCellProductPackage(
id=generate_uuid(),
package_code=package_code,
package_name=package_name,
tenant_no=tenant_no,
order_num=order_num or 0,
remark=remark or "",
create_by=user_id,
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_cell_product_package(
db,
package_id,
*,
package_code=None,
package_name=None,
order_num=None,
remark=None,
user_id=None,
tenant_no=None,
):
obj = get_cell_product_package(db, package_id, tenant_no)
if not obj:
raise ValueError("记录不存在")
if package_code is not None and package_code != obj.package_code:
if cell_product_package_code_exists(db, package_code, tenant_no, exclude_id=package_id):
raise ValueError(f"包装编码 {package_code} 已存在")
obj.package_code = package_code
if package_name is not None:
obj.package_name = package_name
if order_num is not None:
obj.order_num = order_num
if remark is not None:
obj.remark = remark
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def soft_delete_cell_product_package(db, package_id, tenant_no=None):
obj = get_cell_product_package(db, package_id, tenant_no)
if not obj:
raise ValueError("记录不存在")
obj.del_flag = 1
obj.update_time = datetime.now()
db.add(obj)
db.commit()
# ────────────────────────────────────────────
# Entity 3: BizCellProductPrice 细胞产品价格
# ────────────────────────────────────────────
@@ -2151,7 +2288,9 @@ def _insert_package_items(
tenant_no,
user_id=None,
):
for idx, it in enumerate(items):
now = datetime.now()
prepared = []
for idx, it in enumerate(items or []):
pid = it.get("checkup_institution_project_id") or it.get("checkupInstitutionProjectId")
proj = _get_checkup_project_for_package(db, pid, institution_id, tenant_no)
name = (it.get("project_name") or it.get("projectName") or "").strip()
@@ -2160,22 +2299,51 @@ def _insert_package_items(
fee = it.get("project_fee")
if fee is None:
fee = it.get("projectFee")
obj = BizCheckupInstitutionPackageItem(
id=generate_uuid(),
package_id=package_id,
checkup_institution_project_id=pid,
project_name=name,
project_fee=fee,
tenant_no=tenant_no,
order_num=it.get("order_num") if it.get("order_num") is not None else it.get("orderNum") or (idx + 1),
remark=it.get("remark") or "",
create_by=user_id,
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
prepared.append(
{
"project_id": pid,
"project_name": name,
"project_fee": fee,
"order_num": it.get("order_num") if it.get("order_num") is not None else it.get("orderNum") or (idx + 1),
"remark": it.get("remark") or "",
}
)
db.add(obj)
def build_row(row_id):
return BizCheckupInstitutionPackageItem(
id=row_id,
package_id=package_id,
checkup_institution_project_id="",
project_name="",
tenant_no=tenant_no,
create_by=user_id,
create_time=now,
)
def apply_row(row, item, _idx, _now):
row.checkup_institution_project_id = item["project_id"]
row.project_name = item["project_name"]
row.project_fee = item["project_fee"]
row.order_num = item["order_num"]
row.remark = item["remark"]
sync_child_rows(
db,
BizCheckupInstitutionPackageItem,
parent_attr="package_id",
parent_id=package_id,
tenant_no=tenant_no,
items=prepared,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: str(item.get("project_id") or "").strip(),
row_natural_key=lambda row: (row.checkup_institution_project_id or "").strip(),
duplicate_message="套餐项目重复",
foreign_message="套餐项目不属于当前套餐",
reuse_deleted=True,
)
def list_checkup_institution_packages(db, checkup_institution_id, tenant_no=None, user_info=None):
@@ -2319,15 +2487,6 @@ def update_checkup_institution_package(
tenant_no,
existing_item_project_ids=existing_pids,
)
# 全量替换:物理删除旧明细,避免 uk(package_id, project_id) 与软删行冲突
q_items = db.query(BizCheckupInstitutionPackageItem).filter(
BizCheckupInstitutionPackageItem.package_id == package_id
)
if tenant_no:
q_items = q_items.filter(BizCheckupInstitutionPackageItem.tenant_no == tenant_no)
for row in q_items.all():
db.delete(row)
db.flush()
_insert_package_items(
db,
package_id=package_id,
@@ -3076,50 +3235,60 @@ def _save_gmcp_product_rows(
user_id,
now=None,
):
"""仅保存购买订单产品明细,不调整份数库存"""
"""仅保存购买订单产品明细,不调整份数库存。已有项目按编码、规格、细胞类型更新。"""
now = now or datetime.now()
for p in products:
project_code = p.get("projectCode") or p.get("project_code") or ""
project_name = p.get("projectName") or p.get("project_name") or ""
cell_type = p.get("cellType") or p.get("cell_type") or ""
spec_text = p.get("specText") or p.get("spec_text") or ""
agreement_unit_price = float(p.get("agreementUnitPrice") or p.get("agreement_unit_price") or 0)
buy_qty = float(p.get("buyQty") or p.get("buy_qty") or 0)
buy_amount = float(p.get("buyAmount") or p.get("buy_amount") or agreement_unit_price * buy_qty)
tenant = tenant_no or "zkxw"
prod_row = BizChannelServiceOrderProduct(
id=generate_uuid(),
def _product_key(item):
project_code = (item.get("projectCode") or item.get("project_code") or "").strip()
spec_text = (item.get("specText") or item.get("spec_text") or "").strip()
cell_type = (item.get("cellType") or item.get("cell_type") or "").strip()
return f"{project_code}|{spec_text}|{cell_type}"
def build_row(row_id):
return BizChannelServiceOrderProduct(
id=row_id,
service_order_id=order_id,
project_code=project_code,
project_name=project_name,
cell_type=cell_type,
spec_text=spec_text,
agreement_unit_price=agreement_unit_price,
buy_qty=buy_qty,
buy_amount=buy_amount,
tenant_no=tenant_no or "zkxw",
order_num=0,
remark="",
tenant_no=tenant,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(prod_row)
def apply_row(row, p, _idx, _now):
agreement_unit_price = float(p.get("agreementUnitPrice") or p.get("agreement_unit_price") or 0)
buy_qty = float(p.get("buyQty") or p.get("buy_qty") or 0)
row.project_code = p.get("projectCode") or p.get("project_code") or ""
row.project_name = p.get("projectName") or p.get("project_name") or ""
row.cell_type = p.get("cellType") or p.get("cell_type") or ""
row.spec_text = p.get("specText") or p.get("spec_text") or ""
row.agreement_unit_price = agreement_unit_price
row.buy_qty = buy_qty
row.buy_amount = float(p.get("buyAmount") or p.get("buy_amount") or agreement_unit_price * buy_qty)
row.order_num = 0
row.remark = ""
sync_child_rows(
db,
BizChannelServiceOrderProduct,
parent_attr="service_order_id",
parent_id=order_id,
tenant_no=tenant,
items=list(products or []),
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_product_key,
row_natural_key=lambda row: f"{(row.project_code or '').strip()}|{(row.spec_text or '').strip()}|{(row.cell_type or '').strip()}",
duplicate_message="订单产品重复",
foreign_message="订单产品不属于当前订单",
)
db.flush()
def _replace_gmcp_product_rows(db, order_id, products, tenant_no, user_id):
now = datetime.now()
db.query(BizChannelServiceOrderProduct).filter(
and_(
BizChannelServiceOrderProduct.service_order_id == order_id,
BizChannelServiceOrderProduct.del_flag == 0,
)
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
_save_gmcp_product_rows(db, order_id, products, tenant_no, user_id, now=now)
_save_gmcp_product_rows(db, order_id, products, tenant_no, user_id)
def _apply_gmcp_order_quotas(db, order, tenant_no, user_id):
+14 -26
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_link_rows
from app.models.models import SysPost, SysPostRole, SysRole, SysUser, SysUserPost
@@ -232,7 +233,7 @@ def sync_post_role_bindings(
role_ids: List[str],
updater_id: Optional[str],
) -> None:
"""将本职位绑定的角色集合全量替换为 role_ids。"""
"""按角色更新本职位的角色关联。"""
ordered = list(dict.fromkeys([rid for rid in (role_ids or []) if rid]))
for rid in ordered:
role = (
@@ -249,31 +250,18 @@ def sync_post_role_bindings(
if not role:
raise ValueError("存在无效角色或不属于当前租户")
now = datetime.now()
db.query(SysPostRole).filter(
and_(
SysPostRole.post_id == post_id,
SysPostRole.tenant_no == tenant_no,
SysPostRole.del_flag == 0,
)
).update({"del_flag": 1, "update_time": now, "update_by": updater_id}, synchronize_session=False)
for rid in ordered:
db.add(
SysPostRole(
id=generate_uuid(),
post_id=post_id,
role_id=rid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=updater_id,
create_dept_id="",
create_time=now,
update_by=updater_id,
update_time=now,
del_flag=0,
)
)
sync_link_rows(
db,
SysPostRole,
parent_attr="post_id",
parent_id=post_id,
link_attr="role_id",
link_ids=ordered,
tenant_no=tenant_no,
user_id=updater_id,
duplicate_message="角色重复",
foreign_message="角色关联不属于当前职位",
)
db.commit()
+34 -29
View File
@@ -11,6 +11,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime
from app.crud.child_row_sync import sync_child_rows
from app.crud.biz_no_generator import allocate_next_biz_no
from app.crud.collection_protocol import urls_to_attachment_list
from app.models.models import (
@@ -337,41 +338,45 @@ def replace_sample_types(
create_dept_id: str = "",
) -> None:
now = _now()
existing = (
db.query(BizPresignOrderSampleType)
.filter(
BizPresignOrderSampleType.presign_order_id == presign_order_id,
BizPresignOrderSampleType.tenant_no == tenant_no,
BizPresignOrderSampleType.del_flag == 0,
)
.all()
)
for r in existing:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
db.add(r)
payload: List[Dict[str, Any]] = []
seen = set()
for idx, tid in enumerate(sample_type_ids or []):
for tid in sample_type_ids or []:
sid = (tid or "").strip()
if not sid or sid in seen:
continue
seen.add(sid)
db.add(
BizPresignOrderSampleType(
id=generate_uuid(),
presign_order_id=presign_order_id,
sample_type_id=sid,
tenant_no=tenant_no,
order_num=idx,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
payload.append({"sample_type_id": sid})
def build_row(row_id: str) -> BizPresignOrderSampleType:
return BizPresignOrderSampleType(
id=row_id,
presign_order_id=presign_order_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
)
def apply_row(row: BizPresignOrderSampleType, item: Dict[str, Any], idx: int, _now: datetime) -> None:
row.sample_type_id = item["sample_type_id"]
row.order_num = idx
sync_child_rows(
db,
BizPresignOrderSampleType,
parent_attr="presign_order_id",
parent_id=presign_order_id,
tenant_no=tenant_no,
items=payload,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("sample_type_id") or "").strip(),
row_natural_key=lambda row: (row.sample_type_id or "").strip(),
duplicate_message="样本类型编号重复",
foreign_message="样本类型不属于当前预签约订单",
)
db.flush()
+132 -12
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizProcess,
BizProcessCategory,
@@ -300,6 +301,132 @@ def _create_sub_data_collection(db: Session, process_id: str, tenant_no: str, us
db.add(obj)
def _new_process_child(model, row_id: str, process_id: str, tenant_no: str, user_id: Optional[str], now: datetime):
return model(
id=row_id,
process_id=process_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def _sync_process_reagents(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
item = _sub_item_with_order(data, idx)
row.reagent_code = (item.get("reagent_code") or "").strip()
row.reagent_name = (item.get("reagent_name") or "").strip()
row.spec = (item.get("spec") or "").strip() or None
row.standard_usage = (item.get("standard_usage") or "").strip() or None
row.unit = (item.get("unit") or "").strip() or None
row.is_key_material = item.get("is_key_material", 0)
row.remark = (item.get("remark") or "").strip() or None
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
sync_child_rows(
db,
BizProcessReagent,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(BizProcessReagent, row_id, process_id, tenant_no, user_id, now),
apply_fields=apply_row,
natural_key=lambda item: (item.get("reagent_code") or "").strip(),
row_natural_key=lambda row: (row.reagent_code or "").strip(),
duplicate_message="试剂编号重复",
foreign_message="试剂不属于当前工艺",
)
def _sync_process_consumables(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
item = _sub_item_with_order(data, idx)
row.consumable_code = (item.get("consumable_code") or "").strip()
row.consumable_name = (item.get("consumable_name") or "").strip()
row.spec_model = (item.get("spec_model") or "").strip() or None
row.standard_usage = (item.get("standard_usage") or "").strip() or None
row.unit = (item.get("unit") or "").strip() or None
row.is_required = item.get("is_required", 0)
row.remark = (item.get("remark") or "").strip() or None
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
sync_child_rows(
db,
BizProcessConsumable,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(BizProcessConsumable, row_id, process_id, tenant_no, user_id, now),
apply_fields=apply_row,
natural_key=lambda item: (item.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材编号重复",
foreign_message="耗材不属于当前工艺",
)
def _sync_process_outputs(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
item = _sub_item_with_order(data, idx)
row.output_type_id = (item.get("output_type_id") or "").strip() or None
row.output_name = (item.get("output_name") or "").strip() or None
row.output_barcode = (item.get("output_barcode") or "").strip() or None
row.remark = (item.get("remark") or "").strip() or None
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
sync_child_rows(
db,
BizProcessOutput,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(BizProcessOutput, row_id, process_id, tenant_no, user_id, now),
apply_fields=apply_row,
natural_key=lambda item: (item.get("output_barcode") or item.get("output_name") or "").strip(),
row_natural_key=lambda row: (row.output_barcode or row.output_name or "").strip(),
duplicate_message="产出物编号重复",
foreign_message="产出物不属于当前工艺",
)
def _sync_process_data_collections(db, process_id, tenant_no, user_id, now, items):
def apply_row(row, data, idx, _now):
row.collection_name = (data.get("collection_name") or "").strip()
row.unit = (data.get("unit") or "").strip() or None
order_num = data.get("order_num")
row.order_num = idx if order_num is None else order_num
row.remark = (data.get("remark") or "").strip() or None
sync_child_rows(
db,
BizProcessDataCollection,
parent_attr="process_id",
parent_id=process_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_process_child(
BizProcessDataCollection, row_id, process_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("collection_name") or "").strip(),
row_natural_key=lambda row: (row.collection_name or "").strip(),
duplicate_message="数据采集编号重复",
foreign_message="数据采集不属于当前工艺",
)
def create_process(
db: Session,
*,
@@ -434,22 +561,15 @@ def update_process(
sub_updated = reagents is not None or consumables is not None or outputs is not None or data_collections is not None
if sub_updated:
now = datetime.now()
tenant_no = obj.tenant_no
if reagents is not None:
_soft_delete_sub_records(db, BizProcessReagent, process_id, now)
for idx, r in enumerate(reagents):
_create_sub_reagent(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(r, idx))
_sync_process_reagents(db, process_id, tenant_no, user_id, now, reagents)
if consumables is not None:
_soft_delete_sub_records(db, BizProcessConsumable, process_id, now)
for idx, c in enumerate(consumables):
_create_sub_consumable(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(c, idx))
_sync_process_consumables(db, process_id, tenant_no, user_id, now, consumables)
if outputs is not None:
_soft_delete_sub_records(db, BizProcessOutput, process_id, now)
for idx, o in enumerate(outputs):
_create_sub_output(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(o, idx))
_sync_process_outputs(db, process_id, tenant_no, user_id, now, outputs)
if data_collections is not None:
_soft_delete_sub_records(db, BizProcessDataCollection, process_id, now)
for d in data_collections:
_create_sub_data_collection(db, process_id, obj.tenant_no, user_id, now, d)
_sync_process_data_collections(db, process_id, tenant_no, user_id, now, data_collections)
db.commit()
db.refresh(obj)
+123 -13
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
from app.models.models import (
BizProductionProductType,
@@ -652,38 +653,147 @@ def soft_delete_route(db: Session, route_id: str) -> None:
# ---------------------------------------------------------------------------
# 子表完整替换更新
# 子表按行更新:保留原行 ID,去掉的才软删除
# ---------------------------------------------------------------------------
def _new_route_step_child(model, row_id, step_id, tenant_no, user_id, now):
return model(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def update_step_reagents(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepReagent, step_id, now)
for it in items:
_create_step_reagent(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.reagent_code = (data.get("reagent_code") or "").strip() or None
row.reagent_name = (data.get("reagent_name") or "").strip() or None
row.spec = (data.get("spec") or "").strip() or None
row.standard_usage = (data.get("standard_usage") or "").strip() or None
row.unit = (data.get("unit") or "").strip() or None
row.is_key_material = data.get("is_key_material", "0")
row.remark = (data.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProcessRouteStepReagent,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepReagent, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("reagent_code") or "").strip(),
row_natural_key=lambda row: (row.reagent_code or "").strip(),
duplicate_message="试剂编号重复",
foreign_message="试剂不属于当前步骤",
)
db.commit()
def update_step_consumables(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepConsumable, step_id, now)
for it in items:
_create_step_consumable(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.consumable_code = (data.get("consumable_code") or "").strip() or None
row.consumable_name = (data.get("consumable_name") or "").strip() or None
row.spec_model = (data.get("spec_model") or "").strip() or None
row.standard_usage = (data.get("standard_usage") or "").strip() or None
row.unit = (data.get("unit") or "").strip() or None
row.is_required = data.get("is_required", "1")
row.remark = (data.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProcessRouteStepConsumable,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepConsumable, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材编号重复",
foreign_message="耗材不属于当前步骤",
)
db.commit()
def update_step_outputs(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepOutput, step_id, now)
for it in items:
_create_step_output(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.output_type_id = (data.get("output_type_id") or "").strip() or None
row.output_name = (data.get("output_name") or "").strip() or None
row.output_barcode = (data.get("output_barcode") or "").strip() or None
row.remark = (data.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProcessRouteStepOutput,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepOutput, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("output_barcode") or item.get("output_name") or "").strip(),
row_natural_key=lambda row: (row.output_barcode or row.output_name or "").strip(),
duplicate_message="产出物编号重复",
foreign_message="产出物不属于当前步骤",
)
db.commit()
def update_step_data_collections(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
now = datetime.now()
_soft_delete_sub_records(db, BizProcessRouteStepDataCollection, step_id, now)
for it in items:
_create_step_data_collection(db, step_id, tenant_no, user_id, now, it)
def apply_row(row, data, idx, _now):
row.collection_name = (data.get("collection_name") or "").strip() or None
row.unit = (data.get("unit") or "").strip() or None
order_num = data.get("order_num")
row.order_num = idx if order_num is None else (order_num or 0)
row.remark = (data.get("remark") or "").strip() or None
sync_child_rows(
db,
BizProcessRouteStepDataCollection,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=lambda row_id: _new_route_step_child(
BizProcessRouteStepDataCollection, row_id, step_id, tenant_no, user_id, now
),
apply_fields=apply_row,
natural_key=lambda item: (item.get("collection_name") or "").strip(),
row_natural_key=lambda row: (row.collection_name or "").strip(),
duplicate_message="数据采集编号重复",
foreign_message="数据采集不属于当前步骤",
)
db.commit()
+50 -37
View File
@@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.production_material import _dec, _fmt_dt
from app.models.production_material import BizProductionMaterial, BizProductionMaterialFormula
@@ -84,7 +84,7 @@ def replace_formula(
raise ValueError("请至少添加一条配方明细")
seen_components: set = set()
new_rows: List[BizProductionMaterialFormula] = []
normalized: List[Dict[str, Any]] = []
for idx, it in enumerate(lines):
comp_id = (
@@ -125,45 +125,58 @@ def replace_formula(
it.get("usageUnit") or it.get("usage_unit") or comp.unit or ""
).strip()[:32]
new_rows.append(
BizProductionMaterialFormula(
id=generate_uuid(),
configured_material_id=configured_material_id,
component_material_id=comp_id,
component_material_type=comp_type,
component_material_code=comp.material_code,
component_material_name=comp.material_name,
standard_qty=standard_qty,
usage_unit=usage_unit or None,
tenant_no=tenant_no,
order_num=idx,
remark=(it.get("remark") or "").strip()[:512] or None,
create_by=user_id,
create_dept_id=dept_id or configured.create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
normalized.append(
{
"id": (it.get("id") or "").strip(),
"component_material_id": comp_id,
"component_material_type": comp_type,
"component_material_code": comp.material_code,
"component_material_name": comp.material_name,
"standard_qty": standard_qty,
"usage_unit": usage_unit or None,
"order_num": idx,
"remark": (it.get("remark") or "").strip()[:512] or None,
}
)
existing = (
db.query(BizProductionMaterialFormula)
.filter(
BizProductionMaterialFormula.configured_material_id == configured_material_id,
BizProductionMaterialFormula.tenant_no == tenant_no,
BizProductionMaterialFormula.del_flag == 0,
dept = dept_id or configured.create_dept_id or ""
def build_row(row_id: str) -> BizProductionMaterialFormula:
return BizProductionMaterialFormula(
id=row_id,
configured_material_id=configured_material_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=dept,
create_time=now,
)
.all()
def apply_row(row: BizProductionMaterialFormula, it: Dict[str, Any], idx: int, _now: datetime) -> None:
row.component_material_id = it["component_material_id"]
row.component_material_type = it["component_material_type"]
row.component_material_code = it["component_material_code"]
row.component_material_name = it["component_material_name"]
row.standard_qty = it["standard_qty"]
row.usage_unit = it["usage_unit"]
row.order_num = idx
row.remark = it["remark"]
sync_child_rows(
db,
BizProductionMaterialFormula,
parent_attr="configured_material_id",
parent_id=configured_material_id,
tenant_no=tenant_no,
items=normalized,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("component_material_id") or "").strip(),
row_natural_key=lambda row: (row.component_material_id or "").strip(),
duplicate_message="配方明细编号重复",
foreign_message="配方明细不属于当前配置试剂",
)
for row in existing:
row.del_flag = 1
row.update_by = user_id
row.update_time = now
db.add(row)
for row in new_rows:
db.add(row)
db.commit()
return get_formula(db, configured_material_id, tenant_no)
+56 -34
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime
from app.crud.child_row_sync import sync_child_rows
from app.crud.production_material import _dec, _fmt_dt, _is_enabled_status
from app.crud.production_material_stock_batch import (
_create_batch_and_usage,
@@ -1007,44 +1008,65 @@ def update_prep(
lines_raw = data.get("lines")
if lines_raw is not None:
parsed_lines = _parse_prep_lines(db, lines_raw, tenant_no=tenant_no)
existing = (
db.query(BizProductionMaterialPrepLine)
.filter(
BizProductionMaterialPrepLine.prep_id == prep_id,
BizProductionMaterialPrepLine.del_flag == 0,
)
.all()
)
for ln in existing:
ln.del_flag = 1
ln.update_by = user_id
ln.update_time = now
db.add(ln)
line_items = []
for pl in parsed_lines:
comp = pl["component"]
db.add(
BizProductionMaterialPrepLine(
id=generate_uuid(),
prep_id=prep_id,
component_material_id=comp.id,
component_material_type=pl["component_type"],
component_material_code=comp.material_code,
component_material_name=comp.material_name,
requisition_usage_id=pl["requisition"].id,
actual_qty=pl["actual_qty"],
usage_unit=pl["usage_unit"] or None,
tenant_no=tenant_no,
order_num=pl["idx"],
remark=pl["remark"],
create_by=user_id,
create_dept_id=dept_id or prep.create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
line_items.append(
{
"id": (pl.get("id") or "").strip(),
"component_material_id": comp.id,
"component_material_type": pl["component_type"],
"component_material_code": comp.material_code,
"component_material_name": comp.material_name,
"requisition_usage_id": pl["requisition"].id,
"actual_qty": pl["actual_qty"],
"usage_unit": pl["usage_unit"] or None,
"order_num": pl["idx"],
"remark": pl["remark"],
}
)
def build_line(row_id):
return BizProductionMaterialPrepLine(
id=row_id,
prep_id=prep_id,
component_material_id="",
component_material_type="",
actual_qty=0,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=dept_id or prep.create_dept_id or "",
create_time=now,
)
def apply_line(row, item, _idx, _now):
row.component_material_id = item["component_material_id"]
row.component_material_type = item["component_material_type"]
row.component_material_code = item["component_material_code"]
row.component_material_name = item["component_material_name"]
row.requisition_usage_id = item["requisition_usage_id"]
row.actual_qty = item["actual_qty"]
row.usage_unit = item["usage_unit"]
row.order_num = item["order_num"]
row.remark = item["remark"]
sync_child_rows(
db,
BizProductionMaterialPrepLine,
parent_attr="prep_id",
parent_id=prep_id,
tenant_no=tenant_no,
items=line_items,
user_id=user_id,
now=now,
build_row=build_line,
apply_fields=apply_line,
natural_key=lambda item: f"{item['component_material_id']}:{item['requisition_usage_id'] or ''}",
row_natural_key=lambda row: f"{row.component_material_id}:{row.requisition_usage_id or ''}",
duplicate_message="配制明细重复",
foreign_message="配制明细不属于当前配制单",
)
prep.update_by = user_id
prep.update_time = now
db.add(prep)
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.production_material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
from app.models.models import SysUser
from app.models.production_material import (
@@ -449,6 +450,47 @@ def _resolve_unused_item_fields(
return out
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
usage = (usage_id or "").strip()
if usage:
return f"usage:{usage}"
batch = (batch_id or "").strip()
if batch:
return f"batch:{batch}"
return ""
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
material_type = it.get("material_type") if "material_type" in it else it.get("materialType")
return {
"id": str(it.get("id") or "").strip(),
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
"material_type": (material_type or "").strip() or None if material_type is not None else None,
"has_material_type": "material_type" in it or "materialType" in it,
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
"material_name": name,
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
"unit": (it.get("unit") or "").strip() or None,
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
"manufacturer": (it.get("manufacturer") or "").strip() or None,
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
"holder": (it.get("holder") or "").strip() or None,
"unused_qty": Decimal(str(qty)),
"remark": (it.get("remark") or "").strip() or None,
}
def save_unused_lines(
db: Session,
check_id: str,
@@ -465,73 +507,63 @@ def save_unused_lines(
raise ValueError("仅进行中的盘点单可登记未用完物料")
now = datetime.now()
old_rows = (
db.query(BizProductionMaterialStockCheckUnusedLine)
.filter(
BizProductionMaterialStockCheckUnusedLine.check_id == check_id,
BizProductionMaterialStockCheckUnusedLine.del_flag == 0,
)
.all()
)
for r in old_rows:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
db.add(r)
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
count = 0
for it in items or []:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
qty_dec = Decimal(str(qty))
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
row = BizProductionMaterialStockCheckUnusedLine(
id=generate_uuid(),
def build_row(row_id: str):
return BizProductionMaterialStockCheckUnusedLine(
id=row_id,
check_id=check_id,
usage_id=usage_id,
batch_id=batch_id,
material_type=(it.get("material_type") or it.get("materialType") or "").strip() or None,
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
material_name=name,
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
stock_in_date=stock_in_date,
expire_date=expire_date,
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
manufacturer=(it.get("manufacturer") or "").strip() or None,
unit_price=(
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
else None
),
holder=(it.get("holder") or "").strip() or None,
unused_qty=qty_dec,
material_name="",
unused_qty=0,
tenant_no=tenant_no,
remark=(it.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
count += 1
def apply_row(row, it, idx, _now):
row.usage_id = it["usage_id"]
row.batch_id = it["batch_id"]
if it["has_material_type"]:
row.material_type = it["material_type"]
row.material_code = it["material_code"]
row.material_name = it["material_name"]
row.spec_model = it["spec_model"]
row.unit = it["unit"]
row.stock_in_no = it["stock_in_no"]
row.stock_in_date = it["stock_in_date"]
row.expire_date = it["expire_date"]
row.catalog_no = it["catalog_no"]
row.batch_no = it["batch_no"]
row.manufacturer = it["manufacturer"]
row.unit_price = it["unit_price"]
row.holder = it["holder"]
row.unused_qty = it["unused_qty"]
row.remark = it["remark"]
row.order_num = idx
saved = sync_child_rows(
db,
BizProductionMaterialStockCheckUnusedLine,
parent_attr="check_id",
parent_id=check_id,
tenant_no=tenant_no,
items=prepared,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
duplicate_message="未用完明细重复",
foreign_message="未用完明细不属于当前盘点单",
)
check.update_by = user_id
check.update_time = now
db.add(check)
db.commit()
return count
return len(saved)
def complete_check(
+337 -119
View File
@@ -9,7 +9,9 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.utils import format_date, generate_uuid, parse_datetime
from app.crud.child_row_sync import sync_child_rows
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
from app.crud.user_display import user_display_name_map
from app.models.models import (
BizCellProduct,
BizCollectionProtocol,
@@ -727,12 +729,7 @@ def _enrich_assignee_names(db: Session, items: List[Dict[str, Any]]) -> None:
user_ids = list({(x.get("currentAssigneeUserId") or "").strip() for x in items if (x.get("currentAssigneeUserId") or "").strip()})
if not user_ids:
return
rows = (
db.query(SysUser.id, SysUser.nick_name)
.filter(SysUser.id.in_(user_ids), SysUser.del_flag == 0)
.all()
)
name_map = {r.id: (r.nick_name or "").strip() for r in rows}
name_map = user_display_name_map(db, user_ids)
for item in items:
uid = (item.get("currentAssigneeUserId") or "").strip()
if uid:
@@ -2306,10 +2303,17 @@ def replace_step_reagents(
task_no = task_row.task_no or ""
try:
_soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now)
def build_row(row_id: str) -> BizProductionTaskStepReagent:
return BizProductionTaskStepReagent(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
usage_items: List[Dict[str, Any]] = []
for idx, it in enumerate(items):
def apply_row(row: BizProductionTaskStepReagent, it: Dict[str, Any], idx: int, _now: datetime) -> None:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
@@ -2321,20 +2325,39 @@ def replace_step_reagents(
it.get("actual_usage"),
req_id,
)
db.add(BizProductionTaskStepReagent(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
reagent_code=(it.get("reagent_code") or "").strip() or None,
reagent_name=(it.get("reagent_name") or "").strip() or None,
spec=(it.get("spec") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
requisition_usage_id=req_id or None,
is_key_material=it.get("is_key_material", "0"),
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
row.reagent_code = (it.get("reagent_code") or "").strip() or None
row.reagent_name = (it.get("reagent_name") or "").strip() or None
row.spec = (it.get("spec") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.is_key_material = it.get("is_key_material", "0")
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepReagent,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("reagent_code") or "").strip(),
row_natural_key=lambda row: (row.reagent_code or "").strip(),
duplicate_message="试剂编号重复",
foreign_message="试剂不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
if req_id:
usage_items.append({
"requisitionUsageId": req_id,
@@ -2376,10 +2399,17 @@ def replace_step_consumables(
task_no = task_row.task_no or ""
try:
_soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now)
def build_row(row_id: str) -> BizProductionTaskStepConsumable:
return BizProductionTaskStepConsumable(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
usage_items: List[Dict[str, Any]] = []
for idx, it in enumerate(items):
def apply_row(row: BizProductionTaskStepConsumable, it: Dict[str, Any], idx: int, _now: datetime) -> None:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
@@ -2391,20 +2421,39 @@ def replace_step_consumables(
it.get("actual_usage"),
req_id,
)
db.add(BizProductionTaskStepConsumable(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
consumable_code=(it.get("consumable_code") or "").strip() or None,
consumable_name=(it.get("consumable_name") or "").strip() or None,
spec_model=(it.get("spec_model") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
requisition_usage_id=req_id or None,
is_required=it.get("is_required", "1"),
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
row.consumable_code = (it.get("consumable_code") or "").strip() or None
row.consumable_name = (it.get("consumable_name") or "").strip() or None
row.spec_model = (it.get("spec_model") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.is_required = it.get("is_required", "1")
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepConsumable,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("consumable_code") or "").strip(),
row_natural_key=lambda row: (row.consumable_code or "").strip(),
duplicate_message="耗材编号重复",
foreign_message="耗材不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
if req_id:
usage_items.append({
"requisitionUsageId": req_id,
@@ -2445,10 +2494,22 @@ def replace_step_configured_reagents(
task_no = task_row.task_no or ""
try:
_soft_delete_sub(db, BizProductionTaskStepConfiguredReagent, "step_id", step_id, now)
def build_row(row_id: str) -> BizProductionTaskStepConfiguredReagent:
return BizProductionTaskStepConfiguredReagent(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
usage_items: List[Dict[str, Any]] = []
for idx, it in enumerate(items):
def apply_row(
row: BizProductionTaskStepConfiguredReagent,
it: Dict[str, Any],
idx: int,
_now: datetime,
) -> None:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
@@ -2460,19 +2521,38 @@ def replace_step_configured_reagents(
it.get("actual_usage"),
req_id,
)
db.add(BizProductionTaskStepConfiguredReagent(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
configured_reagent_code=(it.get("configured_reagent_code") or "").strip() or None,
configured_reagent_name=(it.get("configured_reagent_name") or "").strip() or None,
spec=(it.get("spec") or "").strip() or None,
standard_usage=(it.get("standard_usage") or "").strip() or None,
actual_usage=(it.get("actual_usage") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
requisition_usage_id=req_id or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
row.configured_reagent_code = (it.get("configured_reagent_code") or "").strip() or None
row.configured_reagent_name = (it.get("configured_reagent_name") or "").strip() or None
row.spec = (it.get("spec") or "").strip() or None
row.standard_usage = (it.get("standard_usage") or "").strip() or None
row.actual_usage = (it.get("actual_usage") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.requisition_usage_id = req_id or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepConfiguredReagent,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("configured_reagent_code") or "").strip(),
row_natural_key=lambda row: (row.configured_reagent_code or "").strip(),
duplicate_message="配置试剂编号重复",
foreign_message="配置试剂不属于当前步骤",
)
usage_items: List[Dict[str, Any]] = []
for it in items:
req_id = (
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
)
if req_id:
usage_items.append({
"requisitionUsageId": req_id,
@@ -2492,23 +2572,62 @@ def replace_step_configured_reagents(
raise
def _apply_step_output_fields(
row: BizProductionTaskStepOutput,
item: Dict[str, Any],
*,
order_num: int,
user_id: Optional[str],
now: datetime,
) -> None:
row.output_type_id = (item.get("output_type_id") or "").strip() or None
row.output_name = (item.get("output_name") or "").strip() or None
row.output_barcode = (item.get("output_barcode") or "").strip() or None
row.remark = (item.get("remark") or "").strip() or None
row.order_num = order_num
row.update_by = user_id
row.update_time = now
row.del_flag = 0
def replace_step_outputs(
db: Session, step_id: str, tenant_no: str,
user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
) -> List[Dict[str, Any]]:
"""按产出物 ID 更新原行。带 ID 的行原地修改,没有 ID 的才新增,提交列表里没有的才软删除。
质检工单用 production_step_output_id 指向这里。整表软删再插入会换掉 ID,关联就断了。
"""
now = datetime.now()
_soft_delete_sub(db, BizProductionTaskStepOutput, "step_id", step_id, now)
for idx, it in enumerate(items):
db.add(BizProductionTaskStepOutput(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
output_type_id=(it.get("output_type_id") or "").strip() or None,
output_name=(it.get("output_name") or "").strip() or None,
output_barcode=(it.get("output_barcode") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
def build_row(row_id: str) -> BizProductionTaskStepOutput:
return BizProductionTaskStepOutput(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
result_rows = sync_child_rows(
db,
BizProductionTaskStepOutput,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=lambda row, item, idx, now_: _apply_step_output_fields(
row, item, order_num=idx, user_id=user_id, now=now_
),
duplicate_message="产出物编号重复",
foreign_message="产出物不属于当前步骤",
)
db.commit()
return [_step_output_to_dict(row) for row in result_rows]
def replace_step_data_collections(
@@ -2516,17 +2635,45 @@ def replace_step_data_collections(
user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
now = datetime.now()
_soft_delete_sub(db, BizProductionTaskStepDataCollection, "step_id", step_id, now)
for idx, it in enumerate(items):
db.add(BizProductionTaskStepDataCollection(
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
collection_name=(it.get("collection_name") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
collection_value=(it.get("collection_value") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
def build_row(row_id: str) -> BizProductionTaskStepDataCollection:
return BizProductionTaskStepDataCollection(
id=row_id,
step_id=step_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(
row: BizProductionTaskStepDataCollection,
it: Dict[str, Any],
idx: int,
_now: datetime,
) -> None:
row.collection_name = (it.get("collection_name") or "").strip() or None
row.unit = (it.get("unit") or "").strip() or None
row.collection_value = (it.get("collection_value") or "").strip() or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
sync_child_rows(
db,
BizProductionTaskStepDataCollection,
parent_attr="step_id",
parent_id=step_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: (it.get("collection_name") or "").strip(),
row_natural_key=lambda row: (row.collection_name or "").strip(),
duplicate_message="数据采集编号重复",
foreign_message="数据采集不属于当前步骤",
)
db.commit()
@@ -2604,58 +2751,129 @@ def spawn_temporary_work_order_from_task(
}
def _task_input_identity(input_type: Optional[str], input_id: Optional[str], input_code: Optional[str]) -> str:
kind = (input_type or "").strip()
ident = (input_id or "").strip() or (input_code or "").strip()
if not kind or not ident:
return ""
return f"{kind}:{ident}"
def _task_input_item_identity(it: Dict[str, Any]) -> str:
return _task_input_identity(
it.get("input_type") or it.get("inputType"),
it.get("input_id") or it.get("inputId"),
it.get("input_code") or it.get("inputCode"),
)
def _set_cell_product_status(
db: Session,
product_id: Optional[str],
*,
user_id: Optional[str],
now: datetime,
to_status: str,
from_statuses: tuple,
) -> None:
pid = (product_id or "").strip()
if not pid:
return
product = db.query(BizCellProduct).filter(
BizCellProduct.id == pid, BizCellProduct.del_flag == 0
).first()
if product and product.product_status in from_statuses:
product.product_status = to_status
product.update_by = user_id
product.update_time = now
db.add(product)
def set_task_inputs(
db: Session, task_id: str, tenant_no: str,
user_id: Optional[str], items: List[Dict[str, Any]],
) -> None:
now = datetime.now()
# --- 1. 加载旧 inputs 并按 cell_product 恢复状态 ---
old_rows = db.query(BizProductionTaskInput).filter(
and_(
BizProductionTaskInput.task_id == task_id,
BizProductionTaskInput.tenant_no == tenant_no,
BizProductionTaskInput.del_flag == 0,
)
).all()
old_identity = {
row.id: (row.input_type or "", row.input_id or "")
for row in old_rows
}
def build_row(row_id: str) -> BizProductionTaskInput:
return BizProductionTaskInput(
id=row_id,
task_id=task_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row: BizProductionTaskInput, it: Dict[str, Any], idx: int, _now: datetime) -> None:
row.input_type = (it.get("input_type") or it.get("inputType") or "").strip() or None
row.input_id = (it.get("input_id") or it.get("inputId") or "").strip() or None
row.input_code = (it.get("input_code") or it.get("inputCode") or "").strip() or None
row.input_name = (it.get("input_name") or it.get("inputName") or "").strip() or None
row.remark = (it.get("remark") or "").strip() or None
row.order_num = idx
saved = sync_child_rows(
db,
BizProductionTaskInput,
parent_attr="task_id",
parent_id=task_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_task_input_item_identity,
row_natural_key=lambda row: _task_input_identity(row.input_type, row.input_id, row.input_code),
duplicate_message="原材料编号重复",
foreign_message="原材料不属于当前任务",
)
saved_ids = {row.id for row in saved}
for old_row in old_rows:
# 如果是细胞产品,恢复状态
if old_row.input_type == "cell_product" and old_row.input_id:
product = db.query(BizCellProduct).filter(
BizCellProduct.id == old_row.input_id, BizCellProduct.del_flag == 0
).first()
if product and product.product_status == "in_production":
product.product_status = "frozen"
product.update_by = user_id
product.update_time = now
db.add(product)
# --- 2. 全量软删除旧行 ---
_soft_delete_sub(db, BizProductionTaskInput, "task_id", task_id, now)
# --- 3. 按传入顺序批量新建 ---
for idx, it in enumerate(items):
inp_type = (it.get("input_type") or it.get("inputType") or "").strip()
inp_id = (it.get("input_id") or it.get("inputId") or "").strip()
# 如果是细胞产品,设置状态为 in_production
if inp_type == "cell_product" and inp_id:
product = db.query(BizCellProduct).filter(
BizCellProduct.id == inp_id, BizCellProduct.del_flag == 0
).first()
if product and product.product_status in ("frozen", "thawed"):
product.product_status = "in_production"
product.update_by = user_id
product.update_time = now
db.add(product)
db.add(BizProductionTaskInput(
id=generate_uuid(), task_id=task_id, tenant_no=tenant_no,
input_type=inp_type or None,
input_id=inp_id or None,
input_code=(it.get("input_code") or it.get("inputCode") or "").strip() or None,
input_name=(it.get("input_name") or it.get("inputName") or "").strip() or None,
remark=(it.get("remark") or "").strip() or None,
order_num=idx, create_by=user_id, create_dept_id="",
create_time=now, update_by=user_id, update_time=now, del_flag=0,
))
if old_row.id in saved_ids:
continue
if old_row.input_type == "cell_product":
_set_cell_product_status(
db,
old_row.input_id,
user_id=user_id,
now=now,
to_status="frozen",
from_statuses=("in_production",),
)
for row in saved:
previous = old_identity.get(row.id)
current = (row.input_type or "", row.input_id or "")
if previous != current and previous and previous[0] == "cell_product":
_set_cell_product_status(
db,
previous[1],
user_id=user_id,
now=now,
to_status="frozen",
from_statuses=("in_production",),
)
if row.input_type == "cell_product":
_set_cell_product_status(
db,
row.input_id,
user_id=user_id,
now=now,
to_status="in_production",
from_statuses=("frozen", "thawed"),
)
db.flush()
db.commit()
+80 -36
View File
@@ -8,8 +8,10 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizProcessRoute,
BizProcessRouteStep,
BizProductionTask,
BizProductionTaskSet,
BizProductionTaskSetItem,
@@ -110,6 +112,39 @@ def collect_missing_route_ids(items: List[Dict[str, Any]], existing_ids: Set[str
return missing
def collect_route_ids_without_steps(
items: List[Dict[str, Any]],
route_ids_with_steps: Set[str],
) -> List[str]:
"""返回条目中已绑定路线但无有效工序步骤的 route_id(去重保序)。"""
empty: List[str] = []
seen: Set[str] = set()
with_steps = route_ids_with_steps or set()
for item in items or []:
rid = _strip_or_none(item.get("route_id"))
if not rid or rid in with_steps or rid in seen:
continue
seen.add(rid)
empty.append(rid)
return empty
def _load_route_ids_with_steps(db: Session, route_ids: List[str]) -> Set[str]:
ids = [rid for rid in route_ids if rid]
if not ids:
return set()
rows = (
db.query(BizProcessRouteStep.route_id)
.filter(
BizProcessRouteStep.route_id.in_(ids),
BizProcessRouteStep.del_flag == 0,
)
.distinct()
.all()
)
return {r[0] for r in rows if r and r[0]}
def total_pages(total: int, page_size: int) -> int:
if page_size <= 0:
return 0
@@ -222,41 +257,46 @@ def _replace_items(
create_dept_id: str,
) -> None:
now = datetime.now()
existing = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == set_id,
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
def _set_item_key(item):
return f"{(item.get('route_id') or '').strip()}|{(item.get('task_name') or '').strip()}"
def build_item(row_id):
return BizProductionTaskSetItem(
id=row_id,
set_id=set_id,
task_name="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
)
.all()
)
for row in existing:
row.del_flag = 1
row.update_by = user_id
row.update_time = now
db.add(row)
for idx, item in enumerate(items, start=1):
def apply_item(row, item, idx, _now):
rid = item.get("route_id")
db.add(
BizProductionTaskSetItem(
id=generate_uuid(),
set_id=set_id,
task_name=item["task_name"],
route_id=rid,
route_name=route_names.get(rid or "") if rid else None,
description=item.get("description"),
remark=item.get("remark"),
tenant_no=tenant_no,
order_num=idx,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
)
row.task_name = item["task_name"]
row.route_id = rid
row.route_name = route_names.get(rid or "") if rid else None
row.description = item.get("description")
row.remark = item.get("remark")
row.order_num = idx + 1
sync_child_rows(
db,
BizProductionTaskSetItem,
parent_attr="set_id",
parent_id=set_id,
tenant_no=tenant_no,
items=items,
user_id=user_id,
now=now,
build_row=build_item,
apply_fields=apply_item,
natural_key=_set_item_key,
row_natural_key=lambda row: f"{(row.route_id or '').strip()}|{(row.task_name or '').strip()}",
duplicate_message="任务集合条目重复",
foreign_message="任务集合条目不属于当前集合",
)
def _item_count_map(db: Session, tenant_no: str, set_ids: List[str]) -> Dict[str, int]:
@@ -664,12 +704,16 @@ def create_tasks_from_set(
}
for row in rows
]
name_map = _load_route_name_map(
db, tenant_no, [p["route_id"] for p in payloads if p.get("route_id")]
)
route_ids = [p["route_id"] for p in payloads if p.get("route_id")]
name_map = _load_route_name_map(db, tenant_no, route_ids)
missing = collect_missing_route_ids(payloads, set(name_map.keys()))
if missing:
raise ValueError("工艺路线已删除,无法派发:" + "、".join(missing))
with_steps = _load_route_ids_with_steps(db, route_ids)
empty_steps = collect_route_ids_without_steps(payloads, with_steps)
if empty_steps:
labels = [name_map.get(rid) or rid for rid in empty_steps]
raise ValueError("工艺路线无工序步骤,无法派发:" + "、".join(labels))
lab_storage_code = resolve_work_order_lab_storage_code(
db, tenant_no=tenant_no, wo=wo
)
@@ -182,7 +182,7 @@ def enrich_step_completed_by_names(db: Session, steps: List[Dict]) -> None:
return
users = {
u.id: u
for u in db.query(SysUser).filter(SysUser.id.in_(uids), SysUser.del_flag == 0).all()
for u in db.query(SysUser).filter(SysUser.id.in_(uids)).all()
}
for step in steps:
uid = (step.get("completedBy") or "").strip()
@@ -347,6 +347,8 @@ def _enrich_outbound_items(
"projectName": product.project_name or "",
"cellTypeName": product.cell_type_name or "",
"specText": product.spec_text or "",
"packageId": getattr(product, "package_id", None) or "",
"packageText": getattr(product, "package_text", None) or "",
"agreementPrice": _dec_price(product.agreement_price),
"actualPrice": _dec_price(product.actual_price),
"quantity": product.quantity,
@@ -435,6 +437,8 @@ def _enrich_outbound_items(
it["totalCourseCount"] = course.get("totalCourseCount")
it["cellType"] = sched.cell_type or ""
it["specText"] = sched.spec_text or ""
it["packageId"] = getattr(sched, "package_id", None) or ""
it["packageText"] = getattr(sched, "package_text", None) or ""
it["infusionTime"] = _fmt_dt(sched.infusion_time) if sched.infusion_time else ""
it["infusionLocation"] = sched.infusion_location or ""
it["products"] = products_map.get(it["infusionScheduleId"], [])
@@ -583,6 +587,8 @@ def _attach_outbound_detail(
out["infusionSchedule"] = {
"cellType": sched.cell_type if sched else "",
"specText": sched.spec_text if sched else "",
"packageId": getattr(sched, "package_id", None) or "" if sched else "",
"packageText": getattr(sched, "package_text", None) or "" if sched else "",
"infusionTime": _fmt_dt(sched.infusion_time) if sched and sched.infusion_time else "",
"infusionLocation": sched.infusion_location if sched else "",
"infusionMethod": sched.infusion_method if sched else "",
+67 -30
View File
@@ -11,6 +11,7 @@ from sqlalchemy import and_, exists, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime
from app.crud.child_row_sync import sync_child_rows
from app.crud.collection_protocol import urls_to_attachment_list
from app.models.models import (
BizCellSampleType,
@@ -746,7 +747,7 @@ def replace_schedules(
user_id: Optional[str],
schedules: List[Dict[str, Any]],
) -> List[BizProtocolPaymentSchedule]:
"""仅在全部批次为 open、全部转账为 dfj 时重建批次。"""
"""仅在全部批次为 open、全部转账为 dfj 时按期次更新批次。去掉的期次连同草稿转账一起软删。"""
sp, old_schedules, transfers = _lock_sample_payment_graph(
db,
sample_payment_id=sample_payment_id,
@@ -755,45 +756,74 @@ def replace_schedules(
if _locked_schedule_ids(old_schedules, transfers):
raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
now = _now()
if old_schedules:
for t in transfers:
t.del_flag = 1
t.update_by = user_id
t.update_time = now
db.add(t)
for s in old_schedules:
s.del_flag = 1
s.update_by = user_id
s.update_time = now
db.add(s)
created: List[BizProtocolPaymentSchedule] = []
payload = []
for idx, raw in enumerate(schedules or []):
due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
if not isinstance(raw, dict):
continue
schedule_no = _schedule_no_of(raw, idx + 1)
obj = BizProtocolPaymentSchedule(
id=generate_uuid(),
due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
payload.append(
{
"id": (raw.get("id") or "").strip(),
"schedule_no": schedule_no,
"due_amount": _dec(
raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")
),
"due_date": due_date,
"remark": (raw.get("remark") or "").strip() or None,
"order_num": idx,
}
)
def build_row(row_id):
return BizProtocolPaymentSchedule(
id=row_id,
sample_payment_id=sp.id,
collection_protocol_id=sp.collection_protocol_id,
sample_code=sp.sample_code,
schedule_no=schedule_no,
due_amount=_dec(raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")),
due_date=due_date,
schedule_status="open",
tenant_no=tenant_no,
order_num=idx,
remark=(raw.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
created.append(obj)
db.flush()
def apply_row(row, item, _idx, _now):
row.collection_protocol_id = sp.collection_protocol_id
row.sample_code = sp.sample_code
row.schedule_no = item["schedule_no"]
row.due_amount = item["due_amount"]
row.due_date = item["due_date"]
row.schedule_status = row.schedule_status or "open"
row.remark = item["remark"]
row.order_num = item["order_num"]
created = sync_child_rows(
db,
BizProtocolPaymentSchedule,
parent_attr="sample_payment_id",
parent_id=sp.id,
tenant_no=tenant_no,
items=payload,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: str(item["schedule_no"]),
row_natural_key=lambda row: str(row.schedule_no or ""),
duplicate_message="应缴批次期次重复",
foreign_message="应缴批次不属于当前缴费方案",
reuse_deleted=True,
)
kept_ids = {row.id for row in created}
for transfer in transfers:
if transfer.schedule_id in kept_ids:
continue
transfer.del_flag = 1
transfer.update_by = user_id
transfer.update_time = now
db.add(transfer)
db.flush()
return created
@@ -1350,8 +1380,12 @@ def save_sample_payments_for_protocol(
tenant_no: str,
user_id: Optional[str],
sample_payments: List[Dict[str, Any]],
commit: bool = True,
) -> None:
"""按样本保存缴费方案;锁定批次/转账所在方案禁止结构性变更。"""
"""按样本保存缴费方案;锁定批次/转账所在方案禁止结构性变更。
commit=False 时仅 flush,由调用方统一提交,避免协议其它子资源已提交后缴费校验失败导致半成功。
"""
existing = (
db.query(BizProtocolSamplePayment)
.filter(
@@ -1477,7 +1511,10 @@ def save_sample_payments_for_protocol(
_soft_delete_sample_payment(
db, sp=sp, tenant_no=tenant_no, user_id=user_id
)
db.commit()
if commit:
db.commit()
else:
db.flush()
def list_deposit_transfers_for_protocol(
+4 -14
View File
@@ -13,6 +13,7 @@ from app.crud import qc_task as task_svc
from app.crud import qc_task_assignee as assignee_svc
from app.crud import qc_third_party_item as third_item_svc
from app.crud.qc_third_party_company import get_company
from app.crud.user_display import user_display_name_map
from app.models.models import (
BizQcBatchAssignee,
BizQcTask,
@@ -20,7 +21,6 @@ from app.models.models import (
BizQcWorkOrderInspectionBatch,
BizQcWorkOrderLabItem,
BizQcWorkOrderThirdItem,
SysUser,
)
_CONFIGURABLE_WORK_ORDER_STATUSES = frozenset({"dpz", "ypz", "ywc", "yzf"})
@@ -180,13 +180,8 @@ def _batch_assignee_user_ids(db: Session, batch_id: str, tenant_no: str) -> List
def _assignee_names(db: Session, user_ids: List[str]) -> str:
if not user_ids:
return ""
rows = (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(user_ids), SysUser.del_flag == 0)
.all()
)
name_map = {r.id: (r.nick_name or r.user_no or r.id) for r in rows}
return "、".join(name_map.get(uid, uid) for uid in user_ids)
name_map = user_display_name_map(db, user_ids)
return "、".join(name_map.get(uid) or uid for uid in user_ids)
def _prefetch_batch_side_data(
@@ -248,12 +243,7 @@ def _prefetch_batch_side_data(
[uid for ids in assignees_by_batch.values() for uid in ids]
)
if all_user_ids:
for row in (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(all_user_ids), SysUser.del_flag == 0)
.all()
):
name_map[row.id] = row.nick_name or row.user_no or row.id
name_map = user_display_name_map(db, all_user_ids)
return {
"assignees_by_batch": assignees_by_batch,
+43 -34
View File
@@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.qc_param import BizQcItemParam
@@ -80,18 +80,23 @@ def save_params_for_item(
_validate_param(p, idx)
now = datetime.now()
db.query(BizQcItemParam).filter(
BizQcItemParam.item_source == item_source,
BizQcItemParam.item_id == item_id,
BizQcItemParam.tenant_no == tenant_no,
BizQcItemParam.del_flag == 0,
).update(
{"del_flag": 1, "update_by": user_id, "update_time": now},
synchronize_session="fetch",
)
db.flush()
for idx, p in enumerate(params):
def build_row(row_id):
return BizQcItemParam(
id=row_id,
item_source=item_source,
item_id=item_id,
param_name="",
param_code="",
param_type="text",
required_flag="0",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_row(row, p, idx, _now):
param_type = (p.get("paramType") or "text").strip()
options_val = None
default_val = None
@@ -99,28 +104,32 @@ def save_params_for_item(
opts = _parse_options_list(p.get("options"))
options_val = json.dumps(opts, ensure_ascii=False)
default_val = opts[0] if opts else None
row.param_name = p.get("paramName", "")
row.param_code = p.get("paramCode", "")
row.param_unit = p.get("paramUnit") or None
row.param_type = param_type
row.options = options_val
row.default_value = default_val
row.required_flag = p.get("requiredFlag", "0")
row.order_num = p.get("orderNum", idx)
obj = BizQcItemParam(
id=generate_uuid(),
item_source=item_source,
item_id=item_id,
param_name=p.get("paramName", ""),
param_code=p.get("paramCode", ""),
param_unit=p.get("paramUnit") or None,
param_type=param_type,
options=options_val,
default_value=default_val,
required_flag=p.get("requiredFlag", "0"),
tenant_no=tenant_no,
order_num=p.get("orderNum", idx),
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
sync_child_rows(
db,
BizQcItemParam,
parent_attr="item_id",
parent_id=item_id,
tenant_no=tenant_no,
items=params,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("paramCode") or "").strip(),
row_natural_key=lambda row: (row.param_code or "").strip(),
duplicate_message="参数编码重复",
foreign_message="参数不属于当前检测项目",
extra_filters={"item_source": item_source},
reuse_deleted=True,
)
db.commit()
return list_params_by_item(db, item_source, item_id, tenant_no)
+82 -54
View File
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.crud.qc_material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
from app.models.models import SysUser
from app.models.qc_material import (
@@ -439,6 +440,44 @@ def _resolve_unused_item_fields(
return out
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
usage = (usage_id or "").strip()
if usage:
return f"usage:{usage}"
batch = (batch_id or "").strip()
if batch:
return f"batch:{batch}"
return ""
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
return {
"id": str(it.get("id") or "").strip(),
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
"material_name": name,
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
"unit": (it.get("unit") or "").strip() or None,
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
"manufacturer": (it.get("manufacturer") or "").strip() or None,
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
"holder": (it.get("holder") or "").strip() or None,
"unused_qty": Decimal(str(qty)),
"remark": (it.get("remark") or "").strip() or None,
}
def save_unused_lines(
db: Session,
check_id: str,
@@ -455,72 +494,61 @@ def save_unused_lines(
raise ValueError("仅进行中的盘点单可登记未用完物料")
now = datetime.now()
old_rows = (
db.query(BizQcMaterialStockCheckUnusedLine)
.filter(
BizQcMaterialStockCheckUnusedLine.check_id == check_id,
BizQcMaterialStockCheckUnusedLine.del_flag == 0,
)
.all()
)
for r in old_rows:
r.del_flag = 1
r.update_by = user_id
r.update_time = now
db.add(r)
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
count = 0
for it in items or []:
it = _resolve_unused_item_fields(db, it, tenant_no)
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
if qty is None or Decimal(str(qty)) <= 0:
raise ValueError("未用完数量必须大于0")
qty_dec = Decimal(str(qty))
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
row = BizQcMaterialStockCheckUnusedLine(
id=generate_uuid(),
def build_row(row_id: str):
return BizQcMaterialStockCheckUnusedLine(
id=row_id,
check_id=check_id,
usage_id=usage_id,
batch_id=batch_id,
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
material_name=name,
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
unit=(it.get("unit") or "").strip() or None,
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
stock_in_date=stock_in_date,
expire_date=expire_date,
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
manufacturer=(it.get("manufacturer") or "").strip() or None,
unit_price=(
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
else None
),
holder=(it.get("holder") or "").strip() or None,
unused_qty=qty_dec,
material_name="",
unused_qty=0,
tenant_no=tenant_no,
remark=(it.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
count += 1
def apply_row(row, it, idx, _now):
row.usage_id = it["usage_id"]
row.batch_id = it["batch_id"]
row.material_code = it["material_code"]
row.material_name = it["material_name"]
row.spec_model = it["spec_model"]
row.unit = it["unit"]
row.stock_in_no = it["stock_in_no"]
row.stock_in_date = it["stock_in_date"]
row.expire_date = it["expire_date"]
row.catalog_no = it["catalog_no"]
row.batch_no = it["batch_no"]
row.manufacturer = it["manufacturer"]
row.unit_price = it["unit_price"]
row.holder = it["holder"]
row.unused_qty = it["unused_qty"]
row.remark = it["remark"]
row.order_num = idx
saved = sync_child_rows(
db,
BizQcMaterialStockCheckUnusedLine,
parent_attr="check_id",
parent_id=check_id,
tenant_no=tenant_no,
items=prepared,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
duplicate_message="未用完明细重复",
foreign_message="未用完明细不属于当前盘点单",
)
check.update_by = user_id
check.update_time = now
db.add(check)
db.commit()
return count
return len(saved)
def complete_check(
+40 -23
View File
@@ -5,8 +5,8 @@ from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud import qc_item_param as param_svc
from app.crud.child_row_sync import sync_child_rows
from app.crud.qc_task import get_task
from app.models.models import BizQcWorkOrderLabItem, BizQcWorkOrderThirdItem
from app.models.qc_param import BizQcTaskResult
@@ -152,36 +152,53 @@ def save_results_for_task(
if not task:
raise ValueError("任务不存在")
now = datetime.now()
db.query(BizQcTaskResult).filter(
BizQcTaskResult.task_id == task_id,
BizQcTaskResult.tenant_no == tenant_no,
BizQcTaskResult.del_flag == 0,
).update(
{"del_flag": 1, "update_by": user_id, "update_time": now},
synchronize_session="fetch",
)
db.flush()
for res in results:
obj = BizQcTaskResult(
id=generate_uuid(),
def _result_key(item: Dict[str, Any]) -> str:
param_id = (item.get("paramId") or "").strip()
if param_id:
return "id:" + param_id
return "code:" + (item.get("paramCode") or "").strip()
def build_row(row_id):
return BizQcTaskResult(
id=row_id,
task_id=task_id,
param_id=res.get("paramId") or None,
param_name=res.get("paramName", ""),
param_code=res.get("paramCode") or None,
param_unit=res.get("paramUnit") or None,
param_type=res.get("paramType", "text"),
param_value=res.get("paramValue") or None,
param_name="",
param_type="text",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
def apply_row(row, res, _idx, _now):
row.param_id = res.get("paramId") or None
row.param_name = res.get("paramName", "")
row.param_code = res.get("paramCode") or None
row.param_unit = res.get("paramUnit") or None
row.param_type = res.get("paramType", "text")
row.param_value = res.get("paramValue") or None
sync_child_rows(
db,
BizQcTaskResult,
parent_attr="task_id",
parent_id=task_id,
tenant_no=tenant_no,
items=results,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=_result_key,
row_natural_key=lambda row: (
"id:" + row.param_id.strip()
if (row.param_id or "").strip()
else "code:" + (row.param_code or "").strip()
),
duplicate_message="检测结果重复",
foreign_message="检测结果不属于当前任务",
)
if commit:
db.commit()
else:
@@ -0,0 +1,159 @@
"""临时质检列表:同生产工单成组,组位置按组内最新创建时间倒序。"""
from __future__ import annotations
from functools import cmp_to_key
from typing import Any, Dict, List, Optional, Sequence, Tuple
from sqlalchemy import and_, func
from sqlalchemy.orm import Query
from app.models.models import BizQcWorkOrder
def uses_temporary_production_cluster_list_sort(work_order_type: Optional[str]) -> bool:
return (work_order_type or "").strip() == "temporary"
def temporary_production_cluster_key(
production_work_order_id: Optional[str], row_id: str
) -> str:
pwo = (production_work_order_id or "").strip()
if pwo:
return f"pwo:{pwo}"
return f"id:{(row_id or '').strip()}"
def _max_create_time_by_cluster(
rows: Sequence[Tuple[str, Optional[str], str]],
) -> Dict[str, str]:
"""rows: (id, production_work_order_id, create_time_str)"""
group_max: Dict[str, str] = {}
for row_id, pwo_id, create_time in rows:
key = temporary_production_cluster_key(pwo_id, row_id)
t = (create_time or "").strip()
prev = group_max.get(key, "")
if t > prev:
group_max[key] = t
return group_max
def compare_temporary_production_cluster_desc(
id_a: str,
pwo_a: Optional[str],
time_a: Optional[str],
group_max_a: str,
id_b: str,
pwo_b: Optional[str],
time_b: Optional[str],
group_max_b: str,
) -> int:
"""负值表示 a 应排在 b 前。"""
ga = (group_max_a or "").strip()
gb = (group_max_b or "").strip()
if ga != gb:
return -1 if ga > gb else 1
key_a = temporary_production_cluster_key(pwo_a, id_a)
key_b = temporary_production_cluster_key(pwo_b, id_b)
if key_a != key_b:
return -1 if key_a < key_b else 1
ta = (time_a or "").strip()
tb = (time_b or "").strip()
if ta != tb:
return -1 if ta > tb else 1
ia = (id_a or "").strip()
ib = (id_b or "").strip()
if ia != ib:
return -1 if ia > ib else 1
return 0
def sort_dicts_by_temporary_production_cluster(
items: List[Dict[str, Any]],
*,
id_key: str = "id",
pwo_key: str = "productionWorkOrderId",
time_key: str = "createTime",
) -> List[Dict[str, Any]]:
"""按设计规则排序质检工单 dict 列表(供单测 / 内存排序)。"""
triples: List[Tuple[str, Optional[str], str]] = []
for item in items:
rid = str(item.get(id_key) or "")
pwo = item.get(pwo_key)
pwo_s: Optional[str]
if pwo is None:
pwo_s = None
elif isinstance(pwo, str):
pwo_s = pwo
else:
pwo_s = str(pwo)
triples.append((rid, pwo_s, str(item.get(time_key) or "")))
group_max = _max_create_time_by_cluster(triples)
def cmp(a: Dict[str, Any], b: Dict[str, Any]) -> int:
id_a = str(a.get(id_key) or "")
id_b = str(b.get(id_key) or "")
pwo_a = a.get(pwo_key)
pwo_b = b.get(pwo_key)
if pwo_a is not None and not isinstance(pwo_a, str):
pwo_a = str(pwo_a)
if pwo_b is not None and not isinstance(pwo_b, str):
pwo_b = str(pwo_b)
time_a = str(a.get(time_key) or "")
time_b = str(b.get(time_key) or "")
key_a = temporary_production_cluster_key(pwo_a, id_a)
key_b = temporary_production_cluster_key(pwo_b, id_b)
return compare_temporary_production_cluster_desc(
id_a,
pwo_a,
time_a,
group_max.get(key_a, ""),
id_b,
pwo_b,
time_b,
group_max.get(key_b, ""),
)
return sorted(items, key=cmp_to_key(cmp))
def apply_temporary_production_cluster_order(q: Query) -> Query:
"""对已过滤的 BizQcWorkOrder Query 施加成组排序(MySQL 5.7 友好)。"""
session = q.session
base = (
q.order_by(None)
.with_entities(
BizQcWorkOrder.id.label("wo_id"),
BizQcWorkOrder.production_work_order_id.label("pwo_id"),
BizQcWorkOrder.create_time.label("ct"),
)
.subquery()
)
pwo_max = (
session.query(
base.c.pwo_id.label("pwo_id"),
func.max(base.c.ct).label("group_ct"),
)
.select_from(base)
.filter(
base.c.pwo_id.isnot(None),
base.c.pwo_id != "",
)
.group_by(base.c.pwo_id)
.subquery()
)
return (
q.outerjoin(
pwo_max,
and_(
BizQcWorkOrder.production_work_order_id == pwo_max.c.pwo_id,
BizQcWorkOrder.production_work_order_id.isnot(None),
BizQcWorkOrder.production_work_order_id != "",
),
)
.order_by(
func.coalesce(pwo_max.c.group_ct, BizQcWorkOrder.create_time).desc(),
func.coalesce(BizQcWorkOrder.production_work_order_id, BizQcWorkOrder.id).asc(),
BizQcWorkOrder.create_time.desc(),
BizQcWorkOrder.id.desc(),
)
)
+130 -7
View File
@@ -6,10 +6,14 @@ from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy import and_, exists, func, or_
from sqlalchemy.orm import Session
from app.core.db_collation import eq_unicode, like_unicode, unicode_ci
from app.core.protocol_status_display import (
DEFAULT_QC_DONATION_PROTOCOL_STATUSES,
resolve_research_donation_protocol_status_filter,
)
from app.core.utils import generate_uuid
from app.crud import qc_lab_test_item as lab_item_svc
from app.crud import qc_task as task_svc
@@ -20,6 +24,10 @@ from app.crud.qc_inbound_lab_storage_sort import (
inbound_lab_storage_order_by_clauses,
uses_inbound_lab_storage_list_sort,
)
from app.crud.qc_temporary_production_cluster_sort import (
apply_temporary_production_cluster_order,
uses_temporary_production_cluster_list_sort,
)
from app.crud.collection_protocol import urls_to_attachment_list
from app.utils.sample_special import apply_special_to_item_from_samples
from app.crud import sample_reception as sample_reception_svc
@@ -219,10 +227,51 @@ def _enrich_work_order_cell_products(
return items
def _step_output_identity(row: BizProductionTaskStepOutput) -> Tuple[str, str, str, str]:
return (
(row.step_id or "").strip(),
(row.output_name or "").strip(),
(row.output_barcode or "").strip(),
(row.output_type_id or "").strip(),
)
def _live_output_replacements(
db: Session,
tenant_no: str,
deleted_rows: List[BizProductionTaskStepOutput],
) -> Dict[str, BizProductionTaskStepOutput]:
"""已软删产出物 → 同一步骤上名称、条码、产出类型都一致的唯一有效行。"""
step_ids = list({(row.step_id or "").strip() for row in deleted_rows if (row.step_id or "").strip()})
if not step_ids:
return {}
live_rows = (
db.query(BizProductionTaskStepOutput)
.filter(
BizProductionTaskStepOutput.step_id.in_(step_ids),
BizProductionTaskStepOutput.tenant_no == tenant_no,
BizProductionTaskStepOutput.del_flag == 0,
)
.all()
)
grouped: Dict[Tuple[str, str, str, str], List[BizProductionTaskStepOutput]] = defaultdict(list)
for row in live_rows:
grouped[_step_output_identity(row)].append(row)
replacements: Dict[str, BizProductionTaskStepOutput] = {}
for old in deleted_rows:
matches = [row for row in grouped.get(_step_output_identity(old), []) if row.id != old.id]
if len(matches) == 1:
replacements[old.id] = matches[0]
return replacements
def _enrich_work_order_step_outputs(
db: Session, tenant_no: str, items: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""步骤产出物触发的质检:无细胞产品时用工单条码/名称展示质检对象。"""
"""步骤产出物触发的质检:无细胞产品时用有效产出物的条码和名称展示质检对象。
工单若仍指向已软删产出物,改用同一步骤上的那条有效产出物,不再拿软删行当质检对象。
"""
if not items:
return items
step_output_ids = list(
@@ -240,17 +289,23 @@ def _enrich_work_order_step_outputs(
.filter(
BizProductionTaskStepOutput.id.in_(step_output_ids),
BizProductionTaskStepOutput.tenant_no == tenant_no,
BizProductionTaskStepOutput.del_flag == 0,
)
.all()
)
row_map = {r.id: r for r in rows}
deleted_rows = [row for row in rows if int(row.del_flag or 0) == 1]
live_by_deleted_id = (
_live_output_replacements(db, tenant_no, deleted_rows) if deleted_rows else {}
)
row_map = {row.id: row for row in rows}
for item in items:
if (item.get("cellProductId") or "").strip():
continue
so = row_map.get(item.get("productionStepOutputId") or "")
if not so:
if so is not None and int(so.del_flag or 0) == 1:
so = live_by_deleted_id.get(so.id)
if so is None or int(so.del_flag or 0) == 1:
continue
item["productionStepOutputId"] = so.id
if not (item.get("cellProductCode") or "").strip():
item["cellProductCode"] = (so.output_barcode or "").strip()
if not (item.get("cellProductName") or "").strip():
@@ -298,6 +353,8 @@ def _enrich_outbound_work_orders(
"projectName": product.project_name or "",
"cellTypeName": product.cell_type_name or "",
"specText": product.spec_text or "",
"packageId": getattr(product, "package_id", None) or "",
"packageText": getattr(product, "package_text", None) or "",
"agreementPrice": _dec(product.agreement_price),
"actualPrice": _dec(product.actual_price),
"quantity": product.quantity,
@@ -381,6 +438,8 @@ def _enrich_outbound_work_orders(
item["totalCourseCount"] = course.get("totalCourseCount")
item["cellType"] = sched.cell_type or ""
item["specText"] = sched.spec_text or ""
item["packageId"] = getattr(sched, "package_id", None) or ""
item["packageText"] = getattr(sched, "package_text", None) or ""
item["infusionTime"] = _fmt_dt(sched.infusion_time)
item["products"] = products_map.get(sid, [])
return items
@@ -1566,6 +1625,52 @@ def _protocol_sample_ids_for_sample_type(
return ids
def _research_donation_status_exists(statuses: List[str]):
"""质检工单关联科研捐赠单且 donation_status 命中 statuses。"""
return exists().where(
and_(
eq_unicode(BizResearchDonation.id, BizQcWorkOrder.collection_protocol_id),
BizResearchDonation.del_flag == 0,
BizResearchDonation.donation_status.in_(statuses),
)
)
def apply_research_donation_protocol_status_filter(
query,
protocol_status: Optional[str],
*,
reception_source: Optional[str] = None,
force_research_donation_scope: bool = False,
):
"""科研捐赠质检:按协议/捐赠状态过滤。
- force_research_donation_scope=True:用于全量 Tab 统计,仅剔除 RD 废弃行,其它来源不动。
- 否则仅当 reception_source == research_donation 时生效。
"""
rs = (reception_source or "").strip()
if force_research_donation_scope:
# 角标与列表默认一致:科研捐赠仅计待生效/生效中
not_default = and_(
BizQcWorkOrder.reception_source == "research_donation",
~_research_donation_status_exists(list(DEFAULT_QC_DONATION_PROTOCOL_STATUSES)),
)
return query.filter(~not_default)
if rs != "research_donation":
return query
mode, statuses = resolve_research_donation_protocol_status_filter(protocol_status)
if mode == "none":
return query.filter(BizQcWorkOrder.id.in_([]))
if mode == "in":
return query.filter(_research_donation_status_exists(statuses))
# default:与前端默认勾选一致
return query.filter(
_research_donation_status_exists(list(DEFAULT_QC_DONATION_PROTOCOL_STATUSES))
)
def list_work_orders(
db: Session,
*,
@@ -1578,6 +1683,7 @@ def list_work_orders(
reception_source: Optional[str] = None,
trigger_source: Optional[str] = None,
sample_type_id: Optional[str] = None,
protocol_status: Optional[str] = None,
create_time_start: Optional[str] = None,
create_time_end: Optional[str] = None,
infusion_time_start: Optional[str] = None,
@@ -1708,6 +1814,11 @@ def list_work_orders(
if user_info:
from app.core.data_scope import apply_data_scope
q0 = apply_data_scope(q0, BizQcWorkOrder, user_info, db, tenant_no)
q0 = apply_research_donation_protocol_status_filter(
q0,
protocol_status,
reception_source=rs,
)
return q0
total = _filtered().order_by(None).with_entities(func.count(BizQcWorkOrder.id)).scalar() or 0
@@ -1716,6 +1827,8 @@ def list_work_orders(
q_page = _filtered()
if uses_inbound_lab_storage_list_sort(reception_source):
q_page = q_page.order_by(*inbound_lab_storage_order_by_clauses())
elif uses_temporary_production_cluster_list_sort(work_order_type):
q_page = apply_temporary_production_cluster_order(q_page)
else:
q_page = q_page.order_by(BizQcWorkOrder.create_time.desc())
rows = (
@@ -1941,6 +2054,9 @@ def count_unfinished_work_orders_by_tab(
BizQcWorkOrder.tenant_no == tenant_no,
)
q = _unfinished_work_order_filter(q)
q = apply_research_donation_protocol_status_filter(
q, None, force_research_donation_scope=True
)
if user_info:
from app.core.data_scope import apply_data_scope
@@ -1997,6 +2113,9 @@ def count_mine_unfinished_work_orders_by_tab(
)
)
q = _unfinished_work_order_filter(q)
q = apply_research_donation_protocol_status_filter(
q, None, force_research_donation_scope=True
)
rows = (
q.group_by(
BizQcWorkOrder.work_order_type,
@@ -3215,9 +3334,9 @@ def list_temp_qc_work_orders(
total = _filtered().order_by(None).with_entities(func.count(BizQcWorkOrder.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
q_page = apply_temporary_production_cluster_order(_filtered())
rows = (
_filtered()
.order_by(BizQcWorkOrder.create_time.desc())
q_page
.offset((page - 1) * page_size)
.limit(page_size)
.all()
@@ -3422,6 +3541,8 @@ def build_outbound_pilot_context_by_schedule(
"infusionMethod": sched.infusion_method or "",
"cellType": sched.cell_type or "",
"specText": sched.spec_text or "",
"packageId": getattr(sched, "package_id", None) or "",
"packageText": getattr(sched, "package_text", None) or "",
"scheduleStatus": sched.schedule_status or "",
"infusionStatus": sched.infusion_status or "",
"qcStatus": sched.qc_status or "",
@@ -3510,6 +3631,8 @@ def build_outbound_pilot_context_by_schedule(
"projectName": p.project_name or "",
"cellTypeName": p.cell_type_name or "",
"specText": p.spec_text or "",
"packageId": getattr(p, "package_id", None) or "",
"packageText": getattr(p, "package_text", None) or "",
"agreementPrice": _dec(p.agreement_price),
"actualPrice": _dec(p.actual_price),
"quantity": p.quantity,
+276 -117
View File
@@ -11,17 +11,22 @@ 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.child_row_sync import sync_child_rows
from app.crud.base import camel_to_snake
from app.crud.biz_no_generator import allocate_next_biz_no
from app.crud.collection_protocol import (
append_protocol_status_change,
attachments_to_urls,
urls_to_attachment_list,
validate_persisted_sample_codes_immutable,
validate_sample_codes_not_used,
_format_sample_collect_summary,
_load_express_name_map,
_load_sample_type_name_map,
_load_site_name_map,
_normalize_yyyymmdd_date,
_owned_child_row,
_retarget_protocol_sample_id,
)
from app.crud.protocol_donation_convert import (
assert_biz_no_available,
@@ -44,6 +49,8 @@ from app.models.models import (
BizProtocolPartyCustomer,
BizProtocolSample,
BizProtocolSampleTransport,
BizProductionWorkOrder,
BizQcSmsNotification,
BizQcWorkOrder,
BizSampleReception,
SysUser,
@@ -1156,7 +1163,7 @@ def create_donation(
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,
donation_date=_normalize_yyyymmdd_date(raw.get("donation_date")),
source_type=(raw.get("source_type") or "direct").strip(),
source_protocol_id=(raw.get("source_protocol_id") or "").strip() or None,
consent_form_urls=consent or None,
@@ -1247,7 +1254,7 @@ def update_donation(
commit: bool = True,
sync_customers: bool = True,
) -> BizResearchDonation:
"""更新科研捐赠单:主表字段 + 子资源 upsert(软删旧 + 创建新)。"""
"""更新科研捐赠单:主表字段就地更新,子资源按行同步。"""
obj = (
db.query(BizResearchDonation)
.filter(
@@ -1287,7 +1294,10 @@ def update_donation(
val = raw[k]
if val is None:
continue
setattr(obj, k, (val or "").strip() or None)
if k == "donation_date":
setattr(obj, k, _normalize_yyyymmdd_date(val))
else:
setattr(obj, k, (val or "").strip() or None)
for k in _MAIN_DT_FIELDS:
if k in raw:
val = raw[k]
@@ -1300,11 +1310,9 @@ def update_donation(
db.add(obj)
db.flush()
# party upsert: 软删旧 → 创建新
if "party" in raw:
from app.services.party_customer_sync import sync_party_customers
_soft_delete_party(db, donation_id, tenant_no, user_id)
party_raw = _normalize_fields(raw["party"] or {})
if sync_customers:
party_raw = sync_party_customers(
@@ -1320,9 +1328,7 @@ def update_donation(
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,
@@ -1332,18 +1338,18 @@ def update_donation(
# samples upsert
if "samples" in raw:
existing_samples = list_samples(db, donation_id, tenant_no)
samples_raw = raw["samples"] or []
validate_persisted_sample_codes_immutable(existing_samples, samples_raw)
old_id_to_code = {
s.id: (s.sample_code or "").strip()
for s in existing_samples
if (s.sample_code or "").strip()
}
_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,
create_dept_id=create_dept_id,
)
kept_ids = {s.id for s in new_samples}
new_code_to_id = {
(s.sample_code or "").strip(): s.id
for s in new_samples
@@ -1352,7 +1358,9 @@ def update_donation(
_migrate_donation_sample_child_refs_on_replace(
db,
tenant_no=tenant_no,
old_id_to_code=old_id_to_code,
old_id_to_code={
sid: code for sid, code in old_id_to_code.items() if sid not in kept_ids
},
new_code_to_id=new_code_to_id,
user_id=user_id,
)
@@ -2220,6 +2228,26 @@ _PARTY_DATE_FIELDS = ["expected_delivery_date"]
_PARTY_DT_FIELDS = ["newborn_delivery_time"]
def _apply_single_child_fields(obj, raw, str_fields, int_fields=(), dec_fields=(), date_fields=(), dt_fields=()):
obj.remark = (raw.get("remark") or "").strip() or None
for key in str_fields:
setattr(obj, key, ((raw.get(key) or "").strip() or None) if key in raw else None)
for key in int_fields:
setattr(obj, key, raw.get(key) if key in raw else None)
for key in dec_fields:
setattr(obj, key, _optional_decimal(raw.get(key)) if key in raw else None)
for key in date_fields:
setattr(obj, key, optional_date(raw.get(key)) if key in raw else None)
for key in dt_fields:
if key not in raw:
setattr(obj, key, None)
continue
val = raw.get(key)
if isinstance(val, str):
val = parse_datetime(val)
setattr(obj, key, val)
def _create_party(
db: Session,
donation_id: str,
@@ -2229,40 +2257,46 @@ def _create_party(
now: datetime,
create_dept_id: str = "",
) -> BizResearchDonationParty:
obj = BizResearchDonationParty(
id=generate_uuid(),
research_donation_id=donation_id,
def build_row(row_id):
return BizResearchDonationParty(
id=row_id,
research_donation_id=donation_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
)
def apply_row(row, item, _idx, _now):
row.order_num = 0
_apply_single_child_fields(
row,
item,
_PARTY_STR_FIELDS,
_PARTY_INT_FIELDS,
_PARTY_DEC_FIELDS,
_PARTY_DATE_FIELDS,
_PARTY_DT_FIELDS,
)
rows = sync_child_rows(
db,
BizResearchDonationParty,
parent_attr="research_donation_id",
parent_id=donation_id,
tenant_no=tenant_no,
order_num=0,
remark=(raw.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
items=[raw or {}],
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda _item: "party",
row_natural_key=lambda _row: "party",
duplicate_message="当事人信息重复",
foreign_message="当事人信息不属于当前捐赠单",
)
for k in _PARTY_STR_FIELDS:
if k in raw:
setattr(obj, k, (raw[k] or "").strip() or None)
for k in _PARTY_INT_FIELDS:
if k in raw:
setattr(obj, k, raw[k])
for k in _PARTY_DEC_FIELDS:
if k in raw:
setattr(obj, k, _optional_decimal(raw[k]))
for k in _PARTY_DATE_FIELDS:
if k in raw:
setattr(obj, k, optional_date(raw[k]))
for k in _PARTY_DT_FIELDS:
if k in raw:
val = raw[k]
if isinstance(val, str):
val = parse_datetime(val)
setattr(obj, k, val)
db.add(obj)
db.flush()
return obj
return rows[0]
_COLL_STR_FIELDS = [
@@ -2284,31 +2318,43 @@ def _create_collection(
now: datetime,
create_dept_id: str = "",
) -> BizResearchDonationCollection:
obj = BizResearchDonationCollection(
id=generate_uuid(),
research_donation_id=donation_id,
def build_row(row_id):
return BizResearchDonationCollection(
id=row_id,
research_donation_id=donation_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
)
def apply_row(row, item, _idx, _now):
row.order_num = 0
_apply_single_child_fields(
row,
item,
_COLL_STR_FIELDS,
dt_fields=_COLL_DT_FIELDS,
)
rows = sync_child_rows(
db,
BizResearchDonationCollection,
parent_attr="research_donation_id",
parent_id=donation_id,
tenant_no=tenant_no,
order_num=0,
remark=(raw.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
items=[raw or {}],
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda _item: "collection",
row_natural_key=lambda _row: "collection",
duplicate_message="采集信息重复",
foreign_message="采集信息不属于当前捐赠单",
)
for k in _COLL_STR_FIELDS:
if k in raw:
setattr(obj, k, (raw[k] or "").strip() or None)
for k in _COLL_DT_FIELDS:
if k in raw:
val = raw[k]
if isinstance(val, str):
val = parse_datetime(val)
setattr(obj, k, val)
db.add(obj)
db.flush()
return obj
return rows[0]
_SAMPLE_STR_FIELDS = [
@@ -2375,6 +2421,55 @@ def _migrate_donation_sample_child_refs_on_replace(
row.update_time = now
db.add(row)
_retarget_protocol_sample_id(
db,
BizProductionWorkOrder,
old_ids=old_ids,
old_id_to_code=old_id_to_code,
new_code_to_id=new_code_to_id,
tenant_no=tenant_no,
user_id=user_id,
now=now,
)
_retarget_protocol_sample_id(
db,
BizQcSmsNotification,
old_ids=old_ids,
old_id_to_code=old_id_to_code,
new_code_to_id=new_code_to_id,
tenant_no=tenant_no,
user_id=user_id,
now=now,
del_flag_alive="0",
)
def _apply_donation_sample_fields(obj, raw, *, order_num, user_id, now) -> None:
obj.order_num = order_num
obj.remark = (raw.get("remark") or "").strip() or None
obj.update_by = user_id
obj.update_time = now
obj.del_flag = 0
for key in _SAMPLE_STR_FIELDS:
if key == "sample_status":
continue
if key in raw:
setattr(obj, key, (raw[key] or "").strip() or None)
if "sample_status" in raw:
status_val = raw.get("sample_status")
obj.sample_status = (
str(status_val).strip()
if status_val is not None and str(status_val).strip()
else None
)
for key in _SAMPLE_DEC_FIELDS:
if key in raw and raw[key] is not None:
setattr(obj, key, Decimal(str(raw[key])))
for key in _SAMPLE_INT_FIELDS:
if key in raw:
setattr(obj, key, raw[key])
apply_sample_special_fields(obj, raw)
def _create_samples(
db: Session,
@@ -2385,19 +2480,62 @@ def _create_samples(
now: datetime,
create_dept_id: str = "",
) -> List[BizResearchDonationSample]:
existing = list_samples(db, donation_id, tenant_no)
by_id = {sample.id: sample for sample in existing}
by_code: Dict[str, List[BizResearchDonationSample]] = {}
for sample in existing:
code = (sample.sample_code or "").strip()
if code:
by_code.setdefault(code, []).append(sample)
claimed = set()
planned = []
for idx, raw_item in enumerate(samples or []):
raw = _normalize_fields(raw_item)
validate_sample_special_raw(raw, index=idx)
sid = (raw.get("id") or "").strip()
code = (raw.get("sample_code") or "").strip()
row = by_id.get(sid) if sid else None
if row is None and sid:
row = _owned_child_row(
db,
BizResearchDonationSample,
sid,
parent_attr="research_donation_id",
parent_id=donation_id,
tenant_no=tenant_no,
message="样本不属于当前捐赠单",
)
if row is None and code:
for candidate in by_code.get(code, []):
if candidate.id not in claimed:
row = candidate
break
if row is not None:
if row.id in claimed:
raise ValueError("样本编号重复")
claimed.add(row.id)
planned.append((idx, raw, row))
kept_codes = {
(sample.sample_code or "").strip()
for sample in existing
if sample.id in claimed and (sample.sample_code or "").strip()
}
validate_sample_codes_not_used(
db,
tenant_no=tenant_no,
samples=samples,
samples=[
raw
for _idx, raw, row in planned
if row is None or (raw.get("sample_code") or "").strip() not in kept_codes
],
exclude_donation_id=donation_id,
)
result = []
assigned_codes: List[str] = []
for idx, raw_item in enumerate(samples or []):
raw = _normalize_fields(raw_item)
validate_sample_special_raw(raw, index=idx)
kept_transport_ids = set()
for idx, raw, obj in planned:
code = (raw.get("sample_code") or "").strip()
if not code and (raw.get("sample_type_id") or "").strip():
if obj is None and not code and (raw.get("sample_type_id") or "").strip():
code = next_donation_sample_code(
db,
tenant_no=tenant_no,
@@ -2406,43 +2544,24 @@ def _create_samples(
raw["sample_code"] = code
if code:
assigned_codes.append(code)
obj = BizResearchDonationSample(
id=generate_uuid(),
research_donation_id=donation_id,
tenant_no=tenant_no,
order_num=idx,
remark=(raw.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
for k in _SAMPLE_STR_FIELDS:
if k == "sample_status":
continue
if k in raw:
setattr(obj, k, (raw[k] or "").strip() or None)
if "sample_status" in raw:
status_val = raw.get("sample_status")
obj.sample_status = (
str(status_val).strip()
if status_val is not None and str(status_val).strip()
else None
if obj is None:
obj = BizResearchDonationSample(
id=(raw.get("id") or "").strip() or generate_uuid(),
research_donation_id=donation_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
)
for k in _SAMPLE_DEC_FIELDS:
if k in raw and raw[k] is not None:
setattr(obj, k, Decimal(str(raw[k])))
for k in _SAMPLE_INT_FIELDS:
if k in raw:
setattr(obj, k, raw[k])
apply_sample_special_fields(obj, raw)
_apply_donation_sample_fields(
obj, raw, order_num=idx, user_id=user_id, now=now
)
obj.research_donation_id = donation_id
obj.tenant_no = tenant_no
db.add(obj)
db.flush()
result.append(obj)
# 预计到达等仍写运输表;运费由 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")}
@@ -2451,7 +2570,7 @@ def _create_samples(
):
transport_raw = {**transport_raw, "eta_time": raw.get("eta_time")}
if transport_raw:
_create_transport(
transport_row = _create_transport(
db,
donation_id,
obj.id,
@@ -2462,7 +2581,31 @@ def _create_samples(
now,
sample_code=obj.sample_code,
)
if transport_row is not None:
kept_transport_ids.add(transport_row.id)
for sample in existing:
if sample.id in claimed:
continue
sample.del_flag = 1
sample.update_by = user_id
sample.update_time = now
db.add(sample)
for transport in (
db.query(BizResearchDonationSampleTransport)
.filter(
BizResearchDonationSampleTransport.research_donation_id == donation_id,
BizResearchDonationSampleTransport.tenant_no == tenant_no,
BizResearchDonationSampleTransport.del_flag == 0,
)
.all()
):
if transport.id in kept_transport_ids:
continue
transport.del_flag = 1
transport.update_by = user_id
transport.update_time = now
db.add(transport)
return result
@@ -2492,30 +2635,46 @@ def _create_transport(
else:
express_fee = None
obj = BizResearchDonationSampleTransport(
existing = (
db.query(BizResearchDonationSampleTransport)
.filter(
BizResearchDonationSampleTransport.research_donation_id == donation_id,
BizResearchDonationSampleTransport.sample_id == sample_id,
BizResearchDonationSampleTransport.tenant_no == tenant_no,
BizResearchDonationSampleTransport.del_flag == 0,
)
.order_by(BizResearchDonationSampleTransport.create_time.asc())
.all()
)
obj = existing[0] if existing else BizResearchDonationSampleTransport(
id=generate_uuid(),
research_donation_id=donation_id,
sample_id=sample_id,
sample_code=sample_code or None,
transport_type=transport_type,
express_company_id=(tnorm.get("express_company_id") or "").strip() or None,
waybill_no=(tnorm.get("waybill_no") or "").strip() or None,
dest_site_id=(tnorm.get("dest_site_id") or "").strip() or None,
express_fee=express_fee,
eta_time=eta_time,
carrier_name=(tnorm.get("carrier_name") or "").strip() or None,
carrier_phone=(tnorm.get("carrier_phone") or "").strip() or None,
tenant_no=tenant_no,
order_num=order_num,
remark=(tnorm.get("remark") or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
obj.sample_code = sample_code or None
obj.transport_type = transport_type
obj.express_company_id = (tnorm.get("express_company_id") or "").strip() or None
obj.waybill_no = (tnorm.get("waybill_no") or "").strip() or None
obj.dest_site_id = (tnorm.get("dest_site_id") or "").strip() or None
obj.express_fee = express_fee
obj.eta_time = eta_time
obj.carrier_name = (tnorm.get("carrier_name") or "").strip() or None
obj.carrier_phone = (tnorm.get("carrier_phone") or "").strip() or None
obj.order_num = order_num
obj.remark = (tnorm.get("remark") or "").strip() or None
obj.del_flag = 0
obj.update_by = user_id
obj.update_time = now
db.add(obj)
for extra in existing[1:]:
extra.del_flag = 1
extra.update_by = user_id
extra.update_time = now
db.add(extra)
db.flush()
return obj
+49 -43
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows, sync_link_rows
from app.models.models import (
SysPost,
SysPostRole,
@@ -377,26 +378,18 @@ def update_role_menus(
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
# uk_role_menu 为 (role_id, menu_id, del_flag):先全量软删再插入会在再次保存时
# 出现两条 (role_id, menu_id, del_flag=1),触发 1062。关联表采用整角色物理清理后重建。
unique_ids = list(dict.fromkeys(menu_ids or []))
db.query(SysRoleMenu).filter(SysRoleMenu.role_id == role_id).delete(synchronize_session=False)
now = datetime.now()
for mid in unique_ids:
obj = SysRoleMenu(
id=generate_uuid(),
role_id=role_id,
menu_id=mid,
tenant_no=tenant_no,
order_num=0,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
sync_link_rows(
db,
SysRoleMenu,
parent_attr="role_id",
parent_id=role_id,
link_attr="menu_id",
link_ids=menu_ids,
tenant_no=tenant_no,
user_id=user_id,
duplicate_message="菜单重复",
foreign_message="菜单关联不属于当前角色",
)
db.commit()
@@ -430,28 +423,41 @@ def update_role_data_scope(
obj.update_time = datetime.now()
db.add(obj)
# soft-delete old scope details
db.query(SysRoleDataScopeDetail).filter(
and_(SysRoleDataScopeDetail.role_id == role_id, SysRoleDataScopeDetail.del_flag == 0)
).update({"del_flag": 1, "update_time": datetime.now()})
now = datetime.now()
details = scope_details if data_scope == "5" and scope_details else []
# insert new scope details (only when custom data_scope == '5')
if data_scope == "5" and scope_details:
now = datetime.now()
for detail in scope_details:
sd = SysRoleDataScopeDetail(
id=generate_uuid(),
role_id=role_id,
scope_type=detail.get("scopeType", "dept"),
scope_value=detail.get("scopeValue", ""),
tenant_no=tenant_no,
order_num=0,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(sd)
def build_scope(row_id):
return SysRoleDataScopeDetail(
id=row_id,
role_id=role_id,
scope_type="",
scope_value="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
def apply_scope(row, detail, _idx, _now):
row.scope_type = detail.get("scopeType", "dept")
row.scope_value = detail.get("scopeValue", "")
row.order_num = 0
sync_child_rows(
db,
SysRoleDataScopeDetail,
parent_attr="role_id",
parent_id=role_id,
tenant_no=tenant_no,
items=details,
user_id=user_id,
now=now,
build_row=build_scope,
apply_fields=apply_scope,
natural_key=lambda detail: f"{detail.get('scopeType', 'dept')}:{detail.get('scopeValue', '')}",
row_natural_key=lambda row: f"{row.scope_type}:{row.scope_value}",
duplicate_message="数据权限重复",
foreign_message="数据权限不属于当前角色",
reuse_deleted=True,
)
db.commit()
+1 -1
View File
@@ -23,7 +23,7 @@ def _resolve_issued_user_display_name(
uid = (user_id or "").strip()
if not uid:
return None
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
u = db.query(SysUser).filter(SysUser.id == uid).first()
if not u:
return None
return (u.nick_name or u.user_no or "").strip() or None
+18 -1
View File
@@ -1230,12 +1230,29 @@ def apply_freight_on_protocol_save(
if keep_count != stale_count:
raise ValueError("已结算的运费不能调整样本")
settled_relink_ids.add(freight_id)
keep_sample_ids = set()
for idx, sample_obj in enumerate(saved_samples or []):
if idx >= len(input_samples or []):
break
raw = input_samples[idx] if isinstance(input_samples[idx], dict) else {}
sample_id = str(getattr(sample_obj, "id", None) or "").strip()
if not sample_id:
continue
draft = raw.get("freightDraft")
if draft is None:
draft = raw.get("freight_draft")
if _input_freight_id(raw) or isinstance(draft, dict):
keep_sample_ids.add(sample_id)
removed_freight_item = False
for stale in stale_items:
if stale.sample_id in keep_sample_ids:
continue
stale.del_flag = 1
stale.update_by = user_id
stale.update_time = now
db.add(stale)
if stale_items:
removed_freight_item = True
if removed_freight_item:
db.flush()
draft_groups: Dict[str, Dict[str, Any]] = {}
-6
View File
@@ -1131,12 +1131,6 @@ def update_sample_reception(
rec.update_by = user_id
rec.update_time = datetime.now()
check_result_svc.soft_delete_reception_check_results(
db,
reception_id=reception_id,
tenant_no=tenant_no,
user_id=user_id,
)
check_result_svc.save_reception_check_results(
db,
reception_id=reception_id,
@@ -7,8 +7,8 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud import sample_reception_check_item as check_item_svc
from app.crud.child_row_sync import sync_child_rows
from app.models.models import (
BizSampleReception,
BizSampleReceptionCheckItem,
@@ -105,7 +105,7 @@ def soft_delete_reception_check_results(
tenant_no: str,
user_id: Optional[str],
) -> None:
"""软删除某次接收的全部检查项结果(编辑接收时先清空再重写)。"""
"""软删除某次接收的全部检查项结果。删除接收记录时使用。"""
now = datetime.now()
(
db.query(BizSampleReceptionCheckResult)
@@ -143,26 +143,56 @@ def save_reception_check_results(
if unknown:
raise ValueError("存在无效的检查项")
now = datetime.now()
payload = []
for item in active_items:
item_id = str(item["id"])
checked = submitted.get(item_id, False)
row = BizSampleReceptionCheckResult(
id=generate_uuid(),
payload.append(
{
"check_item_id": item_id,
"item_code": item["itemCode"],
"item_name": item["itemName"],
"checked": 1 if submitted.get(item_id, False) else 0,
"order_num": item.get("orderNum") or 0,
}
)
def build_row(row_id):
return BizSampleReceptionCheckResult(
id=row_id,
reception_id=reception_id,
check_item_id=item_id,
item_code=item["itemCode"],
item_name=item["itemName"],
checked=1 if checked else 0,
check_item_id="",
item_code="",
item_name="",
checked=0,
tenant_no=tenant_no,
order_num=item.get("orderNum") or 0,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
def apply_row(row, item, _idx, _now):
row.check_item_id = item["check_item_id"]
row.item_code = item["item_code"]
row.item_name = item["item_name"]
row.checked = item["checked"]
row.order_num = item["order_num"]
sync_child_rows(
db,
BizSampleReceptionCheckResult,
parent_attr="reception_id",
parent_id=reception_id,
tenant_no=tenant_no,
items=payload,
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: item["check_item_id"],
row_natural_key=lambda row: row.check_item_id or "",
duplicate_message="检查项重复",
foreign_message="检查结果不属于当前接收记录",
)
def sync_legacy_columns_from_results(
+30 -7
View File
@@ -7,6 +7,7 @@ from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud import sample_reception as reception_svc
from app.models.models import (
BizCellSampleType,
BizCollectionProtocol,
@@ -21,11 +22,13 @@ from app.services.sample_storage_cert_qc import (
normalize_payload,
resolve_sample_type_key,
)
from app.services.sample_storage_cert_qc_prefill import collect_prefill
def _row_to_dict(
row: BizSampleStorageCertQc,
*,
lab_flow_code: str = "",
protocol_code: str = "",
mother_name: str = "",
sample_type_name: str = "",
@@ -33,7 +36,7 @@ def _row_to_dict(
return {
"id": row.id,
"sampleCode": row.sample_code or "",
"labFlowCode": row.sample_code or "",
"labFlowCode": lab_flow_code or "",
"protocolCode": protocol_code or "",
"motherName": mother_name or "",
"sampleTypeName": sample_type_name or "",
@@ -99,15 +102,30 @@ def _load_sample_context(db: Session, tenant_no: str, sample_code: str):
)
if pc:
mother_name = pc.mother_name or ""
return sample, type_name, protocol_code, mother_name
lab_flow_code = ""
sc = (sample.sample_code or "").strip()
if sc:
code_map = reception_svc.get_lab_storage_code_map_by_sample_codes(db, tenant_no, [sc])
lab_flow_code = code_map.get(sc, "") or ""
return sample, type_name, protocol_code, mother_name, lab_flow_code
def _with_prefill(db: Session, tenant_no: str, detail: Dict[str, Any]) -> Dict[str, Any]:
detail["prefill"] = collect_prefill(
db,
tenant_no,
detail.get("sampleCode") or "",
detail.get("sampleTypeKey"),
)
return detail
def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[str, Any]]:
"""样本不存在返回 None;存在但未填返回 empty_detail。"""
"""样本不存在返回 None;存在但未填返回 empty_detail。prefill 只补空,不写入已存档 payload。"""
ctx = _load_sample_context(db, tenant_no, sample_code)
if not ctx:
return None
sample, type_name, protocol_code, mother_name = ctx
sample, type_name, protocol_code, mother_name, lab_flow_code = ctx
key = resolve_sample_type_key(type_name)
row = (
db.query(BizSampleStorageCertQc)
@@ -119,20 +137,24 @@ def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[s
.first()
)
if not row:
return empty_detail(
detail = empty_detail(
sample_code=sample.sample_code or "",
lab_flow_code=lab_flow_code,
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
sample_type_key=key,
collection_protocol_id=sample.collection_protocol_id or "",
)
return _row_to_dict(
return _with_prefill(db, tenant_no, detail)
detail = _row_to_dict(
row,
lab_flow_code=lab_flow_code,
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
)
return _with_prefill(db, tenant_no, detail)
def upsert(
@@ -147,7 +169,7 @@ def upsert(
ctx = _load_sample_context(db, tenant_no, sample_code)
if not ctx:
raise ValueError("样本不存在")
sample, type_name, protocol_code, mother_name = ctx
sample, type_name, protocol_code, mother_name, lab_flow_code = ctx
key = resolve_sample_type_key(type_name)
if not key:
raise ValueError("该样本类型不支持填写储存证质检信息")
@@ -193,6 +215,7 @@ def upsert(
db.refresh(row)
return _row_to_dict(
row,
lab_flow_code=lab_flow_code,
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
+24
View File
@@ -741,6 +741,30 @@ def _search_slot_select_matched_ids(
return matched_ids
def search_slot_ids_by_keyword(
db: Session,
tenant_no: str,
keyword: str,
*,
max_results: int = SLOT_SELECT_KEYWORD_SCAN_LIMIT,
) -> List[str]:
"""按路径/格位编码关键词解析储物格 ID(供细胞产品列表等跨实体检索复用)。"""
kw = (keyword or "").strip()
if not kw or max_results <= 0:
return []
base = db.query(BizStorageFacilitySlot).filter(
BizStorageFacilitySlot.del_flag == 0,
BizStorageFacilitySlot.tenant_no == tenant_no,
)
return _search_slot_select_matched_ids(
db,
base,
tenant_no,
kw,
max_results=max_results,
)
def _apply_slot_select_query_filters(
db: Session,
query,
+39 -66
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_link_rows
from app.crud.base import CRUDBase
from app.models.models import SysUser, SysUserRole, SysUserPost, SysDept
@@ -179,29 +180,19 @@ def set_user_post_ids_only(
post_ids: List[str],
updater_id: Optional[str],
) -> None:
"""仅替换 sys_user_post,不影响 sys_user_role。"""
now = datetime.now()
uniq = [p for p in dict.fromkeys(post_ids or []) if p]
db.query(SysUserPost).filter(
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
for pid in uniq:
db.add(
SysUserPost(
id=generate_uuid(),
user_id=user_id,
post_id=pid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=updater_id,
create_dept_id="",
create_time=now,
update_by=updater_id,
update_time=now,
del_flag=0,
)
)
"""按职位更新 sys_user_post,不影响 sys_user_role。"""
sync_link_rows(
db,
SysUserPost,
parent_attr="user_id",
parent_id=user_id,
link_attr="post_id",
link_ids=post_ids,
tenant_no=tenant_no,
user_id=updater_id,
duplicate_message="职位重复",
foreign_message="职位关联不属于当前用户",
)
def list_user_ids_bound_to_post(db: Session, post_id: str, tenant_no: str) -> List[str]:
@@ -514,51 +505,33 @@ def _replace_associations(
role_ids: Optional[List[str]] = None,
post_ids: Optional[List[str]] = None,
) -> None:
"""全量替换关联。仅当对应参数不为 None 时才替换该侧(避免只改职位时清空直接角色)。"""
now = datetime.now()
"""按关联 id 更新。仅当对应参数不为 None 时才替换该侧(避免只改职位时清空直接角色)。"""
if role_ids is not None:
db.query(SysUserRole).filter(
and_(SysUserRole.user_id == user_id, SysUserRole.del_flag == 0)
).update({"del_flag": 1, "update_time": now})
for rid in role_ids:
if not rid:
continue
db.add(SysUserRole(
id=generate_uuid(),
user_id=user_id,
role_id=rid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=creator_id,
create_dept_id="",
create_time=now,
update_by=creator_id,
update_time=now,
del_flag=0,
))
sync_link_rows(
db,
SysUserRole,
parent_attr="user_id",
parent_id=user_id,
link_attr="role_id",
link_ids=role_ids,
tenant_no=tenant_no,
user_id=creator_id,
duplicate_message="角色重复",
foreign_message="角色关联不属于当前用户",
)
if post_ids is not None:
db.query(SysUserPost).filter(
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
).update({"del_flag": 1, "update_time": now})
for pid in post_ids:
if not pid:
continue
db.add(SysUserPost(
id=generate_uuid(),
user_id=user_id,
post_id=pid,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=creator_id,
create_dept_id="",
create_time=now,
update_by=creator_id,
update_time=now,
del_flag=0,
))
sync_link_rows(
db,
SysUserPost,
parent_attr="user_id",
parent_id=user_id,
link_attr="post_id",
link_ids=post_ids,
tenant_no=tenant_no,
user_id=creator_id,
duplicate_message="职位重复",
foreign_message="职位关联不属于当前用户",
)
def _parse_hire_date(value: Optional[Union[str, date]]) -> Optional[date]:
+40
View File
@@ -0,0 +1,40 @@
"""历史单据上的人员姓名。
用户管理里删除人员只会把 sys_user.del_flag 置为 1,账号不能再登录,
也不能再被选为新的负责人。已经写在工单、步骤、协议上的 user_id
仍要显示 nick_name;查不到人时调用方再回退成 id。
选人、校验、登录不要用本模块。
"""
from __future__ import annotations
from typing import Dict, Iterable, List
from sqlalchemy.orm import Session
from app.models.models import SysUser
def user_display_name_map(db: Session, user_ids: Iterable[str]) -> Dict[str, str]:
"""按 user_id 取展示名,包含已删除用户。优先昵称,否则账号。"""
ids: List[str] = []
seen = set()
for raw in user_ids or []:
uid = str(raw or "").strip()
if not uid or uid in seen:
continue
seen.add(uid)
ids.append(uid)
if not ids:
return {}
rows = (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(SysUser.id.in_(ids))
.all()
)
out: Dict[str, str] = {}
for row in rows:
name = (row.nick_name or "").strip() or (row.user_no or "").strip()
if name:
out[row.id] = name
return out
+2 -1
View File
@@ -13,7 +13,7 @@ from app.core.database import SessionLocal
from app.core.api_request_log_middleware import ApiRequestLogMiddleware
from app.core.api_crypto_middleware import ApiCryptoMiddleware
from app.models import models # noqa: F401
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, 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
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router, customer_service_dict_router, sample_cert_insurance_router, sample_code_correction_router
@asynccontextmanager
@@ -156,6 +156,7 @@ app.include_router(market_mobile_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_storage_cert_qc_router, prefix=settings.API_V1_PREFIX)
app.include_router(customer_service_dict_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_cert_insurance_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_code_correction_router, prefix=settings.API_V1_PREFIX)
def _mask_secret(value: str) -> str:
+70
View File
@@ -1020,6 +1020,7 @@ class OaInstanceViewGrant(Base):
source = Column(String(32), nullable=False, default="comment_mention", comment="comment_mention")
comment_id = Column(String(64), nullable=True, comment="来源评论ID,撤回评论不删除授权")
granted_by = Column(String(64), nullable=True, comment="授权人")
is_read = Column(SmallInteger, nullable=False, default=0, comment="是否已读 0未读 1已读")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
@@ -1748,6 +1749,28 @@ class BizSampleStorageCertQc(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizSampleCodeCorrectionLog(Base):
"""样本编码纠错审计日志 biz_sample_code_correction_log"""
__tablename__ = "biz_sample_code_correction_log"
id = Column(String(64), primary_key=True, comment="主键ID")
reception_source = Column(String(32), nullable=False, comment="来源 cell_storage/research_donation")
old_sample_code = Column(String(32), nullable=False, comment="旧样本编码")
new_sample_code = Column(String(32), nullable=False, comment="新样本编码")
impact_json = Column(Text, nullable=True, comment="各表影响行数 JSON")
reason = Column(String(512), nullable=True, comment="更正原因")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizSampleCustomerService(Base):
"""样本客户服务扩展 biz_sample_customer_service"""
@@ -2509,6 +2532,25 @@ class BizCellProductSpec(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizCellProductPackage(Base):
"""细胞产品包装 biz_cell_product_package"""
__tablename__ = "biz_cell_product_package"
id = Column(String(64), primary_key=True, comment="主键ID")
package_code = Column(String(64), nullable=False, comment="包装编码")
package_name = Column(String(200), nullable=False, comment="包装名称")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizCellProductPrice(Base):
"""细胞产品价格体系 biz_cell_product_price"""
@@ -4080,6 +4122,8 @@ class BizPilotInfusionSchedule(Base):
infusion_plan_id = Column(String(64), nullable=False, comment="输注方案ID")
cell_type = Column(String(200), nullable=True, comment="细胞类型")
spec_text = Column(String(200), nullable=True, comment="规格")
package_id = Column(String(64), nullable=True, comment="产品包装ID")
package_text = Column(String(200), nullable=True, comment="产品包装名称(冗余展示)")
infusion_time = Column(DateTime, nullable=True, comment="输注时间")
infusion_location = Column(String(200), nullable=True, comment="输注地点")
infusion_method = Column(String(32), nullable=True, comment="输注方式(字典szfs)")
@@ -4112,6 +4156,8 @@ class BizPilotInfusionScheduleProduct(Base):
project_name = Column(String(200), nullable=True, comment="项目名称")
cell_type_name = Column(String(200), nullable=True, comment="细胞类型")
spec_text = Column(String(200), nullable=True, comment="规格")
package_id = Column(String(64), nullable=True, comment="产品包装ID")
package_text = Column(String(200), nullable=True, comment="产品包装名称(冗余展示)")
agreement_price = Column(Numeric(18, 2), nullable=True, comment="协议单价(元)")
actual_price = Column(Numeric(18, 2), nullable=True, comment="实际单价(元)")
quantity = Column(Integer, nullable=True, default=1, comment="份数")
@@ -4127,6 +4173,30 @@ class BizPilotInfusionScheduleProduct(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizWxNotifyLog(Base):
"""微信服务号通知发送日志(幂等) biz_wx_notify_log"""
__tablename__ = "biz_wx_notify_log"
id = Column(String(64), primary_key=True, comment="主键ID")
biz_type = Column(String(64), nullable=False, comment="业务类型,如 basic_experiment_delivery_remind")
biz_id = Column(String(64), nullable=False, comment="业务主键(如交付计划ID)")
notify_date = Column(String(10), nullable=False, comment="提醒日 YYYY-MM-DD")
user_id = Column(String(64), nullable=True, comment="接收用户ID")
status = Column(String(16), nullable=False, default="success", comment="success/fail")
errcode = Column(String(64), nullable=True, comment="微信/内部错误码")
errmsg = Column(String(512), nullable=True, comment="错误信息")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizPilotCheckup(Base):
"""中试申请单-体检信息 biz_pilot_checkup"""
+69 -1
View File
@@ -31,6 +31,43 @@ MarketContractAmount = condecimal(
)
MarketSampleTypeId = constr(min_length=1, max_length=64)
_CUSTOMER_INT_LABELS = {
'birth_order': '胎次',
'newborn_weight_gram': '新生儿体重',
}
def _blank_to_none(value):
"""移动端数字框清空后会提交 "",可选数字应视为未填。"""
if isinstance(value, str):
text = value.strip()
return None if text == '' else text
return value
def _optional_customer_int(value, field_name: str):
if value is None or isinstance(value, bool):
return value
if isinstance(value, int):
return value
label = _CUSTOMER_INT_LABELS[field_name]
if isinstance(value, float):
if value.is_integer():
return int(value)
raise ValueError(f'{label}须为整数')
if isinstance(value, str):
text = value.strip().replace(',', '')
if text == '':
return None
if re.fullmatch(r'[+-]?\d+\.?', text):
return int(text.rstrip('.'))
if re.fullmatch(r'[+-]?\d+\.\d+', text):
number = Decimal(text)
if number == number.to_integral_value():
return int(number)
raise ValueError(f'{label}须为整数')
return value
class MarketAttachmentDownloadBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
@@ -193,6 +230,16 @@ class MarketProtocolCustomerBody(BaseModel):
collector_user_id: Optional[str] = Field(None, alias='collectorUserId')
remark: Optional[str] = None
@field_validator('birth_order', 'newborn_weight_gram', mode='before')
@classmethod
def normalize_optional_int(cls, value, info):
return _optional_customer_int(value, info.field_name)
@field_validator('apgar_1min', 'apgar_5min', mode='before')
@classmethod
def normalize_optional_float(cls, value):
return _blank_to_none(value)
class MarketProtocolCollectionBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
@@ -390,11 +437,17 @@ class MarketProtocolPaymentDetailBody(BaseModel):
class MarketProtocolBody(BaseModel):
"""移动协议白名单;状态、审核、财务、退款及转换字段一律拒绝。"""
"""移动协议白名单。协议/捐赠状态可改;审核、财务、退款及转换时间字段一律拒绝。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
source: Literal['cell_storage', 'research_donation']
protocol_status: Optional[
Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zky']
] = Field(None, alias='protocolStatus')
donation_status: Optional[
Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zckc']
] = Field(None, alias='donationStatus')
protocol_no: Optional[str] = Field(None, alias='protocolNo')
donation_no: Optional[str] = Field(None, alias='donationNo')
register_date: Optional[str] = Field(None, alias='registerDate')
@@ -416,6 +469,21 @@ class MarketProtocolBody(BaseModel):
None, alias='paymentDetails'
)
@field_validator('register_date', 'donation_date')
@classmethod
def validate_protocol_ymd(cls, value: Optional[str]) -> Optional[str]:
"""接受 YYYYMMDD / YYYY-MM-DD,统一规范为 CHAR(8) YYYYMMDD。"""
if value in (None, ''):
return value
text = str(value).strip().replace('-', '').replace('/', '')
if len(text) != 8 or not text.isdigit():
raise ValueError('日期必须使用 YYYYMMDD 格式')
try:
datetime.strptime(text, '%Y%m%d')
except ValueError as exc:
raise ValueError('日期必须是有效的 YYYYMMDD') from exc
return text
class MarketProtocolAuditBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
+4
View File
@@ -63,6 +63,7 @@ class StepReorderBody(BaseModel):
# Step sub-table items
class StepReagentItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
reagent_code: Optional[str] = Field(None, alias="reagentCode")
reagent_name: Optional[str] = Field(None, alias="reagentName")
spec: Optional[str] = None
@@ -75,6 +76,7 @@ class StepReagentItem(BaseModel):
class StepConsumableItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
consumable_code: Optional[str] = Field(None, alias="consumableCode")
consumable_name: Optional[str] = Field(None, alias="consumableName")
spec_model: Optional[str] = Field(None, alias="specModel")
@@ -87,6 +89,7 @@ class StepConsumableItem(BaseModel):
class StepOutputItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
output_type_id: Optional[str] = Field(None, alias="outputTypeId")
output_name: Optional[str] = Field(None, alias="outputName")
output_barcode: Optional[str] = Field(None, alias="outputBarcode")
@@ -96,6 +99,7 @@ class StepOutputItem(BaseModel):
class StepDataCollectionItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
collection_name: Optional[str] = Field(None, alias="collectionName")
unit: Optional[str] = None
order_num: Optional[int] = Field(0, alias="orderNum")
+4
View File
@@ -88,6 +88,7 @@ class TaskStepUpdate(BaseModel):
class TaskStepMaterialItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
material_code: Optional[str] = None
material_name: Optional[str] = None
spec: Optional[str] = None
@@ -102,6 +103,7 @@ class TaskStepMaterialItem(BaseModel):
class TaskStepOutputItem(BaseModel):
id: Optional[str] = None
output_type_id: Optional[str] = None
output_name: Optional[str] = None
output_barcode: Optional[str] = None
@@ -109,6 +111,7 @@ class TaskStepOutputItem(BaseModel):
class TaskStepDataCollectionItem(BaseModel):
id: Optional[str] = None
collection_name: Optional[str] = None
unit: Optional[str] = None
collection_value: Optional[str] = None
@@ -117,6 +120,7 @@ class TaskStepDataCollectionItem(BaseModel):
class TaskInputItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
input_type: str = Field(..., alias="inputType")
input_id: Optional[str] = Field(None, alias="inputId")
input_code: Optional[str] = Field(None, alias="inputCode")
@@ -0,0 +1,260 @@
"""基础实验交付日前一天:服务号模板提醒。"""
from __future__ import annotations
import logging
import uuid
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.models import (
BizPilotApplication,
BizPilotInfusionPlan,
BizPilotInfusionSchedule,
BizPilotInfusionScheduleProduct,
BizWxNotifyLog,
SysUser,
)
logger = logging.getLogger(__name__)
NOTIFY_BIZ_TYPE = "basic_experiment_delivery_remind"
CANCELLED_SCHEDULE_STATUS = "yqx"
def format_delivery_product_name(
*,
project_name: str = "",
spec_text: str = "",
package_text: str = "",
) -> str:
"""产品名称拼接:名称(规格、包装);空段省略。"""
name = (project_name or "").strip()
spec = (spec_text or "").strip()
package = (package_text or "").strip()
parts = [p for p in (spec, package) if p]
if not name and not parts:
return ""
if not parts:
return name
suffix = "、".join(parts)
if name:
return f"{name}({suffix})"
return f"({suffix})"
def _notify_enabled() -> bool:
if not bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False)):
return False
tid = (getattr(settings, "WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY", "") or "").strip()
return bool(tid)
def _already_success(db: Session, *, tenant_no: str, schedule_id: str, notify_date: str) -> bool:
row = (
db.query(BizWxNotifyLog)
.filter(
BizWxNotifyLog.tenant_no == tenant_no,
BizWxNotifyLog.biz_type == NOTIFY_BIZ_TYPE,
BizWxNotifyLog.biz_id == schedule_id,
BizWxNotifyLog.notify_date == notify_date,
BizWxNotifyLog.status == "success",
BizWxNotifyLog.del_flag == 0,
)
.first()
)
return row is not None
def _write_log(
db: Session,
*,
tenant_no: str,
schedule_id: str,
notify_date: str,
user_id: str,
status: str,
errcode: str = "",
errmsg: str = "",
) -> None:
now = datetime.now()
db.add(
BizWxNotifyLog(
id=str(uuid.uuid4()).replace("-", "").upper(),
biz_type=NOTIFY_BIZ_TYPE,
biz_id=schedule_id,
notify_date=notify_date,
user_id=user_id or "",
status=status,
errcode=(errcode or "")[:64] or None,
errmsg=(errmsg or "")[:512] or None,
tenant_no=tenant_no,
order_num=0,
remark="",
create_by=user_id or "",
create_dept_id="",
create_time=now,
update_by=user_id or "",
update_time=now,
del_flag=0,
)
)
def _iter_candidate_schedules(db: Session, *, delivery_day: date) -> List[Any]:
"""返回 (schedule, application, product) 元组列表。"""
day_start = datetime.combine(delivery_day, datetime.min.time())
day_end = datetime.combine(delivery_day, datetime.max.time()).replace(microsecond=0)
rows = (
db.query(BizPilotInfusionSchedule, BizPilotApplication, BizPilotInfusionScheduleProduct)
.join(
BizPilotInfusionPlan,
BizPilotInfusionPlan.id == BizPilotInfusionSchedule.infusion_plan_id,
)
.join(
BizPilotApplication,
BizPilotApplication.id == BizPilotInfusionPlan.pilot_application_id,
)
.outerjoin(
BizPilotInfusionScheduleProduct,
(
BizPilotInfusionScheduleProduct.infusion_schedule_id
== BizPilotInfusionSchedule.id
)
& (BizPilotInfusionScheduleProduct.del_flag == 0),
)
.filter(
BizPilotApplication.biz_type == "basic_experiment",
BizPilotApplication.del_flag == 0,
BizPilotInfusionPlan.del_flag == 0,
BizPilotInfusionSchedule.del_flag == 0,
BizPilotInfusionSchedule.infusion_time >= day_start,
BizPilotInfusionSchedule.infusion_time <= day_end,
BizPilotInfusionSchedule.schedule_status != CANCELLED_SCHEDULE_STATUS,
)
.order_by(BizPilotInfusionSchedule.infusion_time.asc())
.all()
)
# 一计划多产品时取首条产品行(基础实验通常一行)
seen: set = set()
out = []
for sched, app, prod in rows:
if sched.id in seen:
continue
seen.add(sched.id)
out.append((sched, app, prod))
return out
def run_delivery_remind_for_date(
db: Session,
*,
today: Optional[date] = None,
) -> Dict[str, int]:
"""扫描「明天」交付计划并发送提醒。返回计数统计。"""
stats = {"candidates": 0, "sent": 0, "skipped": 0, "failed": 0}
if not _notify_enabled():
logger.info("basic experiment delivery remind skipped: notify disabled or no template")
return stats
base = today or date.today()
delivery_day = base + timedelta(days=1)
notify_date = base.strftime("%Y-%m-%d")
from app.services.wechat_mp_message import send_basic_experiment_delivery_remind
candidates = _iter_candidate_schedules(db, delivery_day=delivery_day)
stats["candidates"] = len(candidates)
for sched, app, prod in candidates:
tenant_no = (app.tenant_no or sched.tenant_no or "").strip() or "zkxw"
schedule_id = sched.id
if _already_success(db, tenant_no=tenant_no, schedule_id=schedule_id, notify_date=notify_date):
stats["skipped"] += 1
continue
creator_id = (app.create_by or "").strip()
if not creator_id:
logger.info(
"delivery remind skip: no create_by schedule=%s app=%s",
schedule_id,
app.id,
)
stats["skipped"] += 1
continue
user = (
db.query(SysUser)
.filter(SysUser.id == creator_id, SysUser.del_flag == 0)
.first()
)
mp = (getattr(user, "wx_mp_openid", None) or "").strip() if user else ""
if not mp:
logger.info(
"delivery remind skip: no mp_openid user=%s schedule=%s",
creator_id,
schedule_id,
)
stats["skipped"] += 1
continue
product_name = format_delivery_product_name(
project_name=(prod.project_name if prod else "") or "",
spec_text=(prod.spec_text if prod else "") or (sched.spec_text or ""),
package_text=(prod.package_text if prod else "") or (sched.package_text or ""),
)
qty = 1
if prod is not None and prod.quantity is not None:
qty = int(prod.quantity)
time_text = ""
if sched.infusion_time:
time_text = sched.infusion_time.strftime("%Y-%m-%d %H:%M")
result = send_basic_experiment_delivery_remind(
mp_openid=mp,
customer_name=app.customer_name or "",
product_name=product_name,
quantity=str(qty),
contact_name=app.customer_contact or "",
time_text=time_text,
)
if result.get("success"):
_write_log(
db,
tenant_no=tenant_no,
schedule_id=schedule_id,
notify_date=notify_date,
user_id=creator_id,
status="success",
)
stats["sent"] += 1
else:
logger.error(
"delivery remind send failed schedule=%s user=%s err=%s",
schedule_id,
creator_id,
result,
)
stats["failed"] += 1
try:
db.commit()
except Exception:
db.rollback()
logger.exception("delivery remind commit failed")
raise
return stats
def run_delivery_remind_job(get_db_session) -> None:
"""APScheduler 入口。"""
db = get_db_session()
try:
stats = run_delivery_remind_for_date(db)
logger.info("basic experiment delivery remind done: %s", stats)
except Exception:
logger.exception("basic experiment delivery remind job failed")
finally:
db.close()
+57 -6
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.core.utils import parse_datetime
from app.core.protocol_status_display import (
assert_protocol_status_transition,
build_void_type_label_map,
resolve_void_type_label,
)
@@ -46,6 +47,13 @@ from app.models.research_donation import (
BizResearchDonationSampleTransport,
)
from app.schemas.market_mobile import MarketProtocolBody
from app.services.sample_collect_wx_notify import (
PROTOCOL_KIND_CELL,
PROTOCOL_KIND_DONATION,
sample_codes_from_orm_rows,
sample_codes_from_payload,
schedule_sample_collect_notify,
)
from app.services.market_field_scope import (
MarketFieldContext,
apply_cell_protocol_scope,
@@ -1346,6 +1354,7 @@ def _persist_sample_payments_if_any(
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
sample_payments=raw,
commit=False,
)
@@ -1416,7 +1425,6 @@ def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dic
.filter(
SysUser.id == hid,
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0,
)
.first()
)
@@ -1437,7 +1445,6 @@ def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dic
.filter(
SysUser.id == hid2,
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0,
)
.first()
)
@@ -1862,8 +1869,8 @@ def create_protocol(
row = cell_crud.create_collection_protocol(
db,
protocol_no=body.protocol_no or '',
# 对齐 Web 新增弹窗:默认生效中,移动端不接收客户端改状态
protocol_status='sxz',
# 对齐 Web 新增弹窗:未传时默认生效中
protocol_status=(body.protocol_status or '').strip() or 'sxz',
protocol_attachments=attachments.get('protocol_attachments'),
consent_form_urls=attachments.get('consent_form_urls'),
health_survey_form_urls=attachments.get(
@@ -1930,14 +1937,21 @@ def create_protocol(
assert_cell_protocol_access(db, row.id, ctx, user_info=user_info)
db.commit()
db.refresh(row)
schedule_sample_collect_notify(
tenant_no=ctx.tenant_no,
protocol_id=row.id,
protocol_kind=PROTOCOL_KIND_CELL,
before_codes=[],
submitted_codes=sample_codes_from_payload(samples),
)
return _cell_detail(db, row, ctx.tenant_no)
if body.sample_payments:
raise ValueError('科研捐赠不支持样本缴费方案')
data = {
'donation_no': body.donation_no or '',
# 对齐 Web 新增弹窗:默认生效中,移动端不接收客户端改状态
'donation_status': 'sxz',
# 对齐 Web 新增弹窗:未传时默认生效中
'donation_status': (body.donation_status or '').strip() or 'sxz',
'donation_date': body.donation_date,
'collector_user_id': ctx.user_id
if ctx.scope_kind == 'specialist'
@@ -1977,6 +1991,13 @@ def create_protocol(
assert_donation_access(db, row.id, ctx, user_info=user_info)
db.commit()
db.refresh(row)
schedule_sample_collect_notify(
tenant_no=ctx.tenant_no,
protocol_id=row.id,
protocol_kind=PROTOCOL_KIND_DONATION,
before_codes=[],
submitted_codes=sample_codes_from_payload(samples),
)
return _donation_detail(db, row, ctx.tenant_no)
except Exception:
db.rollback()
@@ -2051,6 +2072,7 @@ def update_protocol(
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
protocol_no=body.protocol_no,
protocol_status=(body.protocol_status or '').strip() or None,
protocol_attachments=attachments.get('protocol_attachments'),
consent_form_urls=attachments.get('consent_form_urls'),
health_survey_form_urls=attachments.get(
@@ -2133,6 +2155,17 @@ def update_protocol(
)
db.commit()
db.refresh(row)
schedule_sample_collect_notify(
tenant_no=ctx.tenant_no,
protocol_id=row.id,
protocol_kind=PROTOCOL_KIND_CELL,
before_codes=sample_codes_from_orm_rows(current_samples),
submitted_codes=(
sample_codes_from_payload(samples)
if body.samples is not None
else None
),
)
return _cell_detail(db, row, ctx.tenant_no)
row = _lock_donation_access(
@@ -2181,6 +2214,11 @@ def update_protocol(
),
**collection,
}
donation_status = (body.donation_status or '').strip()
if donation_status:
assert_protocol_status_transition(
row.donation_status or '', donation_status, kind='donation'
)
data = {
'donation_no': body.donation_no,
'donation_date': body.donation_date,
@@ -2194,6 +2232,8 @@ def update_protocol(
'infectious_disease_report_urls'
),
}
if donation_status:
data['donation_status'] = donation_status
if body.customer is not None:
data['party'] = customer
if body.collection is not None:
@@ -2214,6 +2254,17 @@ def update_protocol(
row = assert_donation_access(db, row.id, ctx, user_info=user_info)
db.commit()
db.refresh(row)
schedule_sample_collect_notify(
tenant_no=ctx.tenant_no,
protocol_id=row.id,
protocol_kind=PROTOCOL_KIND_DONATION,
before_codes=sample_codes_from_orm_rows(current_samples),
submitted_codes=(
sample_codes_from_payload(samples)
if body.samples is not None
else None
),
)
return _donation_detail(db, row, ctx.tenant_no)
except Exception:
db.rollback()
+19 -4
View File
@@ -2237,7 +2237,7 @@ def _apply_instance_list_filters(
if not joined_initiator:
q = q.outerjoin(
SysUser,
(SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
SysUser.id == OaInstance.initiator_user_id,
)
joined_initiator = True
like = f"%{combined}%"
@@ -2265,7 +2265,7 @@ def _apply_instance_list_filters(
if not joined_initiator:
q = q.outerjoin(
SysUser,
(SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
SysUser.id == OaInstance.initiator_user_id,
)
joined_initiator = True
q = q.filter(SysUser.nick_name.like(f"%{iname}%"))
@@ -2922,7 +2922,10 @@ def list_tasks(
}
if kind == "mention":
from app.services.oa_instance_comment import list_mentioned_instance_ids
from app.services.oa_instance_comment import (
list_mentioned_instance_ids,
mention_read_map,
)
ids = list_mentioned_instance_ids(db, tenant_no=tenant_no, user_id=user_id)
if not ids:
@@ -2947,8 +2950,20 @@ def list_tasks(
total = len(insts)
insts = insts[offset : offset + page_size]
rows = [_instance_to_dict(i) for i in insts]
enriched = _enrich_list_rows(
db, tenant_no=tenant_no, rows=rows, instances=insts
)
read_map = mention_read_map(
db,
tenant_no=tenant_no,
user_id=user_id,
instance_ids=[str(i.id) for i in insts],
)
for row in enriched:
rid = str(row.get("id") or row.get("instanceId") or "").strip()
row["isRead"] = bool(read_map.get(rid, True))
return {
"list": _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts),
"list": enriched,
"total": int(total),
}
+97 -4
View File
@@ -331,8 +331,8 @@ def _ensure_view_grants(
) -> None:
if not user_ids:
return
existing = (
db.query(OaInstanceViewGrant.user_id)
existing_rows = (
db.query(OaInstanceViewGrant)
.filter(
OaInstanceViewGrant.instance_id == instance_id,
OaInstanceViewGrant.tenant_no == tenant_no,
@@ -341,10 +341,17 @@ def _ensure_view_grants(
)
.all()
)
have = {str(r[0]) for r in existing}
by_uid = {str(r.user_id): r for r in existing_rows}
now = datetime.now()
for uid in user_ids:
if uid in have:
row = by_uid.get(uid)
if row is not None:
# 再 @:重置未读
row.is_read = 0
row.comment_id = comment_id
row.granted_by = actor_user_id
row.update_by = actor_user_id
row.update_time = now
continue
db.add(
OaInstanceViewGrant(
@@ -354,6 +361,7 @@ def _ensure_view_grants(
source="comment_mention",
comment_id=comment_id,
granted_by=actor_user_id,
is_read=0,
tenant_no=tenant_no,
create_by=actor_user_id,
create_dept_id=dept_id or "",
@@ -404,6 +412,83 @@ def list_mentioned_instance_ids(
return [iid for iid in inst_ids if iid in allowed]
def mention_read_map(
db: Session, *, tenant_no: str, user_id: str, instance_ids: List[str]
) -> Dict[str, bool]:
"""instance_id → isRead(无 grant 视为已读)。"""
ids = [str(x).strip() for x in (instance_ids or []) if str(x).strip()]
uid = (user_id or "").strip()
if not ids or not uid:
return {}
rows = (
db.query(OaInstanceViewGrant)
.filter(
OaInstanceViewGrant.tenant_no == tenant_no,
OaInstanceViewGrant.user_id == uid,
OaInstanceViewGrant.del_flag == 0,
OaInstanceViewGrant.instance_id.in_(ids),
)
.all()
)
out: Dict[str, bool] = {iid: True for iid in ids}
for g in rows:
iid = (g.instance_id or "").strip()
if iid:
out[iid] = bool(int(getattr(g, "is_read", 0) or 0) == 1)
return out
def count_mention_unread(db: Session, *, tenant_no: str, user_id: str) -> int:
uid = (user_id or "").strip()
if not uid:
return 0
q = (
db.query(OaInstanceViewGrant)
.join(
OaInstance,
OaInstance.id == OaInstanceViewGrant.instance_id,
)
.filter(
OaInstanceViewGrant.tenant_no == tenant_no,
OaInstanceViewGrant.user_id == uid,
OaInstanceViewGrant.del_flag == 0,
OaInstanceViewGrant.is_read == 0,
OaInstance.tenant_no == tenant_no,
OaInstance.del_flag == 0,
)
)
return int(q.count())
def mark_mention_instance_read(
db: Session, *, tenant_no: str, user_id: str, instance_id: str
) -> Dict[str, Any]:
uid = (user_id or "").strip()
iid = (instance_id or "").strip()
if not uid or not iid:
raise OaEngineError("参数无效")
row = (
db.query(OaInstanceViewGrant)
.filter(
OaInstanceViewGrant.tenant_no == tenant_no,
OaInstanceViewGrant.user_id == uid,
OaInstanceViewGrant.instance_id == iid,
OaInstanceViewGrant.del_flag == 0,
)
.first()
)
if not row:
raise OaEngineError("提到记录不存在或无权操作")
now = datetime.now()
if int(getattr(row, "is_read", 0) or 0) != 1:
row.is_read = 1
row.update_by = uid
row.update_time = now
db.commit()
db.refresh(row)
return {"instanceId": iid, "isRead": True}
def create_comment(
db: Session,
*,
@@ -461,6 +546,14 @@ def create_comment(
)
db.commit()
db.refresh(row)
if mentions:
from app.services.oa_wechat_notify import schedule_oa_mention_notifies
schedule_oa_mention_notifies(
instance_id=instance_id,
commenter_user_id=user_id,
mentioned_user_ids=mentions,
)
name_map = _user_name_map(db, [user_id, *mentions])
return _comment_to_dict(
row,
+130 -5
View File
@@ -29,6 +29,7 @@ RESULT_OTHER = "其他"
RETURN_PENDING = "/pages/oa/pending/index"
RETURN_CC = "/pages/oa/cc/index"
RETURN_MINE = "/pages/oa/mine/index"
RETURN_MENTIONS = "/pages/oa/mentions/index"
_ACTION_RESULT = {
"approve": RESULT_PASS,
@@ -72,6 +73,14 @@ def _build_matter_title(*, applicant: str, process_name: str, fallback: str) ->
return (fallback or "").strip() or "审批"
def _build_mention_title(commenter_name: str) -> str:
"""抄送模板 thing5:评论人在评论中提到了你。"""
name = (commenter_name or "").strip()
if name:
return f"{name} 在评论中提到了你"
return "有人在评论中提到了你"
def _resolve_process_and_applicant(db, inst: Optional[OaInstance]):
applicant = ""
process_name = ""
@@ -197,11 +206,7 @@ def _send_applicant_progress(
actor = None
aid = (actor_user_id or "").strip()
if aid:
actor = (
db.query(SysUser)
.filter(SysUser.id == aid, SysUser.del_flag == 0)
.first()
)
actor = db.query(SysUser).filter(SysUser.id == aid).first()
approver_name = (actor.nick_name if actor else "") or ""
pagepath = build_oa_detail_pagepath(
instance_id=iid,
@@ -224,6 +229,126 @@ def _oa_wx_notify_enabled() -> bool:
return bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False))
def _send_mention_one(
db,
*,
instance_id: str,
mentioned_user_id: str,
commenter_user_id: str,
) -> None:
"""评论 @:复用抄送模板;thing2=评论人,thing5=「xxx 在评论中提到了你」。"""
uid = (mentioned_user_id or "").strip()
if not uid:
return
user = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
mp = (getattr(user, "wx_mp_openid", None) or "").strip() if user else ""
if not mp:
logger.info(
"oa wx mention skip: no mp_openid user=%s inst=%s", uid, instance_id
)
return
iid = (instance_id or "").strip()
inst = (
db.query(OaInstance)
.filter(OaInstance.id == iid, OaInstance.del_flag == 0)
.first()
if iid
else None
)
process_name = ""
if inst and inst.app_id:
app = (
db.query(OaApp)
.filter(OaApp.id == inst.app_id, OaApp.del_flag == 0)
.first()
)
process_name = (app.app_name if app else "") or ""
if not process_name and inst:
process_name = (inst.title or "").strip()
commenter_name = ""
cid = (commenter_user_id or "").strip()
if cid:
commenter = db.query(SysUser).filter(SysUser.id == cid).first()
commenter_name = (commenter.nick_name if commenter else "") or ""
pagepath = build_oa_detail_pagepath(
instance_id=iid,
task_id="",
mode="view",
return_path=RETURN_MENTIONS,
)
send_oa_cc_notify(
mp_openid=mp,
pagepath=pagepath,
title=_build_mention_title(commenter_name),
applicant=commenter_name or "未知",
node_name="",
time_text=datetime.now().strftime("%Y-%m-%d %H:%M"),
process_name=process_name or "审批",
)
def notify_oa_mentions_sync(
*,
instance_id: str,
commenter_user_id: str,
mentioned_user_ids: Iterable[str],
) -> None:
if not _oa_wx_notify_enabled():
logger.info("oa wx mention skipped: WX_MP_NOTIFY_ENABLED=false")
return
iid = (instance_id or "").strip()
ids = [str(x).strip() for x in (mentioned_user_ids or []) if str(x).strip()]
if not iid or not ids:
return
db = None
try:
db = SessionLocal()
for uid in ids:
try:
_send_mention_one(
db,
instance_id=iid,
mentioned_user_id=uid,
commenter_user_id=commenter_user_id,
)
except Exception:
logger.exception(
"oa wx mention failed user=%s inst=%s", uid, iid
)
except Exception:
logger.exception("oa wx mention batch failed inst=%s", iid)
finally:
if db is not None:
db.close()
def schedule_oa_mention_notifies(
*,
instance_id: str,
commenter_user_id: str,
mentioned_user_ids: List[str],
) -> None:
if not _oa_wx_notify_enabled():
logger.info("oa wx mention schedule skipped: WX_MP_NOTIFY_ENABLED=false")
return
iid = (instance_id or "").strip()
ids = [str(x).strip() for x in (mentioned_user_ids or []) if str(x).strip()]
if not iid or not ids:
return
threading.Thread(
target=notify_oa_mentions_sync,
kwargs={
"instance_id": iid,
"commenter_user_id": (commenter_user_id or "").strip(),
"mentioned_user_ids": ids,
},
daemon=True,
name="oa-wx-mention",
).start()
def notify_oa_tasks_sync(task_ids: Iterable[str]) -> None:
if not _oa_wx_notify_enabled():
logger.info("oa wx notify skipped: WX_MP_NOTIFY_ENABLED=false")
@@ -0,0 +1,676 @@
"""样本编码全链路纠错:预览影响面 + 事务级联更新。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import (
BizCellProduct,
BizProductionTaskInput,
BizProductionWorkOrder,
BizProtocolPaymentSchedule,
BizProtocolPaymentTransfer,
BizProtocolSample,
BizProtocolSamplePayment,
BizProtocolSampleTransport,
BizQcTask,
BizQcWorkOrder,
BizSampleCode,
BizSampleCodeCorrectionLog,
BizSampleCustomerService,
BizSampleFreightItem,
BizSampleInsurance,
BizSampleProductionReception,
BizSampleReception,
BizSampleStorageCert,
BizSampleStorageCertQc,
BizStorageReleaseReview,
)
from app.models.research_donation import (
BizResearchDonationSample,
BizResearchDonationSampleTransport,
)
SOURCE_CELL = "cell_storage"
SOURCE_DONATION = "research_donation"
VALID_SOURCES = {SOURCE_CELL, SOURCE_DONATION}
# (key, label, model, code_attr, has_reception_source, cell_only)
_TABLE_SPECS: List[Tuple[str, str, Any, str, bool, bool]] = [
("biz_protocol_sample_payment", "按样本缴费", BizProtocolSamplePayment, "sample_code", False, True),
("biz_protocol_payment_schedule", "缴费期次", BizProtocolPaymentSchedule, "sample_code", False, True),
("biz_protocol_payment_transfer", "缴费转账", BizProtocolPaymentTransfer, "sample_code", False, True),
("biz_protocol_sample_transport", "协议样本运输", BizProtocolSampleTransport, "sample_code", False, True),
(
"biz_research_donation_sample_transport",
"捐赠样本运输",
BizResearchDonationSampleTransport,
"sample_code",
False,
False,
),
("biz_sample_freight_item", "运费明细", BizSampleFreightItem, "sample_code", False, False),
("biz_sample_reception", "样本接收", BizSampleReception, "sample_code", True, False),
("biz_qc_work_order", "质检工单", BizQcWorkOrder, "sample_code", True, False),
("biz_qc_task", "质检任务", BizQcTask, "sample_code", False, False),
("biz_storage_release_review", "入库放行", BizStorageReleaseReview, "sample_code", True, False),
("biz_sample_storage_cert_qc", "储存证质检", BizSampleStorageCertQc, "sample_code", False, True),
(
"biz_sample_production_reception",
"生产部门接收",
BizSampleProductionReception,
"sample_code",
True,
False,
),
("biz_production_work_order", "生产工单", BizProductionWorkOrder, "sample_code", True, False),
("biz_sample_storage_cert", "储存证", BizSampleStorageCert, "sample_code", False, True),
("biz_sample_insurance", "样本保险", BizSampleInsurance, "sample_code", False, True),
("biz_sample_customer_service", "客户服务扩展", BizSampleCustomerService, "sample_code", False, True),
]
_UNIQUE_TABLES = {
"biz_sample_storage_cert",
"biz_sample_insurance",
"biz_sample_customer_service",
"biz_sample_storage_cert_qc",
}
class SampleCodeCorrectionError(ValueError):
"""业务校验失败(可直接返回给前端)。"""
def _norm(code: Optional[str]) -> str:
return (code or "").strip()
def _now() -> datetime:
return datetime.now()
def _load_source_sample(
db: Session,
*,
tenant_no: str,
reception_source: str,
sample_code: str,
):
code = _norm(sample_code)
if reception_source == SOURCE_CELL:
return (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code == code,
)
.first()
)
return (
db.query(BizResearchDonationSample)
.filter(
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
BizResearchDonationSample.sample_code == code,
)
.first()
)
def _code_used_by_other_sample(
db: Session,
*,
tenant_no: str,
sample_code: str,
exclude_sample_id: Optional[str],
) -> bool:
code = _norm(sample_code)
q1 = db.query(BizProtocolSample.id).filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code == code,
)
q2 = db.query(BizResearchDonationSample.id).filter(
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
BizResearchDonationSample.sample_code == code,
)
if exclude_sample_id:
q1 = q1.filter(BizProtocolSample.id != exclude_sample_id)
q2 = q2.filter(BizResearchDonationSample.id != exclude_sample_id)
return q1.first() is not None or q2.first() is not None
def _count_by_code(
db: Session,
model,
*,
tenant_no: str,
code: str,
code_attr: str = "sample_code",
reception_source: Optional[str] = None,
has_reception_source: bool = False,
) -> int:
col = getattr(model, code_attr)
q = db.query(model).filter(
model.tenant_no == tenant_no,
model.del_flag == 0,
col == code,
)
if has_reception_source and reception_source:
q = q.filter(model.reception_source == reception_source)
return int(q.count())
def _update_by_code(
db: Session,
model,
*,
tenant_no: str,
old_code: str,
new_code: str,
code_attr: str = "sample_code",
reception_source: Optional[str] = None,
has_reception_source: bool = False,
user_id: Optional[str] = None,
) -> int:
col = getattr(model, code_attr)
q = db.query(model).filter(
model.tenant_no == tenant_no,
model.del_flag == 0,
col == old_code,
)
if has_reception_source and reception_source:
q = q.filter(model.reception_source == reception_source)
rows = q.all()
now = _now()
for row in rows:
setattr(row, code_attr, new_code)
if hasattr(row, "update_by"):
row.update_by = user_id
if hasattr(row, "update_time"):
row.update_time = now
return len(rows)
def _count_task_inputs(db: Session, tenant_no: str, code: str) -> int:
return int(
db.query(BizProductionTaskInput)
.filter(
BizProductionTaskInput.tenant_no == tenant_no,
BizProductionTaskInput.del_flag == 0,
BizProductionTaskInput.input_type == "sample",
BizProductionTaskInput.input_code == code,
)
.count()
)
def _update_task_inputs(
db: Session,
*,
tenant_no: str,
old_code: str,
new_code: str,
user_id: Optional[str],
) -> int:
rows = (
db.query(BizProductionTaskInput)
.filter(
BizProductionTaskInput.tenant_no == tenant_no,
BizProductionTaskInput.del_flag == 0,
BizProductionTaskInput.input_type == "sample",
BizProductionTaskInput.input_code == old_code,
)
.all()
)
now = _now()
for row in rows:
row.input_code = new_code
row.update_by = user_id
row.update_time = now
return len(rows)
def _count_same_product_code(db: Session, tenant_no: str, code: str) -> int:
return int(
db.query(BizCellProduct)
.filter(
BizCellProduct.tenant_no == tenant_no,
BizCellProduct.del_flag == 0,
BizCellProduct.product_code == code,
)
.count()
)
def _unique_conflict(
db: Session,
model,
*,
tenant_no: str,
new_code: str,
) -> bool:
return (
db.query(model.id)
.filter(
model.tenant_no == tenant_no,
model.del_flag == 0,
model.sample_code == new_code,
)
.first()
is not None
)
def _spec_applies(reception_source: str, cell_only: bool, table_key: str) -> bool:
if reception_source == SOURCE_CELL:
if table_key == "biz_research_donation_sample_transport":
return False
return True
# donation
if cell_only:
return False
if table_key.startswith("biz_protocol_"):
return False
return True
def preview_correction(
db: Session,
*,
tenant_no: str,
reception_source: str,
old_sample_code: str,
new_sample_code: str,
new_sample_code_id: Optional[str] = None,
) -> Dict[str, Any]:
source = _norm(reception_source) or SOURCE_CELL
old_code = _norm(old_sample_code)
new_code = _norm(new_sample_code)
blockers: List[str] = []
if source not in VALID_SOURCES:
blockers.append("receptionSource 必须是 cell_storage 或 research_donation")
if not old_code:
blockers.append("旧样本编码不能为空")
if not new_code:
blockers.append("新样本编码不能为空")
if old_code and new_code and old_code == new_code:
blockers.append("新样本编码不能与旧编码相同")
sample = None
if not blockers:
sample = _load_source_sample(
db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
)
if sample is None:
blockers.append(f"未找到来源「{source}」下样本编码「{old_code}」")
elif _code_used_by_other_sample(
db,
tenant_no=tenant_no,
sample_code=new_code,
exclude_sample_id=sample.id,
):
blockers.append(f"样本编码「{new_code}」已被其它协议/捐赠样本占用")
impacts: List[Dict[str, Any]] = []
if sample is not None and not blockers:
sot_key = (
"biz_protocol_sample"
if source == SOURCE_CELL
else "biz_research_donation_sample"
)
impacts.append(
{
"table": sot_key,
"label": "样本赋值源表",
"count": 1,
}
)
pool_count = 0
if source == SOURCE_CELL:
pool_id = _norm(new_sample_code_id) or _norm(getattr(sample, "sample_code_id", None))
if pool_id:
pool_row = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.id == pool_id,
)
.first()
)
if pool_row and _norm(pool_row.sample_code) == old_code:
pool_count = 1
elif not pool_row and _norm(new_sample_code_id):
blockers.append(f"指定的新编码池 ID「{new_sample_code_id}」不存在")
existing_new_pool = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.sample_code == new_code,
)
.first()
)
if existing_new_pool and pool_count == 0:
impacts.append(
{
"table": "biz_sample_code",
"label": "编码池(切换占用)",
"count": 1,
}
)
elif pool_count:
impacts.append(
{
"table": "biz_sample_code",
"label": "编码池(就地改码)",
"count": pool_count,
}
)
for key, label, model, attr, has_rs, cell_only in _TABLE_SPECS:
if not _spec_applies(source, cell_only, key):
continue
cnt = _count_by_code(
db,
model,
tenant_no=tenant_no,
code=old_code,
code_attr=attr,
reception_source=source if has_rs else None,
has_reception_source=has_rs,
)
if cnt:
impacts.append({"table": key, "label": label, "count": cnt})
if (
key in _UNIQUE_TABLES
and source == SOURCE_CELL
and cnt > 0
and _unique_conflict(db, model, tenant_no=tenant_no, new_code=new_code)
):
blockers.append(
f"表 {key} 已存在样本编码「{new_code}」记录,无法直接改码"
)
ti = _count_task_inputs(db, tenant_no, old_code)
if ti:
impacts.append(
{
"table": "biz_production_task_input",
"label": "生产任务投入物(样本)",
"count": ti,
}
)
same_product_old = (
_count_same_product_code(db, tenant_no, old_code) if old_code else 0
)
same_product_new = (
_count_same_product_code(db, tenant_no, new_code) if new_code else 0
)
total = sum(int(i.get("count") or 0) for i in impacts)
return {
"ok": len(blockers) == 0,
"blockers": blockers,
"receptionSource": source,
"oldSampleCode": old_code,
"newSampleCode": new_code,
"sampleId": getattr(sample, "id", None) if sample else None,
"impacts": impacts,
"totalAffectedRows": total,
"hints": {
"sameProductCodeAsOld": same_product_old,
"sameProductCodeAsNew": same_product_new,
"labStorageCodeUnchanged": True,
"cellProductCodeNotAutoUpdated": True,
},
}
def _resolve_pool(
db: Session,
*,
tenant_no: str,
sample: BizProtocolSample,
old_code: str,
new_code: str,
new_sample_code_id: Optional[str],
user_id: Optional[str],
) -> Dict[str, Any]:
"""返回 pool 处理结果摘要。"""
now = _now()
result = {"action": "none", "poolId": None}
preferred_id = _norm(new_sample_code_id)
existing_new = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.sample_code == new_code,
)
.first()
)
if preferred_id:
preferred = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.id == preferred_id,
)
.first()
)
if preferred is None:
raise SampleCodeCorrectionError(f"指定的新编码池 ID「{preferred_id}」不存在")
if _norm(preferred.sample_code) not in {new_code, old_code}:
# 允许指向已是新码的池行,或指向即将就地改码的旧池行
if _norm(preferred.sample_code) != new_code:
preferred.sample_code = new_code
preferred.update_by = user_id
preferred.update_time = now
result["action"] = "rename_preferred"
existing_new = preferred
old_pool_id = _norm(getattr(sample, "sample_code_id", None))
old_pool = None
if old_pool_id:
old_pool = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.id == old_pool_id,
)
.first()
)
if existing_new is not None:
sample.sample_code_id = existing_new.id
sample.sample_code = new_code
existing_new.issued = 1
existing_new.update_by = user_id
existing_new.update_time = now
result["action"] = result["action"] if result["action"] != "none" else "reassign"
result["poolId"] = existing_new.id
if (
old_pool
and old_pool.id != existing_new.id
and _norm(old_pool.sample_code) == old_code
):
still_used = (
db.query(BizProtocolSample.id)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code_id == old_pool.id,
BizProtocolSample.id != sample.id,
)
.first()
)
if still_used is None:
old_pool.issued = 0
old_pool.update_by = user_id
old_pool.update_time = now
return result
if old_pool and _norm(old_pool.sample_code) == old_code:
old_pool.sample_code = new_code
old_pool.issued = 1
old_pool.update_by = user_id
old_pool.update_time = now
sample.sample_code = new_code
sample.sample_code_id = old_pool.id
result["action"] = "rename_inplace"
result["poolId"] = old_pool.id
return result
sample.sample_code = new_code
result["action"] = "sample_text_only"
return result
def apply_correction(
db: Session,
*,
tenant_no: str,
reception_source: str,
old_sample_code: str,
new_sample_code: str,
new_sample_code_id: Optional[str] = None,
reason: Optional[str] = None,
user_id: Optional[str] = None,
) -> Dict[str, Any]:
preview = preview_correction(
db,
tenant_no=tenant_no,
reception_source=reception_source,
old_sample_code=old_sample_code,
new_sample_code=new_sample_code,
new_sample_code_id=new_sample_code_id,
)
if not preview.get("ok"):
raise SampleCodeCorrectionError(
";".join(preview.get("blockers") or ["校验未通过"])
)
source = preview["receptionSource"]
old_code = preview["oldSampleCode"]
new_code = preview["newSampleCode"]
sample = _load_source_sample(
db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
)
if sample is None:
raise SampleCodeCorrectionError(f"未找到样本编码「{old_code}」")
now = _now()
impact_counts: Dict[str, int] = {}
if source == SOURCE_CELL:
pool_info = _resolve_pool(
db,
tenant_no=tenant_no,
sample=sample,
old_code=old_code,
new_code=new_code,
new_sample_code_id=new_sample_code_id,
user_id=user_id,
)
impact_counts["biz_sample_code"] = 1 if pool_info.get("action") != "none" else 0
sample.update_by = user_id
sample.update_time = now
impact_counts["biz_protocol_sample"] = 1
else:
sample.sample_code = new_code
sample.update_by = user_id
sample.update_time = now
impact_counts["biz_research_donation_sample"] = 1
# UNIQUE 表先更新,避免其它逻辑读到半新半旧
for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
if key not in _UNIQUE_TABLES:
continue
if not _spec_applies(source, cell_only, key):
continue
n = _update_by_code(
db,
model,
tenant_no=tenant_no,
old_code=old_code,
new_code=new_code,
code_attr=attr,
reception_source=source if has_rs else None,
has_reception_source=has_rs,
user_id=user_id,
)
if n:
impact_counts[key] = n
for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
if key in _UNIQUE_TABLES:
continue
if not _spec_applies(source, cell_only, key):
continue
n = _update_by_code(
db,
model,
tenant_no=tenant_no,
old_code=old_code,
new_code=new_code,
code_attr=attr,
reception_source=source if has_rs else None,
has_reception_source=has_rs,
user_id=user_id,
)
if n:
impact_counts[key] = n
ti = _update_task_inputs(
db,
tenant_no=tenant_no,
old_code=old_code,
new_code=new_code,
user_id=user_id,
)
if ti:
impact_counts["biz_production_task_input"] = ti
log = BizSampleCodeCorrectionLog(
id=generate_uuid(),
reception_source=source,
old_sample_code=old_code,
new_sample_code=new_code,
impact_json=json.dumps(impact_counts, ensure_ascii=False),
reason=_norm(reason) or None,
tenant_no=tenant_no,
order_num=0,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(log)
db.commit()
return {
"ok": True,
"logId": log.id,
"receptionSource": source,
"oldSampleCode": old_code,
"newSampleCode": new_code,
"impactCounts": impact_counts,
"totalAffectedRows": sum(impact_counts.values()),
"hints": preview.get("hints") or {},
}
@@ -0,0 +1,573 @@
"""协议新增样本:服务号模板消息通知。"""
from __future__ import annotations
import logging
import threading
import uuid
from datetime import datetime
from typing import Any, Dict, Iterable, List, Optional, Set
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import SessionLocal
from app.core.protocol_status_display import INACTIVE_PROTOCOL_STATUSES
from app.models.models import (
BizCellSampleType,
BizCollectionProtocol,
BizProtocolSample,
BizProtocolSampleTransport,
BizWxNotifyLog,
SysUser,
)
from app.models.research_donation import (
BizResearchDonation,
BizResearchDonationSample,
BizResearchDonationSampleTransport,
)
from app.services.web_auth_roles import list_user_ids_with_effective_role_name
logger = logging.getLogger(__name__)
NOTIFY_BIZ_TYPE = "sample_collect_notify"
NOTIFY_DATE_ONCE = "1970-01-01"
PROTOCOL_KIND_CELL = "cell_storage"
PROTOCOL_KIND_DONATION = "research_donation"
TASK_TYPE_CELL = "细胞存储"
TASK_TYPE_DONATION = "科研捐赠"
SAMPLE_STATUS_VOID = "yzf"
def sample_codes_from_payload(samples: Optional[Iterable[Any]]) -> Set[str]:
"""从请求体样本列表提取非空 sample_code(兼容 camelCase)。"""
out: Set[str] = set()
for raw in samples or []:
if isinstance(raw, dict):
code = (raw.get("sample_code") or raw.get("sampleCode") or "").strip()
else:
code = (getattr(raw, "sample_code", None) or "").strip()
if code:
out.add(code)
return out
def sample_codes_from_orm_rows(rows: Optional[Iterable[Any]]) -> Set[str]:
out: Set[str] = set()
for row in rows or []:
code = (getattr(row, "sample_code", None) or "").strip()
if code:
out.add(code)
return out
def diff_new_sample_codes(
*,
before_codes: Iterable[str],
submitted_codes: Iterable[str],
) -> List[str]:
"""提交相对保存前的新增 sample_code;稳定排序便于测试。"""
before = {str(c).strip() for c in before_codes if str(c).strip()}
submitted = {str(c).strip() for c in submitted_codes if str(c).strip()}
return sorted(submitted - before)
def format_sample_collect_title(eta_time: Optional[datetime] = None) -> str:
"""工单标题:有 ETA → 样本采集(预计到达 M月D日);否则到达待定。"""
if eta_time is not None:
title = f"样本采集(预计到达 {eta_time.month}月{eta_time.day}日)"
else:
title = "样本采集(到达待定)"
return title
def format_protocol_create_time(dt: Optional[datetime]) -> str:
if not dt:
return ""
return dt.strftime("%Y年%m月%d日 %H:%M")
def _notify_enabled() -> bool:
if not bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False)):
return False
tid = (getattr(settings, "WX_MP_TEMPLATE_SAMPLE_COLLECT", "") or "").strip()
return bool(tid)
def _role_name() -> str:
return (
getattr(settings, "WX_MP_SAMPLE_COLLECT_ROLE_NAME", "") or "样本采集通知人员"
).strip() or "样本采集通知人员"
def protocol_status_blocks_notify(status: Optional[str]) -> bool:
"""已作废、属地废弃的协议单不发样本采集通知。"""
return (status or "").strip() in INACTIVE_PROTOCOL_STATUSES
def sample_status_blocks_notify(status: Optional[str]) -> bool:
"""已作废样本不发样本采集通知。"""
return (status or "").strip() == SAMPLE_STATUS_VOID
def _sample_status_by_code(samples: Iterable[Any]) -> Dict[str, str]:
out: Dict[str, str] = {}
for sample in samples or []:
code = (getattr(sample, "sample_code", None) or "").strip()
if not code:
continue
out[code] = (getattr(sample, "sample_status", None) or "").strip()
return out
def _task_type_for_kind(protocol_kind: str) -> str:
if protocol_kind == PROTOCOL_KIND_DONATION:
return TASK_TYPE_DONATION
return TASK_TYPE_CELL
def _sample_type_name_by_code(
db: Session, *, tenant_no: str, samples: Iterable[Any]
) -> Dict[str, str]:
"""按样本编码取样本类型名称。无类型或类型已删除时为空串。"""
rows = list(samples or [])
type_ids = {
(getattr(sample, "sample_type_id", None) or "").strip()
for sample in rows
if (getattr(sample, "sample_type_id", None) or "").strip()
}
name_by_id: Dict[str, str] = {}
if type_ids:
type_rows = (
db.query(BizCellSampleType)
.filter(
BizCellSampleType.id.in_(list(type_ids)),
BizCellSampleType.tenant_no == tenant_no,
BizCellSampleType.del_flag == 0,
)
.all()
)
name_by_id = {
row.id: (row.type_name or "").strip() for row in type_rows
}
out: Dict[str, str] = {}
for sample in rows:
code = (getattr(sample, "sample_code", None) or "").strip()
if not code:
continue
type_id = (getattr(sample, "sample_type_id", None) or "").strip()
out[code] = name_by_id.get(type_id, "") if type_id else ""
return out
def _biz_id(sample_code: str, user_id: str) -> str:
return f"{(sample_code or '').strip()}:{(user_id or '').strip()}"
def _already_success(
db: Session, *, tenant_no: str, sample_code: str, user_id: str
) -> bool:
row = (
db.query(BizWxNotifyLog)
.filter(
BizWxNotifyLog.tenant_no == tenant_no,
BizWxNotifyLog.biz_type == NOTIFY_BIZ_TYPE,
BizWxNotifyLog.biz_id == _biz_id(sample_code, user_id),
BizWxNotifyLog.notify_date == NOTIFY_DATE_ONCE,
BizWxNotifyLog.status == "success",
BizWxNotifyLog.del_flag == 0,
)
.first()
)
return row is not None
def _write_success_log(
db: Session,
*,
tenant_no: str,
sample_code: str,
user_id: str,
) -> None:
now = datetime.now()
db.add(
BizWxNotifyLog(
id=str(uuid.uuid4()).replace("-", "").upper(),
biz_type=NOTIFY_BIZ_TYPE,
biz_id=_biz_id(sample_code, user_id),
notify_date=NOTIFY_DATE_ONCE,
user_id=user_id or "",
status="success",
errcode=None,
errmsg=None,
tenant_no=tenant_no,
order_num=0,
remark="",
create_by=user_id or "",
create_dept_id="",
create_time=now,
update_by=user_id or "",
update_time=now,
del_flag=0,
)
)
def list_notify_recipient_users(db: Session, *, tenant_no: str) -> List[SysUser]:
"""有效拥有「样本采集通知人员」角色且已绑定服务号的用户。
有效角色 = 用户直接绑角色 ∪ 经启用职位继承(sys_user_post → sys_post → sys_post_role),
与登录权限解析一致。
"""
role_name = _role_name()
user_ids = list_user_ids_with_effective_role_name(
db, tenant_no=tenant_no, role_name=role_name
)
if not user_ids:
return []
rows = (
db.query(SysUser)
.filter(
SysUser.id.in_(list(user_ids)),
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0,
SysUser.wx_mp_openid.isnot(None),
SysUser.wx_mp_openid != "",
)
.all()
)
# 按 user_id 已去重;再按 openid 去重,避免一人多职位/多账号同微信重复推送
seen_openid: Set[str] = set()
out: List[SysUser] = []
for u in rows:
oid = (getattr(u, "wx_mp_openid", None) or "").strip()
if not oid or oid in seen_openid:
continue
seen_openid.add(oid)
out.append(u)
return out
def _load_cell_context(
db: Session, *, protocol_id: str, tenant_no: str, new_codes: List[str]
) -> Optional[Dict[str, Any]]:
protocol = (
db.query(BizCollectionProtocol)
.filter(
BizCollectionProtocol.id == protocol_id,
BizCollectionProtocol.tenant_no == tenant_no,
BizCollectionProtocol.del_flag == 0,
)
.first()
)
if not protocol:
return None
samples = (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.collection_protocol_id == protocol_id,
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code.in_(new_codes),
)
.all()
)
code_to_id = {
(s.sample_code or "").strip(): s.id
for s in samples
if (s.sample_code or "").strip()
}
sample_ids = list(code_to_id.values())
eta_by_code: Dict[str, Optional[datetime]] = {c: None for c in new_codes}
if sample_ids:
transports = (
db.query(BizProtocolSampleTransport)
.filter(
BizProtocolSampleTransport.collection_protocol_id == protocol_id,
BizProtocolSampleTransport.tenant_no == tenant_no,
BizProtocolSampleTransport.del_flag == 0,
BizProtocolSampleTransport.sample_id.in_(sample_ids),
)
.all()
)
id_to_code = {sid: code for code, sid in code_to_id.items()}
for t in transports:
code = id_to_code.get((t.sample_id or "").strip()) or (
getattr(t, "sample_code", None) or ""
).strip()
if code and code in eta_by_code and t.eta_time and eta_by_code[code] is None:
eta_by_code[code] = t.eta_time
creator = None
if protocol.create_by:
creator = (
db.query(SysUser)
.filter(SysUser.id == protocol.create_by, SysUser.del_flag == 0)
.first()
)
return {
"create_time": protocol.create_time,
"applicant": (creator.nick_name if creator else "") or "",
"eta_by_code": eta_by_code,
"protocol_status": (protocol.protocol_status or "").strip(),
"sample_status_by_code": _sample_status_by_code(samples),
"sample_type_by_code": _sample_type_name_by_code(
db, tenant_no=tenant_no, samples=samples
),
}
def _load_donation_context(
db: Session, *, protocol_id: str, tenant_no: str, new_codes: List[str]
) -> Optional[Dict[str, Any]]:
donation = (
db.query(BizResearchDonation)
.filter(
BizResearchDonation.id == protocol_id,
BizResearchDonation.tenant_no == tenant_no,
BizResearchDonation.del_flag == 0,
)
.first()
)
if not donation:
return None
samples = (
db.query(BizResearchDonationSample)
.filter(
BizResearchDonationSample.research_donation_id == protocol_id,
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
BizResearchDonationSample.sample_code.in_(new_codes),
)
.all()
)
code_to_id = {
(s.sample_code or "").strip(): s.id
for s in samples
if (s.sample_code or "").strip()
}
sample_ids = list(code_to_id.values())
eta_by_code: Dict[str, Optional[datetime]] = {c: None for c in new_codes}
if sample_ids:
transports = (
db.query(BizResearchDonationSampleTransport)
.filter(
BizResearchDonationSampleTransport.research_donation_id
== protocol_id,
BizResearchDonationSampleTransport.tenant_no == tenant_no,
BizResearchDonationSampleTransport.del_flag == 0,
BizResearchDonationSampleTransport.sample_id.in_(sample_ids),
)
.all()
)
id_to_code = {sid: code for code, sid in code_to_id.items()}
for t in transports:
code = id_to_code.get((t.sample_id or "").strip()) or (
getattr(t, "sample_code", None) or ""
).strip()
if code and code in eta_by_code and t.eta_time and eta_by_code[code] is None:
eta_by_code[code] = t.eta_time
creator = None
if donation.create_by:
creator = (
db.query(SysUser)
.filter(SysUser.id == donation.create_by, SysUser.del_flag == 0)
.first()
)
return {
"create_time": donation.create_time,
"applicant": (creator.nick_name if creator else "") or "",
"eta_by_code": eta_by_code,
"protocol_status": (donation.donation_status or "").strip(),
"sample_status_by_code": _sample_status_by_code(samples),
"sample_type_by_code": _sample_type_name_by_code(
db, tenant_no=tenant_no, samples=samples
),
}
def run_sample_collect_notify(
db: Session,
*,
tenant_no: str,
protocol_id: str,
protocol_kind: str,
new_sample_codes: Iterable[str],
) -> Dict[str, int]:
"""对新增样本向角色用户发送通知。返回计数。"""
stats = {"codes": 0, "sent": 0, "skipped": 0, "failed": 0}
if not _notify_enabled():
logger.info("sample collect notify skipped: notify disabled or no template")
return stats
codes = [c for c in (str(x).strip() for x in new_sample_codes) if c]
if not codes:
return stats
stats["codes"] = len(codes)
tenant = (tenant_no or "").strip() or "zkxw"
kind = (protocol_kind or "").strip() or PROTOCOL_KIND_CELL
if kind == PROTOCOL_KIND_DONATION:
ctx = _load_donation_context(
db, protocol_id=protocol_id, tenant_no=tenant, new_codes=codes
)
else:
ctx = _load_cell_context(
db, protocol_id=protocol_id, tenant_no=tenant, new_codes=codes
)
if not ctx:
logger.info(
"sample collect notify skip: protocol missing id=%s kind=%s",
protocol_id,
kind,
)
return stats
protocol_status = (ctx.get("protocol_status") or "").strip()
if protocol_status_blocks_notify(protocol_status):
logger.info(
"sample collect notify skip: inactive protocol id=%s kind=%s status=%s",
protocol_id,
kind,
protocol_status,
)
stats["skipped"] = len(codes)
return stats
recipients = list_notify_recipient_users(db, tenant_no=tenant)
if not recipients:
logger.info(
"sample collect notify skip: no recipients tenant=%s role=%s",
tenant,
_role_name(),
)
return stats
from app.services.wechat_mp_message import send_sample_collect_notify
applicant = ctx.get("applicant") or ""
time_text = format_protocol_create_time(ctx.get("create_time"))
task_type = _task_type_for_kind(kind)
eta_by_code: Dict[str, Optional[datetime]] = ctx.get("eta_by_code") or {}
sample_type_by_code: Dict[str, str] = ctx.get("sample_type_by_code") or {}
sample_status_by_code: Dict[str, str] = ctx.get("sample_status_by_code") or {}
for code in codes:
if sample_status_blocks_notify(sample_status_by_code.get(code)):
logger.info(
"sample collect notify skip: voided sample code=%s protocol=%s",
code,
protocol_id,
)
stats["skipped"] += 1
continue
title = format_sample_collect_title(eta_by_code.get(code))
sample_type = (sample_type_by_code.get(code) or "").strip()
for user in recipients:
uid = (user.id or "").strip()
mp = (getattr(user, "wx_mp_openid", None) or "").strip()
if not uid or not mp:
stats["skipped"] += 1
continue
if _already_success(db, tenant_no=tenant, sample_code=code, user_id=uid):
stats["skipped"] += 1
continue
result = send_sample_collect_notify(
mp_openid=mp,
title=title,
applicant=applicant,
time_text=time_text,
task_type=task_type,
sample_type=sample_type,
pagepath="",
)
if result.get("success"):
_write_success_log(
db, tenant_no=tenant, sample_code=code, user_id=uid
)
stats["sent"] += 1
else:
logger.error(
"sample collect notify failed code=%s user=%s err=%s",
code,
uid,
result,
)
stats["failed"] += 1
try:
db.commit()
except Exception:
db.rollback()
logger.exception("sample collect notify commit failed")
raise
return stats
def notify_sample_collect_sync(
*,
tenant_no: str,
protocol_id: str,
protocol_kind: str,
new_sample_codes: List[str],
) -> None:
db = None
try:
db = SessionLocal()
stats = run_sample_collect_notify(
db,
tenant_no=tenant_no,
protocol_id=protocol_id,
protocol_kind=protocol_kind,
new_sample_codes=new_sample_codes,
)
logger.info(
"sample collect notify done protocol=%s kind=%s stats=%s",
protocol_id,
protocol_kind,
stats,
)
except Exception:
logger.exception(
"sample collect notify batch failed protocol=%s kind=%s",
protocol_id,
protocol_kind,
)
finally:
if db is not None:
db.close()
def schedule_sample_collect_notify(
*,
tenant_no: str,
protocol_id: str,
protocol_kind: str,
before_codes: Iterable[str],
submitted_codes: Optional[Iterable[str]],
) -> None:
"""保存成功后调度:submitted_codes 为 None 表示本次未改样本,不推。"""
if not _notify_enabled():
logger.info("sample collect notify schedule skipped: disabled")
return
if submitted_codes is None:
return
new_codes = diff_new_sample_codes(
before_codes=before_codes, submitted_codes=submitted_codes
)
if not new_codes:
return
pid = (protocol_id or "").strip()
if not pid:
return
threading.Thread(
target=notify_sample_collect_sync,
kwargs={
"tenant_no": (tenant_no or "").strip() or "zkxw",
"protocol_id": pid,
"protocol_kind": (protocol_kind or "").strip() or PROTOCOL_KIND_CELL,
"new_sample_codes": new_codes,
},
daemon=True,
name="sample-collect-wx-notify",
).start()
@@ -85,7 +85,7 @@ def _user_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
return {}
rows = (
db.query(SysUser.id, SysUser.nick_name)
.filter(SysUser.id.in_(ids), SysUser.del_flag == 0)
.filter(SysUser.id.in_(ids))
.all()
)
return {str(row[0]): (row[1] or "") for row in rows}
@@ -57,6 +57,7 @@ def loads_payload(raw: Optional[str]) -> Dict[str, Any]:
def empty_detail(
*,
sample_code: str,
lab_flow_code: str = "",
protocol_code: str = "",
mother_name: str = "",
sample_type_name: str = "",
@@ -66,7 +67,7 @@ def empty_detail(
) -> Dict[str, Any]:
return {
"sampleCode": sample_code,
"labFlowCode": sample_code,
"labFlowCode": lab_flow_code or "",
"protocolCode": protocol_code or "",
"motherName": mother_name or "",
"sampleTypeName": sample_type_name or "",
@@ -0,0 +1,551 @@
"""储存证质检信息:从已有业务数据补空字段。读不到的键不写入。
阴阳性只取检测参数里的阴/阳,不根据任务状态或结论推断;参数未填则留空。
细菌检测默认「阴性」。围产期检测结果评论、脐带检测结果评判默认「合格」。
同一采集协议下其他样本的母血可补当前样本缺的母亲血液项;本样本已有结果优先。不共享脐血。
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, Iterable, List, Optional
from sqlalchemy.orm import Session
from app.models.models import (
BizCellProduct,
BizProductionTask,
BizProductionTaskOutput,
BizProductionWorkOrder,
BizProtocolCollectionInfo,
BizProtocolSample,
BizQcTask,
BizQcTaskResult,
BizQcWorkOrder,
BizQcWorkOrderInspectionBatch,
BizSampleProductionReception,
BizSampleReception,
)
_FIXED_COMMON = {"storage_years": "20年"}
_FIXED_UC_MSC = {
"available_cell_type": "间充质干细胞",
"available_cell_morphology": "短梭/贴壁型",
"available_immunophenotype": "符合间充质干细胞表型特性",
"freeze_method": "程序降温盒降温",
"storage_temp": "-196℃",
}
def _compact(name: Optional[str]) -> str:
text = (name or "").strip().lower().replace("+", "+")
for ch in " \t\r\n**//()()【】\"'“”+":
text = text.replace(ch, "")
return text
def match_cd34_param(param_name: Optional[str]) -> Optional[str]:
"""CD34+细胞流式参数名 → 储存证字段。CD45dim 优先于 CD34 总数。"""
compact = _compact(param_name)
if not compact:
return None
if "cd45dim" in compact:
return "cd34_cd45dim_1e5"
if "tnc" in compact or "有核细胞" in compact:
return "tnc_1e8"
if "cd34" in compact and "cd45" not in compact:
return "cd34_count_1e6"
return None
def is_cd34_flow_item(item_name: Optional[str]) -> bool:
compact = _compact(item_name)
return "cd34" in compact and "流式" in compact
def match_process_qc_field(item_name: Optional[str]) -> Optional[str]:
name = item_name or ""
if "内毒素" in name:
return "endotoxin_test"
if "真菌" in name:
return "fungus_test"
if "支原体" in name:
return "mycoplasma_test"
return None
def qual_yin_yang(values: Iterable[str]) -> str:
"""只接受明确的阴/阳。通过、未检出、阴阳性同时出现都留空。"""
found = set()
for raw in values:
text = (raw or "").strip()
if text in ("阴", "阴性"):
found.add("阴")
elif text in ("阳", "阳性"):
found.add("阳")
if len(found) == 1:
return next(iter(found))
return ""
def normalize_negative_positive(value: Optional[str]) -> str:
"""储存证阴阳性下拉用「阴性 / 阳性」。"""
text = (value or "").strip()
if text in ("阳", "阳性"):
return "阳性"
if text in ("阴", "阴性"):
return "阴性"
return ""
def _fold_name(name: Optional[str]) -> str:
return (name or "").replace(" ", "").replace(" ", "").upper()
def batch_kind_from_name(batch_name: Optional[str]) -> str:
"""检测批次名含母血、脐血时区分储存证来源。母血优先,避免名称里同时出现时串项。"""
folded = _fold_name(batch_name)
if "母血" in folded:
return "母血"
if "脐血" in folded:
return "脐血"
return ""
def _first_negative_positive(values: Iterable[str]) -> str:
found = []
for raw in values:
qual = normalize_negative_positive(raw)
if qual and qual not in found:
found.append(qual)
if "阳性" in found:
return "阳性"
if "阴性" in found:
return "阴性"
return ""
def _item_has(item_name: Optional[str], *parts: str) -> bool:
folded = _fold_name(item_name)
return all(part.upper() in folded for part in parts)
def _param_surface_antigen(param_name: Optional[str]) -> bool:
folded = _fold_name(param_name)
return "表面抗原" in folded and "抗体" not in folded
# (字段, 批次, 项目是否命中, 参数是否命中;参数为空则取该项目里能识别的阴/阳)
_HSC_SEROLOGY_RULES = (
("hbsag", "脐血", lambda name: _item_has(name, "乙肝五项"), _param_surface_antigen),
("anti_hcv", "脐血", lambda name: _item_has(name, "抗丙型肝炎病毒抗体"), None),
("anti_tp", "脐血", lambda name: _item_has(name, "梅毒", "抗体"), None),
("anti_hiv", "脐血", lambda name: _item_has(name, "抗人类免疫缺陷病毒抗体"), None),
("cmv_igm", "脐血", lambda name: _item_has(name, "巨细胞") and _item_has(name, "IGM"), None),
("mother_hbsag", "母血", lambda name: _item_has(name, "乙肝五项"), _param_surface_antigen),
(
"mother_hbv_dna",
"母血",
lambda name: _item_has(name, "乙型肝炎病毒DNA") or _item_has(name, "乙型肝炎病毒核酸"),
None,
),
("mother_anti_hcv", "母血", lambda name: _item_has(name, "抗丙型肝炎病毒抗体"), None),
(
"mother_hcv_rna",
"母血",
lambda name: _item_has(name, "丙型肝炎病毒RNA") or _item_has(name, "丙型肝炎病毒核酸"),
None,
),
("mother_anti_tp", "母血", lambda name: _item_has(name, "梅毒", "抗体"), None),
("mother_cmv_igm", "母血", lambda name: _item_has(name, "巨细胞") and _item_has(name, "IGM"), None),
("mother_anti_hiv", "母血", lambda name: _item_has(name, "抗人类免疫缺陷病毒抗体"), None),
(
"mother_hiv1_rna",
"母血",
lambda name: _item_has(name, "人类免疫缺陷病毒", "核酸") or _item_has(name, "HIV-1RNA") or _item_has(name, "HIV1RNA"),
None,
),
)
_MOTHER_SEROLOGY_RULES = tuple(rule for rule in _HSC_SEROLOGY_RULES if str(rule[0]).startswith("mother_"))
def _serology_from_tasks(
tasks: List[Dict[str, Any]],
rules=_HSC_SEROLOGY_RULES,
) -> Dict[str, str]:
out: Dict[str, str] = {}
for field, kind, item_ok, param_ok in rules:
matched = [
task
for task in tasks
if task.get("batch_kind") == kind and item_ok(task.get("item_name") or "")
]
if not matched:
continue
chosen = max(matched, key=lambda task: task["stamp"])
values = []
for param_name, param_value in chosen.get("params") or []:
if param_ok is not None and not param_ok(param_name):
continue
values.append(param_value)
qual = _first_negative_positive(values)
if qual:
out[field] = qual
return out
def format_number(value: Any) -> str:
if value is None or value == "":
return ""
try:
number = Decimal(str(value))
except (InvalidOperation, ValueError):
return ""
text = format(number, "f")
if "." in text:
text = text.rstrip("0").rstrip(".")
return text
def sum_cryo_volume(weight_g: Any, volume_ml: Any) -> str:
"""富集后血量与冻存液用量都有才相加。"""
if weight_g is None or volume_ml is None or weight_g == "" or volume_ml == "":
return ""
try:
total = Decimal(str(weight_g)) + Decimal(str(volume_ml))
except (InvalidOperation, ValueError):
return ""
return format_number(total)
def format_datetime(value: Any) -> str:
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M")
return ""
def format_date(value: Any) -> str:
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d")
return ""
def merge_prefill(saved: Optional[Dict[str, Any]], suggested: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""已有非空内容保留,只补空格。"""
out: Dict[str, Any] = dict(saved or {})
for key, value in (suggested or {}).items():
if value is None or str(value).strip() == "":
continue
current = out.get(key)
if current is None or str(current).strip() == "":
out[key] = value
return out
def _latest_task_params(tasks: List[Dict[str, Any]], predicate) -> List[str]:
matched = [task for task in tasks if predicate(task)]
if not matched:
return []
chosen = max(matched, key=lambda task: task["stamp"])
return list(chosen["values"])
def _cd34_from_tasks(tasks: List[Dict[str, Any]]) -> Dict[str, str]:
flow_tasks = [task for task in tasks if is_cd34_flow_item(task["item_name"])]
if not flow_tasks:
return {}
chosen = max(flow_tasks, key=lambda task: task["stamp"])
out: Dict[str, str] = {}
for param_name, param_value in chosen["params"]:
field = match_cd34_param(param_name)
text = (param_value or "").strip()
if field and text and field not in out:
out[field] = text
return out
def _process_qual_from_tasks(tasks: List[Dict[str, Any]]) -> Dict[str, str]:
"""生产过程质检:内毒素、真菌、支原体。只认参数里的阴/阳,任务结论「通过」不算。"""
out: Dict[str, str] = {}
for field in ("endotoxin_test", "fungus_test", "mycoplasma_test"):
values = _latest_task_params(
tasks, lambda task, field=field: match_process_qc_field(task["item_name"]) == field
)
qual = _first_negative_positive(values)
if qual:
out[field] = qual
return out
def _fill_shared_mother(suggested: Dict[str, str], tasks: Optional[List[Dict[str, Any]]]) -> None:
"""只补母亲血液空项。共享任务里的脐血不参与。"""
shared = _serology_from_tasks(tasks or [], _MOTHER_SEROLOGY_RULES)
for key, value in shared.items():
suggested.setdefault(key, value)
def build_suggested(
*,
sample_type_key: str,
collection_time: Any = None,
reception_time: Any = None,
receive_temperature: Any = None,
enriched_blood_weight_g: Any = None,
cryopreservation_volume_ml: Any = None,
storage_total_tubes: Any = None,
available_cell_count_1e7: Any = None,
gate_tasks: Optional[List[Dict[str, Any]]] = None,
process_tasks: Optional[List[Dict[str, Any]]] = None,
shared_mother_tasks: Optional[List[Dict[str, Any]]] = None,
latest_freeze_date: Any = None,
) -> Dict[str, str]:
suggested: Dict[str, str] = {}
collected = format_datetime(collection_time)
if collected:
suggested["collection_time"] = collected
received = format_datetime(reception_time)
if received:
suggested["receive_time"] = received
freeze = format_date(latest_freeze_date)
if sample_type_key == "perinatal_hsc":
volume = sum_cryo_volume(enriched_blood_weight_g, cryopreservation_volume_ml)
if volume:
suggested["cryo_volume_ml"] = volume
suggested.update(_cd34_from_tasks(gate_tasks or []))
suggested.update(_serology_from_tasks(gate_tasks or []))
suggested.setdefault("bacteria_test", "阴性")
suggested.setdefault("result_comment", "合格")
if freeze:
suggested["storage_date"] = freeze
suggested.update(_FIXED_COMMON)
elif sample_type_key == "uc_msc":
temp = ("" if receive_temperature is None else str(receive_temperature)).strip()
if temp:
suggested["transport_temp_c"] = temp
tubes = format_number(storage_total_tubes)
if tubes:
suggested["storage_tube_count"] = tubes
count = format_number(available_cell_count_1e7)
if count:
suggested["available_cell_count_1e7"] = count
suggested.update(_process_qual_from_tasks(process_tasks or []))
suggested.update(_serology_from_tasks(gate_tasks or [], _MOTHER_SEROLOGY_RULES))
suggested.setdefault("bacteria_test", "阴性")
suggested.setdefault("result_judgment", "合格")
if freeze:
suggested["storage_date_p2"] = freeze
suggested.update(_FIXED_COMMON)
suggested.update(_FIXED_UC_MSC)
if sample_type_key in ("perinatal_hsc", "uc_msc"):
_fill_shared_mother(suggested, shared_mother_tasks)
return {key: value for key, value in suggested.items() if str(value).strip()}
def _stamp(completed: Any, updated: Any) -> datetime:
if isinstance(completed, datetime):
return completed
if isinstance(updated, datetime):
return updated
return datetime.min
def _load_qc_tasks(db: Session, tenant_no: str, sample_code: str, trigger_source: str) -> List[Dict[str, Any]]:
rows = (
db.query(
BizQcTask.id,
BizQcTask.item_name,
BizQcTask.completed_time,
BizQcTask.update_time,
BizQcWorkOrderInspectionBatch.batch_name,
BizQcTaskResult.param_name,
BizQcTaskResult.param_value,
)
.join(BizQcWorkOrder, BizQcWorkOrder.id == BizQcTask.work_order_id)
.outerjoin(
BizQcWorkOrderInspectionBatch,
BizQcWorkOrderInspectionBatch.id == BizQcTask.batch_id,
)
.outerjoin(
BizQcTaskResult,
(BizQcTaskResult.task_id == BizQcTask.id) & (BizQcTaskResult.del_flag == 0),
)
.filter(
BizQcWorkOrder.tenant_no == tenant_no,
BizQcWorkOrder.sample_code == sample_code,
BizQcWorkOrder.del_flag == 0,
BizQcWorkOrder.trigger_source == trigger_source,
BizQcTask.tenant_no == tenant_no,
BizQcTask.del_flag == 0,
BizQcTask.task_status == "ywc",
)
.all()
)
grouped: Dict[str, Dict[str, Any]] = {}
for row in rows:
task = grouped.setdefault(
row.id,
{
"item_name": row.item_name or "",
"batch_kind": batch_kind_from_name(row.batch_name),
"stamp": _stamp(row.completed_time, row.update_time),
"params": [],
"values": [],
},
)
if (row.param_name or "").strip() or (row.param_value or "").strip():
task["params"].append((row.param_name or "", row.param_value or ""))
if (row.param_value or "").strip():
task["values"].append(row.param_value or "")
return list(grouped.values())
def _sibling_sample_codes(db: Session, tenant_no: str, sample_code: str) -> List[str]:
"""同一采集协议下的其他样本。母血通常只做在其中一份上。"""
owner = (
db.query(BizProtocolSample.collection_protocol_id)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.sample_code == sample_code,
BizProtocolSample.del_flag == 0,
)
.first()
)
protocol_id = (owner.collection_protocol_id or "").strip() if owner else ""
if not protocol_id:
return []
rows = (
db.query(BizProtocolSample.sample_code)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.collection_protocol_id == protocol_id,
BizProtocolSample.sample_code != sample_code,
BizProtocolSample.del_flag == 0,
)
.all()
)
codes = []
for row in rows:
code = (row.sample_code or "").strip()
if code and code not in codes:
codes.append(code)
return codes
def _load_shared_mother_tasks(db: Session, tenant_no: str, sample_code: str) -> List[Dict[str, Any]]:
tasks: List[Dict[str, Any]] = []
for code in _sibling_sample_codes(db, tenant_no, sample_code):
tasks.extend(
task
for task in _load_qc_tasks(db, tenant_no, code, "reception")
if task.get("batch_kind") == "母血"
)
return tasks
def _latest_freeze_date(db: Session, tenant_no: str, sample_code: str) -> Optional[datetime]:
output_dates = (
db.query(BizCellProduct.freeze_date)
.join(BizProductionTaskOutput, BizProductionTaskOutput.cell_product_id == BizCellProduct.id)
.join(BizProductionTask, BizProductionTask.id == BizProductionTaskOutput.task_id)
.join(BizProductionWorkOrder, BizProductionWorkOrder.id == BizProductionTask.work_order_id)
.filter(
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.sample_code == sample_code,
BizProductionWorkOrder.del_flag == 0,
BizProductionWorkOrder.work_order_status != "yzf",
BizProductionTask.tenant_no == tenant_no,
BizProductionTask.del_flag == 0,
BizProductionTaskOutput.tenant_no == tenant_no,
BizProductionTaskOutput.del_flag == 0,
BizCellProduct.tenant_no == tenant_no,
BizCellProduct.del_flag == 0,
BizCellProduct.product_status != "voided",
BizCellProduct.freeze_date.isnot(None),
)
.all()
)
linked_dates = (
db.query(BizCellProduct.freeze_date)
.join(BizProductionTask, BizProductionTask.id == BizCellProduct.production_task_id)
.join(BizProductionWorkOrder, BizProductionWorkOrder.id == BizProductionTask.work_order_id)
.filter(
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.sample_code == sample_code,
BizProductionWorkOrder.del_flag == 0,
BizProductionWorkOrder.work_order_status != "yzf",
BizProductionTask.tenant_no == tenant_no,
BizProductionTask.del_flag == 0,
BizCellProduct.tenant_no == tenant_no,
BizCellProduct.del_flag == 0,
BizCellProduct.product_status != "voided",
BizCellProduct.freeze_date.isnot(None),
)
.all()
)
dates = [row.freeze_date for row in output_dates + linked_dates if isinstance(row.freeze_date, datetime)]
if not dates:
return None
return max(dates)
def collect_prefill(db: Session, tenant_no: str, sample_code: str, sample_type_key: Optional[str]) -> Dict[str, str]:
if sample_type_key not in ("perinatal_hsc", "uc_msc"):
return {}
code = (sample_code or "").strip()
if not code:
return {}
collection = (
db.query(BizProtocolCollectionInfo.sample_collect_time)
.join(
BizProtocolSample,
BizProtocolSample.collection_protocol_id == BizProtocolCollectionInfo.collection_protocol_id,
)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.sample_code == code,
BizProtocolSample.del_flag == 0,
BizProtocolCollectionInfo.tenant_no == tenant_no,
BizProtocolCollectionInfo.del_flag == 0,
)
.first()
)
collection_time = collection.sample_collect_time if collection else None
reception = (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.sample_code == code,
BizSampleReception.del_flag == 0,
BizSampleReception.reception_source == "cell_storage",
BizSampleReception.accept_conclusion == "js",
)
.order_by(BizSampleReception.reception_time.desc())
.first()
)
production = None
if reception is not None:
production = (
db.query(BizSampleProductionReception)
.filter(
BizSampleProductionReception.tenant_no == tenant_no,
BizSampleProductionReception.reception_id == reception.id,
BizSampleProductionReception.del_flag == 0,
)
.first()
)
return build_suggested(
sample_type_key=sample_type_key,
collection_time=collection_time,
reception_time=None if reception is None else reception.reception_time,
receive_temperature=None if reception is None else reception.receive_temperature,
enriched_blood_weight_g=None if production is None else production.enriched_blood_weight_g,
cryopreservation_volume_ml=None if production is None else production.cryopreservation_volume_ml,
storage_total_tubes=None if production is None else production.storage_total_tubes,
available_cell_count_1e7=None if production is None else production.available_cell_count_1e7,
gate_tasks=_load_qc_tasks(db, tenant_no, code, "reception"),
process_tasks=_load_qc_tasks(db, tenant_no, code, "production"),
shared_mother_tasks=_load_shared_mother_tasks(db, tenant_no, code),
latest_freeze_date=_latest_freeze_date(db, tenant_no, code),
)
+74
View File
@@ -234,3 +234,77 @@ def send_oa_applicant_progress_notify(
data=data,
pagepath=pagepath,
)
def send_basic_experiment_delivery_remind(
*,
mp_openid: str,
customer_name: str,
product_name: str,
quantity: str,
contact_name: str,
time_text: str,
pagepath: str = "",
) -> dict:
"""基础实验:交付日前一天出库提醒。"""
data: Dict[str, Dict[str, str]] = {}
customer_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER or "").strip()
product_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT or "").strip()
qty_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_QTY or "").strip()
contact_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT or "").strip()
time_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_TIME or "").strip()
if customer_key:
data[customer_key] = {"value": _truncate_thing(customer_name)}
if product_key:
data[product_key] = {"value": _truncate_thing(product_name)}
if qty_key:
data[qty_key] = {"value": (quantity or "").strip()}
if contact_key:
data[contact_key] = {"value": _truncate_thing(contact_name)}
if time_key:
data[time_key] = {"value": (time_text or "").strip()}
return _send_template(
mp_openid=mp_openid,
template_id=settings.WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY,
data=data,
pagepath=pagepath or "",
)
def send_sample_collect_notify(
*,
mp_openid: str,
title: str,
applicant: str,
time_text: str,
task_type: str,
sample_type: str = "",
pagepath: str = "",
) -> dict:
"""协议新增样本:样本采集工单通知。"""
data: Dict[str, Dict[str, str]] = {}
title_key = (settings.WX_MP_TPL_SC_FIELD_TITLE or "").strip()
applicant_key = (settings.WX_MP_TPL_SC_FIELD_APPLICANT or "").strip()
time_key = (settings.WX_MP_TPL_SC_FIELD_TIME or "").strip()
type_key = (settings.WX_MP_TPL_SC_FIELD_TYPE or "").strip()
sample_type_key = (settings.WX_MP_TPL_SC_FIELD_SAMPLE_TYPE or "").strip()
if title_key:
data[title_key] = {"value": _truncate_thing(title)}
if applicant_key:
data[applicant_key] = {"value": _truncate_thing(applicant)}
if time_key:
data[time_key] = {"value": (time_text or "").strip()}
if type_key:
# phrase:纯汉字短词,不做 thing 截断以外的改写
data[type_key] = {"value": (task_type or "").strip()}
if sample_type_key:
# thing 至少 1 个字符;没有样本类型时不传该字段,避免空值被微信拒绝
sample_type_value = _truncate_thing(sample_type)
if sample_type_value:
data[sample_type_key] = {"value": sample_type_value}
return _send_template(
mp_openid=mp_openid,
template_id=settings.WX_MP_TEMPLATE_SAMPLE_COLLECT,
data=data,
pagepath=pagepath or "",
)
+18 -3
View File
@@ -1,18 +1,33 @@
"""定时任务模块(骨架:不注册业务任务)"""
"""定时任务模块"""
import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler()
def init_scheduled_tasks(get_db_session):
"""初始化定时任务(骨架工程无默认任务)"""
"""初始化定时任务。"""
if scheduler.running:
return
scheduler.start()
logger.info("定时任务调度器已启动(骨架模式:无业务 Job)")
from app.services.basic_experiment_delivery_remind import run_delivery_remind_job
scheduler.add_job(
run_delivery_remind_job,
trigger=CronTrigger(hour=10, minute=0),
args=[get_db_session],
id="basic_experiment_delivery_remind",
replace_existing=True,
max_instances=1,
coalesce=True,
)
logger.info(
"定时任务调度器已启动:basic_experiment_delivery_remind @ 10:00 daily"
)
def shutdown_scheduler():
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+93 -86
View File
@@ -1,11 +1,11 @@
# Graph Report - app (2026-10-22)
# Graph Report - app (2026-10-29)
## Corpus Check
- 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.
- Large corpus: 320 files · ~394,483 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
## Summary
- 6406 nodes · 35474 edges · 43 communities detected
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 24253 edges (avg confidence: 0.6)
- 6609 nodes · 36569 edges · 44 communities detected
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 25020 edges (avg confidence: 0.6)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
@@ -52,18 +52,19 @@
- [[_COMMUNITY_Community 42|Community 42]]
- [[_COMMUNITY_Community 43|Community 43]]
- [[_COMMUNITY_Community 44|Community 44]]
- [[_COMMUNITY_Community 47|Community 47]]
## God Nodes (most connected - your core abstractions)
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
1. `success_response()` - 842 edges
2. `SysUser` - 756 edges
3. `error_response()` - 710 edges
4. `BizQcTaskResult` - 396 edges
5. `BizProductionMaterial` - 379 edges
6. `BizProductionMaterialUsage` - 371 edges
7. `BizQcMaterialUsage` - 369 edges
8. `BizQcMaterial` - 359 edges
9. `BizProductionMaterialStockBatch` - 358 edges
10. `BizMaterialUsage` - 355 edges
## Surprising Connections (you probably didn't know these)
- `统一记录 API 请求/响应出入参的中间件` --uses--> `SysApiRequestLog` [INFERRED]
@@ -81,132 +82,132 @@
### Community 0 - "Community 0"
Cohesion: 0.01
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)
Nodes (1175): _assert_user_tenant_access(), attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu() (+1167 more)
### Community 1 - "Community 1"
Cohesion: 0.01
Nodes (830): validate_ymd(), BaseSettings, Config, secret_key_must_be_strong(), Settings, generate_uuid(), ApiRequestLogCRUD, delete() (+822 more)
Cohesion: 0.0
Nodes (901): BaseSettings, Config, secret_key_must_be_strong(), Settings, generate_uuid(), allocate_next_biz_no(), _allocate_sqlite_sequence(), _format_biz_no() (+893 more)
### Community 2 - "Community 2"
Cohesion: 0.01
Nodes (730): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+722 more)
Nodes (735): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+727 more)
### Community 3 - "Community 3"
Cohesion: 0.04
Nodes (754): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+746 more)
Cohesion: 0.07
Nodes (479): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+471 more)
### Community 4 - "Community 4"
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)
Cohesion: 0.04
Nodes (427): AuditCreateBody, BatchDetailIdsBody, BatchRefundFinanceSubmitBody, FinanceConfirmBody, FullmoonHandleBody, FullmoonTodoInsuranceBody, InvoiceRequestCompleteBody, InvoiceRequestCreateBody (+419 more)
### Community 5 - "Community 5"
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)
Nodes (365): _normalize_date_bound(), assert_protocol_sensitive_fields(), create_collection_protocol(), _persist_protocol_sub_resources(), _resolve_consent_form_urls(), _resolve_health_survey_form_urls(), _resolve_protocol_attachments(), update_collection_protocol() (+357 more)
### Community 6 - "Community 6"
Cohesion: 0.02
Nodes (285): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+277 more)
Cohesion: 0.01
Nodes (328): _body_to_payload(), _get_current_user_id(), get_production_reception_by_sample_code(), get_production_reception_detail(), _get_tenant_no(), post_production_reception(), put_production_reception(), _resolve_reception_id() (+320 more)
### Community 7 - "Community 7"
Cohesion: 0.02
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)
Nodes (312): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), create_group() (+304 more)
### Community 8 - "Community 8"
Cohesion: 0.02
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)
Cohesion: 0.01
Nodes (256): list_ethnics_for_select(), get_designer_data(), apply_data_scope(), _get_child_dept_ids(), _resolve_user_role_scopes(), delete(), get_multi_with_filters(), count_pending_protocol_invoice_requests() (+248 more)
### Community 9 - "Community 9"
Cohesion: 0.03
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)
Cohesion: 0.02
Nodes (188): _register_attachment_routes(), _register_freight_routes(), _register_payment_routes(), _register_presign_routes(), _register_protocol_routes(), mp_bind_status(), 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。 (+180 more)
### Community 10 - "Community 10"
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)
Cohesion: 0.02
Nodes (173): _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() (+165 more)
### Community 11 - "Community 11"
Cohesion: 0.03
Nodes (140): _save_sample_payments(), deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。 (+132 more)
### Community 12 - "Community 12"
Cohesion: 0.04
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.06
Nodes (103): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+95 more)
### Community 13 - "Community 13"
Cohesion: 0.05
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)
Cohesion: 0.06
Nodes (105): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+97 more)
### Community 14 - "Community 14"
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)
Cohesion: 0.05
Nodes (58): _error_json(), lifespan(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware, ApiCryptoError (+50 more)
### Community 15 - "Community 15"
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)
Nodes (52): 验证密码(哈希非 bcrypt 或损坏时返回 False,避免抛错), verify_password(), 按行同步子表。 带 ID 的行原地修改,没有 ID 时按业务键对上尚未占用的原行,其余才新增。 本次提交里没有的活动行才软删除。不要整表软删后再插入,否则外键, 按关联 id 更新关系行。重新加回已删除的关联时恢复原行,避免唯一索引冲突。, _sort_key(), sync_link_rows(), _bound_user_names_by_post_ids(), create_post() (+44 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 17 - "Community 17"
Cohesion: 0.1
Nodes (38): get_detail(), _load_sample_context(), _row_to_dict(), upsert(), _with_prefill(), dumps_payload(), empty_detail(), loads_payload() (+30 more)
### Community 18 - "Community 18"
Cohesion: 0.11
Nodes (35): batch_create_sterilize_logs(), batch_create_wash_logs(), batch_discard_garments(), batch_soft_delete_garments(), _count_map(), create_garment(), create_sterilize_log(), create_wash_log() (+27 more)
### Community 19 - "Community 19"
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 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 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)
Cohesion: 0.13
Nodes (23): batch_create_checkups(), _checkup_project_fee(), _checkup_project_name(), create_checkup(), delete_actual_item(), delete_reserve_item(), get_checkup_by_id(), list_actual_items() (+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)
Cohesion: 0.19
Nodes (21): apply_overtime_kit_hours(), _build_rest_day_segments(), _build_workday_windows(), calc_overtime_hours(), _day_at_minutes(), _day_start_ms(), _extract_rules(), _format_ymd() (+13 more)
### 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)
Cohesion: 0.17
Nodes (12): BusinessException, InvalidStatusTransitionException, log_delivery_operation(), log_operation(), log_order_operation(), log_settlement_operation(), log_user_operation(), OrderLockedException (+4 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)
Cohesion: 0.2
Nodes (19): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+11 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)
Nodes (18): _field_label(), _is_non_empty_str(), _is_required(), _is_valid_receive_account_item(), normalize_account_fields(), _pick(), _pick_order_num(), _pick_str() (+10 more)
### Community 25 - "Community 25"
Cohesion: 0.25
Nodes (15): create_protocol_refund_record(), delete_protocol_refund_record(), _get_current_user_id(), _get_tenant_no(), list_protocol_refund_records(), _protocol_accessible(), update_protocol_refund_record(), create_refund_record() (+7 more)
### Community 26 - "Community 26"
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 27 - "Community 27"
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 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 27 - "Community 27"
### Community 28 - "Community 28"
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 28 - "Community 28"
### Community 29 - "Community 29"
Cohesion: 0.29
Nodes (2): BaseModel, SoftDeleteModel
### Community 29 - "Community 29"
### Community 30 - "Community 30"
Cohesion: 0.38
Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。 约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断; 若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
### Community 30 - "Community 30"
### Community 31 - "Community 31"
Cohesion: 0.4
Nodes (5): batch_matches_stock_status(), normalize_batch_stock_status(), 质检物料入库批次:按剩余数量过滤的语义辅助。, 纯函数判定(供单测);list_batches 用 SQL 等价条件。, 返回 'available' | 'exhausted';全部 / 空 / 非法 → None(不加剩余数量条件)。
### Community 31 - "Community 31"
Cohesion: 0.6
Nodes (5): _dec(), _fmt_dt(), list_stock_logs(), _row_to_dict(), _user_nick_name_map()
### Community 32 - "Community 32"
Cohesion: 0.5
Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
@@ -251,10 +252,14 @@ Nodes (1): 发送退款结果通知 Args: openid:
Cohesion: 1.0
Nodes (1): 发送催单通知给配送员 Args: deliver_openid: 配送员openid
### Community 47 - "Community 47"
Cohesion: 1.0
Nodes (1): 接受 YYYYMMDD / YYYY-MM-DD,统一规范为 CHAR(8) YYYYMMDD。
## Knowledge Gaps
- **208 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `严格判断当前租户用户是否拥有指定移动市场权限码。`, `为移动市场 API 提供租户化、精确匹配的菜单权限依赖。` (+203 more)
- **213 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `严格判断当前租户用户是否拥有指定移动市场权限码。`, `为移动市场 API 提供租户化、精确匹配的菜单权限依赖。` (+208 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__()`
- **Thin community `Community 29`** (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.
@@ -278,21 +283,23 @@ Nodes (1): 发送催单通知给配送员 Args: del
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **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.
- **Thin community `Community 47`** (1 nodes): `接受 YYYYMMDD / YYYY-MM-DD,统一规范为 CHAR(8) YYYYMMDD。`
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 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._
- **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 15`, `Community 27`, `Community 28`?**
_High betweenness centrality (0.118) - this node is a cross-community bridge._
- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 12`, `Community 13`, `Community 25`?**
_High betweenness centrality (0.050) - this node is a cross-community bridge._
- **Why does `OaEngineError` connect `Community 7` to `Community 0`, `Community 1`, `Community 2`?**
_High betweenness centrality (0.035) - this node is a cross-community bridge._
- **Are the 1209 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
_`str` has 1209 INFERRED edges - model-reasoned connections that need verification._
- **Are the 841 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
_`success_response()` has 841 INFERRED edges - model-reasoned connections that need verification._
- **Are the 754 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 754 INFERRED edges - model-reasoned connections that need verification._
- **Are the 708 inferred relationships involving `ValueError` (e.g. with `secret_key_must_be_strong()` and `assert_protocol_status_transition()`) actually correct?**
_`ValueError` has 708 INFERRED edges - model-reasoned connections that need verification._
+57041 -42074
View File
File diff suppressed because it is too large. Load diff
+77 -69
View File
@@ -1,12 +1,12 @@
{
"/Users/wuzilong/work/00code/zkxw/backend/app/__init__.py": 1778578257.989357,
"/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/main.py": 1792634982.6186624,
"/Users/wuzilong/work/00code/zkxw/backend/app/tasks/__init__.py": 1792649509.101045,
"/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,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/config.py": 1793153767.2755127,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/wechat_pay.py": 1778578258.0461762,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/api_request_log_middleware.py": 1779882001.753267,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/avatar_url.py": 1780540753.5481932,
@@ -20,7 +20,7 @@
"/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": 1792196253.8018239,
"/Users/wuzilong/work/00code/zkxw/backend/app/core/protocol_status_display.py": 1792646839.6074612,
"/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,
@@ -34,7 +34,7 @@
"/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": 1792576366.7658954,
"/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1792722215.456125,
"/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,
@@ -44,7 +44,7 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/models/clinical_payment.py": 1779834956.9588954,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/storage_release_review.py": 1787110411.5106792,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/qc_task.py": 1779597960.4337523,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/process_route.py": 1787621857.4239202,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/process_route.py": 1793096085.6087615,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/auth.py": 1780539434.444314,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/delivery.py": 1778578258.0949655,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/outbound_release_review.py": 1779968230.0408957,
@@ -57,14 +57,14 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_parameter_value.py": 1778578258.0980055,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_review_media.py": 1778578258.0986822,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_parameter.py": 1778578258.0977957,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/production_task.py": 1787712460.1154053,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/production_task.py": 1793096082.4957078,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_detail_media.py": 1778578258.0973988,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_banner.py": 1778578258.097102,
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/region.py": 1778578258.099429,
"/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/market_mobile.py": 1793155336.902657,
"/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,
@@ -84,19 +84,19 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_release_review.py": 1787110718.4798927,
"/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/process_route.py": 1793095936.5547593,
"/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,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_stock_check.py": 1781423988.9720724,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_code.py": 1793065268.5905912,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task_result.py": 1793097690.7125766,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_review.py": 1792646414.7740386,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_stock_check.py": 1793098731.3404455,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/material_stock_batch.py": 1782894693.7810533,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/post.py": 1785911428.7739174,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_result.py": 1779255909.8554008,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/post.py": 1793097928.8808393,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_result.py": 1793097696.0818002,
"/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": 1791693092.544366,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_work_order.py": 1789366011.0738122,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1793097925.7885866,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_work_order.py": 1792646376.0415618,
"/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,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_occupancy.py": 1785237078.9580858,
@@ -110,83 +110,86 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/material_usage.py": 1782894693.7808309,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_usage.py": 1781513732.7796721,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sop_document.py": 1779265721.9928973,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task.py": 1788158255.5391397,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task.py": 1793096185.7513871,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/inbound_sample_special.py": 1782890475.769446,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_third_party_item.py": 1779265661.482352,
"/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": 1792199580.9285016,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1793097712.881502,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/cleanroom_garment.py": 1782264452.4901433,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/child_row_sync.py": 1793097946.2135875,
"/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/qc_temporary_production_cluster_sort.py": 1792636895.7513638,
"/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/presign_order.py": 1793095962.5634658,
"/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": 1792377880.3840506,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_confirm.py": 1779828818.7646794,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_confirm.py": 1793065268.0816917,
"/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,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_schedule_mgmt.py": 1793098096.204029,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/clean_room.py": 1781403189.1288755,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/__init__.py": 1778605691.3816392,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_stock_check.py": 1781427225.7574296,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_stock_check.py": 1793098717.924901,
"/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/pilot_application.py": 1792646362.7074053,
"/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,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/role.py": 1785912868.6779847,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/role.py": 1793097935.9497454,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_stock_batch.py": 1787017176.7612765,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/material_stock_check.py": 1781427194.5434632,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task_step_assignee.py": 1785909952.2598927,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/material_stock_check.py": 1793098703.7762063,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task_step_assignee.py": 1793065267.4658027,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_sms_notification.py": 1781590614.9654794,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_product_type.py": 1785145511.4485562,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task_assignee.py": 1781233180.3766735,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/research_donation_sample_collection.py": 1791187401.410944,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_amount_auth.py": 1785905319.9222975,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/oa_approval.py": 1786414220.5298004,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/cell_product.py": 1785289892.8170507,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/cell_product.py": 1792638856.8515265,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_refund_record.py": 1790996674.1676536,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/menu.py": 1779265729.0381103,
"/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/pilot_delivery.py": 1793098096.2724218,
"/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": 1792199678.6192756,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/research_donation.py": 1793097746.9649897,
"/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": 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/sample_freight.py": 1793097752.573005,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_formula.py": 1793095965.716362,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_checkup.py": 1793097846.6982708,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/env_detect_item.py": 1781145689.9525824,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/process_category.py": 1779265700.0606413,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/item_unit.py": 1779265653.7504313,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_work_order.py": 1787193578.7385066,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_work_order.py": 1793094741.0946975,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_handover_inventory.py": 1781598316.476379,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/env_detect_record.py": 1786945487.5517714,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task_set.py": 1788233133.196318,
"/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/production_task_set.py": 1793097810.1320748,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_material.py": 1793097901.0378923,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/process.py": 1793095920.843898,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_service.py": 1793097887.8013425,
"/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/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": 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,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception.py": 1793097696.6803784,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/consumable_pack.py": 1793097793.9003072,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_inspection_batch.py": 1793065284.8892066,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_prep.py": 1793097985.9659176,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material.py": 1787114725.8477683,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol_no.py": 1779882001.753651,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_storage_cert_qc.py": 1791520567.195576,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/user_display.py": 1793065264.1457374,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol_no.py": 1793065269.7817523,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_storage_cert_qc.py": 1793009250.2649956,
"/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/storage_facility.py": 1792638802.7313325,
"/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,
@@ -197,8 +200,8 @@
"/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/protocol_sample_payment.py": 1793097761.4878137,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_item_param.py": 1793097679.9535193,
"/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,
@@ -206,54 +209,56 @@
"/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": 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/material.py": 1793098686.1687553,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/production_material.py": 1793098686.8677888,
"/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/qc.py": 1793098687.6486495,
"/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": 1792199682.804543,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/collection_protocol.py": 1793065269.7831242,
"/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": 1792079421.3688679,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/presign_order.py": 1793001629.236552,
"/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": 1791860598.0884578,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/__init__.py": 1792634972.2299473,
"/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/pilot_application.py": 1792653386.9872339,
"/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": 1792579495.6813116,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/oa_approval.py": 1792722294.1293957,
"/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": 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/sample_cert_insurance.py": 1793001635.2987237,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/admin.py": 1793240569.7858844,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/env_detect.py": 1786945487.513935,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1792199703.3429806,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1793001640.0574386,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/ethnic.py": 1779266243.9511197,
"/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/pilot_service.py": 1792641868.3677006,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_code_correction.py": 1792635822.5685863,
"/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": 1791912978.5283322,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_storage_cert_qc.py": 1792662212.518068,
"/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/production.py": 1793096091.2640226,
"/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/protocol_sample_payment.py": 1793001632.8994496,
"/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": 1792580527.7126133,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1793065275.172498,
"/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_info_list_enrich.py": 1793065267.798019,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_period_summary.py": 1791799261.7547023,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_collect_wx_notify.py": 1793153978.1273012,
"/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,
@@ -264,6 +269,7 @@
"/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/sample_storage_cert_qc_prefill.py": 1793091264.6031697,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_publish.py": 1786001976.196836,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/cn_holiday_sync.py": 1786412469.104896,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_engine.py": 1790217176.6912422,
@@ -279,7 +285,7 @@
"/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/wechat_mp_message.py": 1793153773.5584576,
"/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": 1791799767.0946853,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/payment_chase.py": 1791903365.3346775,
@@ -287,25 +293,27 @@
"/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": 1792134136.443889,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_instance_comment.py": 1792722268.265355,
"/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_protocol.py": 1793154276.9704335,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_field_scope.py": 1791651783.1531875,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_code_correction.py": 1792634957.0385876,
"/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": 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/sample_storage_cert_qc.py": 1792722939.9336097,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_pay_mode_switch.py": 1791922115.8003666,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/basic_experiment_delivery_remind.py": 1792649506.2005453,
"/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_wechat_notify.py": 1793065275.6481574,
"/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,
@@ -0,0 +1,273 @@
"""基础实验交付日前一天服务号提醒单测。"""
from datetime import date, datetime
from unittest.mock import MagicMock, patch
from app.services.basic_experiment_delivery_remind import (
format_delivery_product_name,
run_delivery_remind_for_date,
)
def test_format_delivery_product_name_full():
assert (
format_delivery_product_name(
project_name="NK扩增",
spec_text="1e8",
package_text="冻存管",
)
== "NK扩增(1e8、冻存管)"
)
def test_format_delivery_product_name_name_and_spec_only():
assert (
format_delivery_product_name(
project_name="NK扩增",
spec_text="1e8",
package_text="",
)
== "NK扩增(1e8)"
)
def test_format_delivery_product_name_name_and_package_only():
assert (
format_delivery_product_name(
project_name="NK扩增",
spec_text="",
package_text="冻存管",
)
== "NK扩增(冻存管)"
)
def test_format_delivery_product_name_name_only():
assert (
format_delivery_product_name(
project_name="NK扩增",
spec_text="",
package_text="",
)
== "NK扩增"
)
def test_format_delivery_product_name_empty():
assert (
format_delivery_product_name(
project_name="",
spec_text="",
package_text="",
)
== ""
)
def test_send_basic_experiment_delivery_remind_builds_data():
with patch(
"app.services.wechat_mp_message._send_template"
) as send_tpl, patch("app.services.wechat_mp_message.settings") as st:
st.WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY = "tpl-be"
st.WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER = "thing6"
st.WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT = "thing10"
st.WX_MP_TPL_BE_DELIVERY_FIELD_QTY = "character_string8"
st.WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT = "thing7"
st.WX_MP_TPL_BE_DELIVERY_FIELD_TIME = "time14"
send_tpl.return_value = {"success": True, "msgid": "1"}
from app.services.wechat_mp_message import send_basic_experiment_delivery_remind
long_name = "这是一个超过二十个汉字的产品名称规格包装拼接"
send_basic_experiment_delivery_remind(
mp_openid="oid",
customer_name="张三客户",
product_name=long_name,
quantity="3",
contact_name="李联系人",
time_text="2026-10-23 14:00",
)
assert send_tpl.called
kwargs = send_tpl.call_args.kwargs
assert kwargs["template_id"] == "tpl-be"
assert kwargs["mp_openid"] == "oid"
data = kwargs["data"]
assert data["thing6"]["value"] == "张三客户"
assert len(data["thing10"]["value"]) <= 20
assert data["character_string8"]["value"] == "3"
assert data["thing7"]["value"] == "李联系人"
assert data["time14"]["value"] == "2026-10-23 14:00"
def _make_db_for_remind(
*,
schedules,
already_success=False,
user_openid="mp-oid",
):
"""构造轻量 MagicMock Session,覆盖 remind 扫描路径。"""
db = MagicMock()
candidate_q = MagicMock()
candidate_q.join.return_value = candidate_q
candidate_q.outerjoin.return_value = candidate_q
candidate_q.filter.return_value = candidate_q
candidate_q.order_by.return_value = candidate_q
candidate_q.all.return_value = schedules
success_q = MagicMock()
success_q.filter.return_value = success_q
success_q.first.return_value = MagicMock() if already_success else None
user = MagicMock()
user.wx_mp_openid = user_openid
user_q = MagicMock()
user_q.filter.return_value = user_q
user_q.first.return_value = user
def query_side_effect(*models):
from app.models.models import BizWxNotifyLog, SysUser
if len(models) > 1:
return candidate_q
model = models[0]
if model is BizWxNotifyLog:
return success_q
if model is SysUser:
return user_q
return candidate_q
db.query.side_effect = query_side_effect
return db
def test_run_delivery_remind_sends_for_tomorrow_schedule():
sched = MagicMock()
sched.id = "sch-1"
sched.tenant_no = "zkxw"
sched.infusion_time = datetime(2026, 10, 23, 14, 0, 0)
sched.spec_text = "1e8"
sched.package_text = "冻存管"
sched.schedule_status = "dsc"
app = MagicMock()
app.id = "app-1"
app.tenant_no = "zkxw"
app.create_by = "user-1"
app.customer_name = "客户甲"
app.customer_contact = "联系人乙"
prod = MagicMock()
prod.project_name = "NK扩增"
prod.spec_text = "1e8"
prod.package_text = "冻存管"
prod.quantity = 2
db = _make_db_for_remind(schedules=[(sched, app, prod)])
with patch(
"app.services.basic_experiment_delivery_remind._notify_enabled",
return_value=True,
), patch(
"app.services.wechat_mp_message.send_basic_experiment_delivery_remind"
) as send_fn:
send_fn.return_value = {"success": True, "msgid": "m1"}
stats = run_delivery_remind_for_date(db, today=date(2026, 10, 22))
assert stats["candidates"] == 1
assert stats["sent"] == 1
assert send_fn.called
kwargs = send_fn.call_args.kwargs
assert kwargs["customer_name"] == "客户甲"
assert kwargs["product_name"] == "NK扩增(1e8、冻存管)"
assert kwargs["quantity"] == "2"
assert kwargs["contact_name"] == "联系人乙"
assert kwargs["time_text"] == "2026-10-23 14:00"
assert db.add.called
assert db.commit.called
def test_run_delivery_remind_skips_when_already_success():
sched = MagicMock()
sched.id = "sch-1"
sched.tenant_no = "zkxw"
sched.infusion_time = datetime(2026, 10, 23, 14, 0, 0)
sched.spec_text = ""
sched.package_text = ""
app = MagicMock()
app.id = "app-1"
app.tenant_no = "zkxw"
app.create_by = "user-1"
app.customer_name = "客户甲"
app.customer_contact = "联系人乙"
prod = MagicMock()
prod.project_name = "NK"
prod.spec_text = ""
prod.package_text = ""
prod.quantity = 1
db = _make_db_for_remind(
schedules=[(sched, app, prod)], already_success=True
)
with patch(
"app.services.basic_experiment_delivery_remind._notify_enabled",
return_value=True,
), patch(
"app.services.wechat_mp_message.send_basic_experiment_delivery_remind"
) as send_fn:
send_fn.return_value = {"success": True}
stats = run_delivery_remind_for_date(db, today=date(2026, 10, 22))
assert stats["skipped"] == 1
assert stats["sent"] == 0
assert not send_fn.called
def test_run_delivery_remind_skips_without_openid():
sched = MagicMock()
sched.id = "sch-1"
sched.tenant_no = "zkxw"
sched.infusion_time = datetime(2026, 10, 23, 9, 0, 0)
sched.spec_text = ""
sched.package_text = ""
app = MagicMock()
app.id = "app-1"
app.tenant_no = "zkxw"
app.create_by = "user-1"
app.customer_name = "客户甲"
app.customer_contact = "联系人乙"
prod = MagicMock()
prod.project_name = "NK"
prod.spec_text = ""
prod.package_text = ""
prod.quantity = 1
db = _make_db_for_remind(
schedules=[(sched, app, prod)], user_openid=""
)
with patch(
"app.services.basic_experiment_delivery_remind._notify_enabled",
return_value=True,
), patch(
"app.services.wechat_mp_message.send_basic_experiment_delivery_remind"
) as send_fn:
stats = run_delivery_remind_for_date(db, today=date(2026, 10, 22))
assert stats["skipped"] == 1
assert not send_fn.called
def test_run_delivery_remind_noop_when_disabled():
db = MagicMock()
with patch(
"app.services.basic_experiment_delivery_remind._notify_enabled",
return_value=False,
):
stats = run_delivery_remind_for_date(db, today=date(2026, 10, 22))
assert stats == {"candidates": 0, "sent": 0, "skipped": 0, "failed": 0}
assert not db.query.called
@@ -0,0 +1,51 @@
"""细胞产品列表 keyword:支持储物格路径匹配。"""
from unittest.mock import MagicMock, patch
from app.crud.storage_facility import search_slot_ids_by_keyword
def test_search_slot_ids_by_keyword_blank_returns_empty():
assert search_slot_ids_by_keyword(MagicMock(), "zkxw", "") == []
assert search_slot_ids_by_keyword(MagicMock(), "zkxw", " ") == []
@patch(
"app.crud.storage_facility._search_slot_select_matched_ids",
return_value=["slot-1", "slot-2"],
)
def test_search_slot_ids_by_keyword_delegates(mock_search):
db = MagicMock()
q = MagicMock()
db.query.return_value = q
q.filter.return_value = q
assert search_slot_ids_by_keyword(db, "zkxw", "2-3-7-15") == ["slot-1", "slot-2"]
mock_search.assert_called_once()
assert mock_search.call_args[0][2] == "zkxw"
assert mock_search.call_args[0][3] == "2-3-7-15"
@patch("app.crud.cell_product.search_slot_ids_by_keyword", return_value=["slot-a"])
@patch("app.crud.cell_product._enrich_product_dicts", side_effect=lambda db, tenant, items: items)
@patch("app.crud.cell_product._row_to_dict", side_effect=lambda x: {"id": x.id})
def test_list_cell_products_keyword_ors_matched_slot_ids(
_row_to_dict, _enrich, mock_slots
):
from app.crud.cell_product import list_cell_products
db = MagicMock()
q = MagicMock()
db.query.return_value = q
q.filter.return_value = q
q.order_by.return_value = q
q.with_entities.return_value = q
q.scalar.return_value = 0
q.offset.return_value = q
q.limit.return_value = q
q.all.return_value = []
list_cell_products(db, tenant_no="zkxw", page=1, page_size=20, keyword="2-3-7")
mock_slots.assert_called_once_with(db, "zkxw", "2-3-7")
# filter 至少调用两次:基础 del/tenant + keyword or_
assert q.filter.call_count >= 2
+208
View File
@@ -0,0 +1,208 @@
"""子表按行同步:更新原行、新增缺行、只软删去掉的行。"""
import unittest
from datetime import datetime
from sqlalchemy import Column, DateTime, Integer, SmallInteger, String, create_engine
from sqlalchemy.orm import sessionmaker
from app.core.database import Base
from app.crud.child_row_sync import sync_child_rows
class DemoChild(Base):
__tablename__ = "demo_child_row"
id = Column(String(64), primary_key=True)
parent_id = Column(String(64), nullable=False)
tenant_no = Column(String(64), nullable=False)
item_code = Column(String(64))
item_name = Column(String(128))
order_num = Column(Integer)
create_time = Column(DateTime)
update_by = Column(String(64))
update_time = Column(DateTime)
del_flag = Column(SmallInteger, nullable=False, default=0)
def _session():
engine = create_engine("sqlite://")
DemoChild.__table__.create(bind=engine)
return sessionmaker(bind=engine)()
def _row(**kwargs):
now = datetime(2026, 10, 27, 9, 0, 0)
data = dict(
parent_id="parent-1",
tenant_no="tenant-a",
item_code="R-1",
item_name="试剂",
order_num=0,
create_time=now,
update_time=now,
del_flag=0,
)
data.update(kwargs)
return DemoChild(**data)
def _sync(db, items):
now = datetime(2026, 10, 27, 10, 0, 0)
def build_row(row_id):
return DemoChild(
id=row_id,
parent_id="parent-1",
tenant_no="tenant-a",
create_time=now,
)
def apply_row(row, item, idx, _now):
row.item_code = item.get("item_code")
row.item_name = item.get("item_name")
row.order_num = idx
return sync_child_rows(
db,
DemoChild,
parent_attr="parent_id",
parent_id="parent-1",
tenant_no="tenant-a",
items=items,
user_id="user-1",
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("item_code") or "").strip(),
row_natural_key=lambda row: (row.item_code or "").strip(),
)
class TestChildRowSync(unittest.TestCase):
def test_id_keeps_row_when_fields_change(self):
db = _session()
db.add(_row(id="row-keep", item_name="旧名称"))
db.commit()
saved = _sync(db, [{"id": "row-keep", "item_code": "R-2", "item_name": "新名称"}])
self.assertEqual(saved[0].id, "row-keep")
self.assertEqual(saved[0].item_name, "新名称")
self.assertEqual(db.query(DemoChild).filter_by(del_flag=0).count(), 1)
def test_natural_key_keeps_id_when_payload_omits_id(self):
db = _session()
db.add(_row(id="row-keep", item_code="R-1"))
db.commit()
saved = _sync(db, [{"item_code": "R-1", "item_name": "改名"}])
self.assertEqual([row.id for row in saved], ["row-keep"])
self.assertEqual(saved[0].item_name, "改名")
def test_insert_update_and_soft_delete_removed(self):
db = _session()
db.add(_row(id="row-keep", item_code="R-1"))
db.add(_row(id="row-drop", item_code="R-9", item_name="去掉"))
db.commit()
saved = _sync(
db,
[
{"id": "row-keep", "item_code": "R-1", "item_name": "留下"},
{"item_code": "R-NEW", "item_name": "新增"},
],
)
self.assertEqual(saved[0].id, "row-keep")
self.assertNotEqual(saved[1].id, "row-drop")
rows = {row.id: row for row in db.query(DemoChild).all()}
self.assertEqual(rows["row-keep"].del_flag, 0)
self.assertEqual(rows["row-drop"].del_flag, 1)
self.assertEqual(rows[saved[1].id].del_flag, 0)
def test_id_from_another_parent_is_rejected(self):
db = _session()
db.add(_row(id="row-other", parent_id="parent-2"))
db.commit()
with self.assertRaises(ValueError):
_sync(db, [{"id": "row-other", "item_code": "R-1", "item_name": "占用"}])
other = db.query(DemoChild).filter_by(id="row-other").one()
self.assertEqual(other.parent_id, "parent-2")
self.assertEqual(other.del_flag, 0)
def test_readded_business_key_restores_deleted_row(self):
db = _session()
db.add(_row(id="row-old", item_code="R-1", del_flag=1))
db.commit()
now = datetime(2026, 10, 27, 10, 0, 0)
def build_row(row_id):
return DemoChild(
id=row_id,
parent_id="parent-1",
tenant_no="tenant-a",
create_time=now,
)
def apply_row(row, item, idx, _now):
row.item_code = item.get("item_code")
row.item_name = item.get("item_name")
row.order_num = idx
saved = sync_child_rows(
db,
DemoChild,
parent_attr="parent_id",
parent_id="parent-1",
tenant_no="tenant-a",
items=[{"item_code": "R-1", "item_name": "恢复"}],
user_id="user-1",
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda item: (item.get("item_code") or "").strip(),
row_natural_key=lambda row: (row.item_code or "").strip(),
reuse_deleted=True,
)
db.commit()
self.assertEqual(saved[0].id, "row-old")
self.assertEqual(saved[0].del_flag, 0)
self.assertEqual(db.query(DemoChild).count(), 1)
def test_removing_live_row_clears_older_tombstone(self):
db = _session()
db.add(_row(id="row-live", item_code="R-1"))
db.add(_row(id="row-tomb", item_code="R-1", del_flag=1))
db.commit()
now = datetime(2026, 10, 27, 10, 0, 0)
sync_child_rows(
db,
DemoChild,
parent_attr="parent_id",
parent_id="parent-1",
tenant_no="tenant-a",
items=[],
user_id="user-1",
now=now,
build_row=lambda row_id: DemoChild(
id=row_id, parent_id="parent-1", tenant_no="tenant-a", create_time=now
),
apply_fields=lambda row, item, idx, _now: None,
natural_key=lambda item: (item.get("item_code") or "").strip(),
row_natural_key=lambda row: (row.item_code or "").strip(),
reuse_deleted=True,
)
db.commit()
self.assertIsNone(db.query(DemoChild).filter_by(id="row-tomb").first())
live = db.query(DemoChild).filter_by(id="row-live").one()
self.assertEqual(live.del_flag, 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,68 @@
"""离职(软删除)后,历史单据仍应展示人员姓名,而不是人员 id。"""
from __future__ import annotations
import unittest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.crud.production_task import _enrich_assignee_names
from app.crud.production_task_step_assignee import enrich_step_completed_by_names
from app.crud.qc_inspection_batch import _assignee_names
from app.models.base import Base
from app.models.models import SysUser
def _user(user_id: str, nick_name: str, *, deleted: bool = False) -> SysUser:
now = datetime(2026, 7, 1, 10, 0, 0)
return SysUser(
id=user_id,
user_no="u-" + user_id[:6],
nick_name=nick_name,
email="",
phone_no="",
password="",
status="0" if deleted else "1",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=1 if deleted else 0,
)
class TestDepartedUserDisplayName(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine, tables=[SysUser.__table__])
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
self.db = Session()
self.db.add_all(
[
_user("GONEID0001", "张倩文", deleted=True),
_user("STAYID0001", "侯以婷"),
]
)
self.db.commit()
def tearDown(self):
self.db.close()
def test_qc_batch_assignee_names_keep_departed_nick(self):
text = _assignee_names(self.db, ["GONEID0001", "STAYID0001"])
self.assertEqual(text, "张倩文、侯以婷")
def test_qc_batch_assignee_unknown_id_stays_id(self):
self.assertEqual(_assignee_names(self.db, ["NO_SUCH_USER"]), "NO_SUCH_USER")
def test_production_current_assignee_keeps_departed_nick(self):
items = [{"currentAssigneeUserId": "GONEID0001"}]
_enrich_assignee_names(self.db, items)
self.assertEqual(items[0]["currentAssigneeUserName"], "张倩文")
def test_step_completed_by_keeps_departed_nick(self):
steps = [{"completedBy": "GONEID0001"}]
enrich_step_completed_by_names(self.db, steps)
self.assertIn("张倩文", steps[0]["completedByName"])
self.assertNotIn("GONEID0001", steps[0]["completedByName"])
+227 -3
View File
@@ -578,6 +578,64 @@ def test_specialist_cell_create_forces_current_collection_and_collector_users():
assert sample.sample_code == 'S-001'
def test_mobile_protocol_status_follows_web_options_and_transitions():
from app.services.market_protocol import create_protocol, update_protocol
db = _session()
_masters(db)
created = create_protocol(
db,
body=_body(protocolStatus='dsx'),
ctx=_ctx('specialist'),
user_info=_user(),
)
protocol_id = created['basic']['id']
assert db.get(BizCollectionProtocol, protocol_id).protocol_status == 'dsx'
updated = update_protocol(
db,
source='cell_storage',
protocol_id=protocol_id,
body=_body(protocolStatus='yzf'),
ctx=_ctx('specialist'),
user_info=_user(),
)
assert updated['basic']['protocolStatus'] == 'yzf'
with pytest.raises(ValueError, match='不可转为'):
update_protocol(
db,
source='cell_storage',
protocol_id=protocol_id,
body=_body(protocolStatus='sxz'),
ctx=_ctx('specialist'),
user_info=_user(),
)
donation = create_protocol(
db,
body=_body(
'research_donation',
donationNo='D-STATUS',
protocolNo=None,
donationStatus='sdfq',
attachments={},
samples=[
{
'sampleTypeId': 'type-a',
'sampleCodeId': 'code-b',
'sampleCode': 'S-002',
'unitId': 'unit-a',
'collectAmount': 10,
}
],
),
ctx=_ctx('specialist'),
user_info=_user(),
)
assert db.get(BizResearchDonation, donation['basic']['id']).donation_status == 'sdfq'
def test_specialist_donation_create_forces_current_users_and_has_no_payment_section():
from app.services.market_protocol import create_protocol
@@ -608,6 +666,115 @@ def test_specialist_donation_create_forces_current_users_and_has_no_payment_sect
assert 'payment' not in result
def test_mobile_create_schedules_sample_collect_wx_notify_for_cell_and_donation():
from app.services.market_protocol import create_protocol
db_cell = _session()
_masters(db_cell)
with patch(
'app.services.market_protocol.schedule_sample_collect_notify'
) as schedule_cell:
cell = create_protocol(
db_cell, body=_body(), ctx=_ctx('specialist'), user_info=_user()
)
cell_kw = schedule_cell.call_args.kwargs
assert cell_kw['tenant_no'] == 'tenant-a'
assert cell_kw['protocol_id'] == cell['basic']['id']
assert cell_kw['protocol_kind'] == 'cell_storage'
assert cell_kw['before_codes'] == []
assert 'S-001' in cell_kw['submitted_codes']
db_don = _session()
_masters(db_don)
with patch(
'app.services.market_protocol.schedule_sample_collect_notify'
) as schedule_don:
donation = create_protocol(
db_don,
body=_body(
'research_donation',
donationNo='D-NOTIFY',
protocolNo=None,
samples=[
{
'sampleTypeId': 'type-a',
'unitId': 'unit-a',
'collectAmount': 10,
}
],
),
ctx=_ctx('specialist'),
user_info=_user(),
)
don_kw = schedule_don.call_args.kwargs
assert don_kw['protocol_id'] == donation['basic']['id']
assert don_kw['protocol_kind'] == 'research_donation'
assert don_kw['before_codes'] == []
assert don_kw['submitted_codes']
def test_mobile_update_schedules_notify_only_when_samples_submitted():
from app.schemas.market_mobile import MarketProtocolBody
from app.services.market_protocol import create_protocol, update_protocol
db = _session()
_masters(db)
with patch(
'app.services.market_protocol.schedule_sample_collect_notify'
):
created = create_protocol(
db, body=_body(), ctx=_ctx('specialist'), user_info=_user()
)
protocol_id = created['basic']['id']
with patch(
'app.services.market_protocol.schedule_sample_collect_notify'
) as schedule:
update_protocol(
db,
source='cell_storage',
protocol_id=protocol_id,
body=MarketProtocolBody.model_validate(
{'source': 'cell_storage', 'remark': '仅改备注'}
),
ctx=_ctx('specialist'),
user_info=_user(),
)
update_protocol(
db,
source='cell_storage',
protocol_id=protocol_id,
body=_body(
samples=[
{
'sampleTypeId': 'type-a',
'sampleCodeId': 'code-a',
'sampleCode': 'S-001',
'unitId': 'unit-a',
'collectAmount': 10,
},
{
'sampleTypeId': 'type-a',
'sampleCodeId': 'code-b',
'sampleCode': 'S-002',
'unitId': 'unit-a',
'collectAmount': 5,
},
]
),
ctx=_ctx('specialist'),
user_info=_user(),
)
assert schedule.call_count == 2
assert schedule.call_args_list[0].kwargs['submitted_codes'] is None
second = schedule.call_args_list[1].kwargs
assert 'S-001' in second['before_codes']
assert set(second['submitted_codes']) >= {'S-001', 'S-002'}
assert second['protocol_kind'] == 'cell_storage'
def test_manager_update_cannot_move_document_out_of_scope_and_rolls_back():
from app.services.market_protocol import update_protocol
@@ -959,6 +1126,57 @@ def test_protocol_body_legacy_payment_accepts_month_end_and_leap_dates():
assert body.payment_details[0].installment_due_date == '20260228'
def test_protocol_body_blank_customer_numbers_are_unset():
from app.schemas.market_mobile import MarketProtocolBody
body = MarketProtocolBody.model_validate(
{
'source': 'cell_storage',
'customer': {
'motherName': '张敏',
'birthOrder': '',
'newbornWeightGram': ' ',
'apgar1min': '',
'apgar5min': ' 9 ',
},
}
)
assert body.customer.birth_order is None
assert body.customer.newborn_weight_gram is None
assert body.customer.apgar_1min is None
assert body.customer.apgar_5min == 9
whole = MarketProtocolBody.model_validate(
{
'source': 'research_donation',
'customer': {'birthOrder': '2.0', 'newbornWeightGram': '3200'},
}
)
assert whole.customer.birth_order == 2
assert whole.customer.newborn_weight_gram == 3200
with pytest.raises(ValidationError, match='新生儿体重须为整数'):
MarketProtocolBody.model_validate(
{
'source': 'cell_storage',
'customer': {'birthOrder': '1', 'newbornWeightGram': '3200.5'},
}
)
def test_protocol_body_normalizes_dashed_register_and_donation_dates():
from app.schemas.market_mobile import MarketProtocolBody
donation = MarketProtocolBody.model_validate(
{'source': 'research_donation', 'donationDate': '2026-06-17'}
)
assert donation.donation_date == '20260617'
cell = MarketProtocolBody.model_validate(
{'source': 'cell_storage', 'registerDate': '2026/06/17'}
)
assert cell.register_date == '20260617'
def test_protocol_payment_models_are_mutually_exclusive_by_stored_version():
from app.schemas.market_mobile import MarketProtocolBody
from app.services.market_protocol import update_protocol
@@ -2930,7 +3148,8 @@ def test_protocol_body_rejects_status_finance_and_conversion_fields():
forbidden = [
{'auditStatus': 'shtg'},
{'protocolStatus': 'yzf'},
{'protocolStatus': 'zckc'},
{'donationStatus': 'zky'},
{'delFlag': 1},
{'refundAmount': 100},
{'financeRemark': '通过'},
@@ -2939,6 +3158,11 @@ def test_protocol_body_rejects_status_finance_and_conversion_fields():
for field in forbidden:
with pytest.raises(ValidationError):
MarketProtocolBody.model_validate({'source': 'cell_storage', **field})
allowed = MarketProtocolBody.model_validate(
{'source': 'cell_storage', 'protocolStatus': 'sxz', 'donationStatus': 'dsx'}
)
assert allowed.protocol_status == 'sxz'
assert allowed.donation_status == 'dsx'
def test_cross_tenant_or_disabled_referenced_user_is_rejected():
@@ -3608,7 +3832,7 @@ def test_cell_protocol_detail_includes_storage_insurance_and_audits():
assert detail['audits'][0]['auditStatus'] == 'shtg'
def test_cell_protocol_detail_hides_cross_tenant_and_deleted_handler_names():
def test_cell_protocol_detail_hides_cross_tenant_handler_and_keeps_deleted_name():
from app.services.market_protocol import get_protocol
db = _session()
@@ -3663,7 +3887,7 @@ def test_cell_protocol_detail_hides_cross_tenant_and_deleted_handler_names():
)
assert detail['storageCert']['handlerUserName'] == ''
assert detail['fullmoonTodo']['handlerUserName'] == ''
assert detail['fullmoonTodo']['handlerUserName'] == '已删除经办人'
def test_cell_protocol_detail_includes_presign_deposit_hint_and_transfers():
+20 -10
View File
@@ -366,19 +366,29 @@ class TestCommentMentionGrant(unittest.TestCase):
self.db.commit()
def test_mention_grants_view_and_is_idempotent(self):
from unittest.mock import patch
from app.models.models import OaInstanceViewGrant
from app.services import oa_instance_comment as svc
row = svc.create_comment(
self.db,
instance_id="i1",
tenant_no="zkxw",
user_id="u1",
dept_id="d1",
content="",
attachment_refs=None,
mention_user_ids=["u2", "u2", "u1"],
)
with patch(
"app.services.oa_wechat_notify.schedule_oa_mention_notifies"
) as schedule:
row = svc.create_comment(
self.db,
instance_id="i1",
tenant_no="zkxw",
user_id="u1",
dept_id="d1",
content="",
attachment_refs=None,
mention_user_ids=["u2", "u2", "u1"],
)
schedule.assert_called_once_with(
instance_id="i1",
commenter_user_id="u1",
mentioned_user_ids=["u2"],
)
self.assertEqual(row["content"], "")
self.assertEqual([m["userId"] for m in row["mentions"]], ["u2"])
self.assertEqual(row["mentions"][0]["userName"], "李四")
+168
View File
@@ -0,0 +1,168 @@
"""OA 提到我的未读计数 / 标记已读 / 再@重置。"""
import unittest
from datetime import datetime
from unittest.mock import patch
class TestMentionUnread(unittest.TestCase):
def setUp(self):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.models import (
OaAppVersion,
OaInstance,
OaInstanceComment,
OaInstanceViewGrant,
SysUser,
)
self.engine = create_engine("sqlite:///:memory:")
for table in (
OaAppVersion.__table__,
OaInstance.__table__,
OaInstanceComment.__table__,
OaInstanceViewGrant.__table__,
SysUser.__table__,
):
table.create(self.engine)
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
self.db = Session()
now = datetime.now()
self.db.add(
OaAppVersion(
id="v1",
app_id="app1",
version_no=1,
status="published",
is_latest_published="1",
form_schema_json="{}",
flow_tree_json="{}",
advanced_settings_json="{}",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
OaInstance(
id="i1",
app_id="app1",
app_version_id="v1",
title="t",
status="running",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json="{}",
tenant_no="zkxw",
create_dept_id="d1",
create_time=now,
update_time=now,
del_flag=0,
current_round=1,
)
)
for uid, name in (("u1", "张三"), ("u2", "李四")):
self.db.add(
SysUser(
id=uid,
tenant_no="zkxw",
user_no=uid,
nick_name=name,
email="",
phone_no="",
password="",
status="1",
create_dept_id="",
del_flag=0,
create_time=now,
update_time=now,
)
)
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_mention_starts_unread_and_count(self):
from app.services import oa_instance_comment as svc
with patch("app.services.oa_wechat_notify.schedule_oa_mention_notifies"):
svc.create_comment(
self.db,
instance_id="i1",
tenant_no="zkxw",
user_id="u1",
dept_id="d1",
content="@你",
attachment_refs=None,
mention_user_ids=["u2"],
)
self.assertEqual(
svc.count_mention_unread(self.db, tenant_no="zkxw", user_id="u2"), 1
)
self.assertEqual(
svc.count_mention_unread(self.db, tenant_no="zkxw", user_id="u1"), 0
)
def test_mark_read_clears_unread(self):
from app.services import oa_instance_comment as svc
with patch("app.services.oa_wechat_notify.schedule_oa_mention_notifies"):
svc.create_comment(
self.db,
instance_id="i1",
tenant_no="zkxw",
user_id="u1",
dept_id="d1",
content="x",
attachment_refs=None,
mention_user_ids=["u2"],
)
out = svc.mark_mention_instance_read(
self.db, tenant_no="zkxw", user_id="u2", instance_id="i1"
)
self.assertTrue(out.get("isRead"))
self.assertEqual(
svc.count_mention_unread(self.db, tenant_no="zkxw", user_id="u2"), 0
)
def test_remention_resets_unread(self):
from app.services import oa_instance_comment as svc
with patch("app.services.oa_wechat_notify.schedule_oa_mention_notifies"):
svc.create_comment(
self.db,
instance_id="i1",
tenant_no="zkxw",
user_id="u1",
dept_id="d1",
content="第一次",
attachment_refs=None,
mention_user_ids=["u2"],
)
svc.mark_mention_instance_read(
self.db, tenant_no="zkxw", user_id="u2", instance_id="i1"
)
self.assertEqual(
svc.count_mention_unread(self.db, tenant_no="zkxw", user_id="u2"), 0
)
svc.create_comment(
self.db,
instance_id="i1",
tenant_no="zkxw",
user_id="u1",
dept_id="d1",
content="再提",
attachment_refs=None,
mention_user_ids=["u2"],
)
self.assertEqual(
svc.count_mention_unread(self.db, tenant_no="zkxw", user_id="u2"), 1
)
if __name__ == "__main__":
unittest.main()
+146
View File
@@ -0,0 +1,146 @@
"""OA 评论 @提到 复用抄送模板推送单测。"""
from unittest.mock import MagicMock, patch
from urllib.parse import parse_qs, urlparse
from app.services.oa_wechat_notify import (
RETURN_MENTIONS,
_build_mention_title,
_send_mention_one,
build_oa_detail_pagepath,
schedule_oa_mention_notifies,
)
def test_return_mentions_path():
assert RETURN_MENTIONS == "/pages/oa/mentions/index"
def test_build_mention_title():
assert _build_mention_title("张三") == "张三 在评论中提到了你"
assert _build_mention_title("") == "有人在评论中提到了你"
def test_mention_pagepath_mode_view_return_mentions():
p = build_oa_detail_pagepath(
instance_id="i1",
task_id="",
mode="view",
return_path=RETURN_MENTIONS,
)
q = parse_qs(urlparse("x://" + p).query)
assert q["mode"] == ["view"]
assert "taskId" not in q
assert q["returnPath"] == [RETURN_MENTIONS]
def test_send_mention_one_uses_cc_template_fields():
db = MagicMock()
mentioned = MagicMock()
mentioned.wx_mp_openid = "mp-m1"
mentioned.nick_name = "李四"
commenter = MagicMock()
commenter.nick_name = "张三"
inst = MagicMock()
inst.id = "i1"
inst.app_id = "app1"
inst.title = "请假"
inst.del_flag = 0
app = MagicMock()
app.app_name = "请假申请"
user_queue = [mentioned, commenter]
def query_side_effect(model):
q = MagicMock()
name = getattr(model, "__name__", "") or str(model)
if "OaInstance" in name:
q.filter.return_value.first.return_value = inst
elif "OaApp" in name:
q.filter.return_value.first.return_value = app
elif "SysUser" in name:
q.filter.return_value.first.side_effect = lambda: user_queue.pop(0)
else:
q.filter.return_value.first.return_value = None
return q
db.query.side_effect = query_side_effect
with patch("app.services.oa_wechat_notify.send_oa_cc_notify") as send:
_send_mention_one(
db,
instance_id="i1",
mentioned_user_id="u2",
commenter_user_id="u1",
)
send.assert_called_once()
kwargs = send.call_args.kwargs
assert kwargs["mp_openid"] == "mp-m1"
assert kwargs["title"] == "张三 在评论中提到了你"
assert kwargs["applicant"] == "张三"
assert kwargs["process_name"] == "请假申请"
assert "mode=view" in kwargs["pagepath"]
assert (
"%2Fpages%2Foa%2Fmentions%2Findex" in kwargs["pagepath"]
or "/pages/oa/mentions/index" in kwargs["pagepath"]
)
def test_send_mention_one_skips_without_mp():
db = MagicMock()
mentioned = MagicMock()
mentioned.wx_mp_openid = ""
mentioned.nick_name = "李四"
def query_side_effect(model):
q = MagicMock()
name = getattr(model, "__name__", "") or str(model)
if "SysUser" in name:
q.filter.return_value.first.return_value = mentioned
else:
q.filter.return_value.first.return_value = None
return q
db.query.side_effect = query_side_effect
with patch("app.services.oa_wechat_notify.send_oa_cc_notify") as send:
_send_mention_one(
db,
instance_id="i1",
mentioned_user_id="u2",
commenter_user_id="u1",
)
send.assert_not_called()
def test_schedule_oa_mention_notifies_noop_when_disabled():
with patch("app.services.oa_wechat_notify.settings") as st, patch(
"app.services.oa_wechat_notify.threading.Thread"
) as th:
st.WX_MP_NOTIFY_ENABLED = False
schedule_oa_mention_notifies(
instance_id="i1",
commenter_user_id="u1",
mentioned_user_ids=["u2"],
)
th.assert_not_called()
def test_schedule_oa_mention_notifies_starts_thread_when_enabled():
with patch("app.services.oa_wechat_notify.settings") as st, patch(
"app.services.oa_wechat_notify.threading.Thread"
) as th:
st.WX_MP_NOTIFY_ENABLED = True
schedule_oa_mention_notifies(
instance_id="i1",
commenter_user_id="u1",
mentioned_user_ids=["u2", "u3"],
)
th.assert_called_once()
kwargs = th.call_args.kwargs
assert kwargs["kwargs"]["instance_id"] == "i1"
assert kwargs["kwargs"]["commenter_user_id"] == "u1"
assert kwargs["kwargs"]["mentioned_user_ids"] == ["u2", "u3"]
@@ -0,0 +1,39 @@
import unittest
from app.crud.production_task_set import (
collect_missing_route_ids,
collect_route_ids_without_steps,
)
class TestCollectRouteIdsWithoutSteps(unittest.TestCase):
def test_empty_when_all_routes_have_steps(self):
items = [{"route_id": "r1"}, {"route_id": "r2"}]
self.assertEqual(
collect_route_ids_without_steps(items, {"r1", "r2"}),
[],
)
def test_reports_routes_without_steps_deduped(self):
items = [
{"route_id": "r1"},
{"route_id": "r2"},
{"route_id": "r1"},
{"route_id": None},
{"route_id": ""},
]
self.assertEqual(
collect_route_ids_without_steps(items, {"r2"}),
["r1"],
)
def test_missing_route_ids_unchanged(self):
items = [{"route_id": "gone"}, {"route_id": "ok"}]
self.assertEqual(
collect_missing_route_ids(items, {"ok"}),
["gone"],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,63 @@
"""已落库样本编码在协议编辑中不可修改。"""
from types import SimpleNamespace
import pytest
from app.crud.collection_protocol import validate_persisted_sample_codes_immutable
def _row(*, sid="ps-1", code="OLD-001", code_id="pool-1"):
return SimpleNamespace(id=sid, sample_code=code, sample_code_id=code_id)
def test_rejects_code_change_when_persisted_id_present():
existing = [_row()]
with pytest.raises(ValueError, match="更正编码"):
validate_persisted_sample_codes_immutable(
existing,
[{"id": "ps-1", "sampleCode": "NEW-001", "sampleCodeId": "pool-1"}],
)
def test_rejects_code_id_change_when_persisted_id_present():
existing = [_row()]
with pytest.raises(ValueError, match="更正编码"):
validate_persisted_sample_codes_immutable(
existing,
[{"id": "ps-1", "sampleCode": "OLD-001", "sampleCodeId": "pool-other"}],
)
def test_allows_same_code_for_persisted_sample():
existing = [_row()]
validate_persisted_sample_codes_immutable(
existing,
[{"id": "ps-1", "sampleCode": "OLD-001", "sampleCodeId": "pool-1"}],
)
def test_allows_new_sample_without_id():
existing = [_row()]
validate_persisted_sample_codes_immutable(
existing,
[
{"id": "ps-1", "sampleCode": "OLD-001", "sampleCodeId": "pool-1"},
{"sampleCode": "NEW-002", "sampleCodeId": "pool-2"},
],
)
def test_ignores_unknown_id_as_new_sample():
existing = [_row()]
validate_persisted_sample_codes_immutable(
existing,
[{"id": "ps-unknown", "sampleCode": "ANY", "sampleCodeId": "x"}],
)
def test_trims_before_compare():
existing = [_row(code="OLD-001", code_id="pool-1")]
validate_persisted_sample_codes_immutable(
existing,
[{"id": "ps-1", "sample_code": " OLD-001 ", "sample_code_id": " pool-1 "}],
)
@@ -283,6 +283,46 @@ def test_locked_sample_payment_cannot_be_omitted_from_protocol_save():
assert db.get(BizProtocolPaymentSchedule, "schedule-1").del_flag == 0
def test_save_sample_payments_commit_false_defers_commit(monkeypatch):
"""commit=False 时成功路径不得自行 db.commit。"""
db = _session()
_payment(db)
_schedule(db, status="open")
_transfer(db, status="dfj")
commits: list = []
real_commit = db.commit
def tracking_commit(*args, **kwargs):
commits.append(1)
return real_commit(*args, **kwargs)
monkeypatch.setattr(db, "commit", tracking_commit)
save_sample_payments_for_protocol(
db,
collection_protocol_id="protocol-1",
tenant_no="tenant-a",
user_id="u1",
sample_payments=[
{
"id": "pay-1",
"sampleCode": "S1",
"payMode": "fq",
"contractAmount": 100,
"schedules": [
{"scheduleNo": 1, "dueAmount": 100, "dueDate": "20261031"}
],
}
],
commit=False,
)
assert commits == []
db.expire_all()
assert db.get(BizProtocolSamplePayment, "pay-1").update_by == "u1"
def test_all_draft_structure_can_still_be_rebuilt():
db = _session()
_payment(db)
@@ -0,0 +1,31 @@
from app.core.protocol_status_display import (
DEFAULT_QC_DONATION_PROTOCOL_STATUSES,
INACTIVE_PROTOCOL_STATUSES,
resolve_research_donation_protocol_status_filter,
)
def test_inactive_constant():
assert set(INACTIVE_PROTOCOL_STATUSES) == {"yzf", "sdfq"}
def test_default_qc_donation_statuses():
assert DEFAULT_QC_DONATION_PROTOCOL_STATUSES == ("dsx", "sxz")
def test_explicit_statuses_returned():
assert resolve_research_donation_protocol_status_filter("dsx,sxz") == ("in", ["dsx", "sxz"])
def test_explicit_empty_means_none():
assert resolve_research_donation_protocol_status_filter("") == ("none", [])
def test_missing_means_default_dsx_sxz():
mode, statuses = resolve_research_donation_protocol_status_filter(None)
assert mode == "default"
assert statuses == ["dsx", "sxz"]
def test_whitespace_only_means_none():
assert resolve_research_donation_protocol_status_filter(" , ") == ("none", [])
@@ -0,0 +1,48 @@
"""临时质检:同生产工单成组 + 创建时间倒序。"""
import unittest
from app.crud.qc_temporary_production_cluster_sort import (
sort_dicts_by_temporary_production_cluster,
uses_temporary_production_cluster_list_sort,
)
class TestUsesTemporaryClusterSort(unittest.TestCase):
def test_temporary_true(self):
self.assertTrue(uses_temporary_production_cluster_list_sort("temporary"))
def test_other_false(self):
self.assertFalse(uses_temporary_production_cluster_list_sort("inbound"))
self.assertFalse(uses_temporary_production_cluster_list_sort(""))
self.assertFalse(uses_temporary_production_cluster_list_sort(None))
class TestSortDictsByTemporaryCluster(unittest.TestCase):
def test_example_q1_then_a_cluster_then_b(self):
items = [
{"id": "Q3", "productionWorkOrderId": "A", "createTime": "2026-01-05 00:00:00"},
{"id": "Q4", "productionWorkOrderId": "B", "createTime": "2026-01-08 00:00:00"},
{"id": "Q1", "productionWorkOrderId": "", "createTime": "2026-01-10 00:00:00"},
{"id": "Q2", "productionWorkOrderId": "A", "createTime": "2026-01-09 00:00:00"},
]
ordered = sort_dicts_by_temporary_production_cluster(items)
self.assertEqual([x["id"] for x in ordered], ["Q1", "Q2", "Q3", "Q4"])
def test_same_pwo_newer_first_within_group(self):
items = [
{"id": "old", "productionWorkOrderId": "P1", "createTime": "2026-01-01 10:00:00"},
{"id": "new", "productionWorkOrderId": "P1", "createTime": "2026-01-02 10:00:00"},
]
ordered = sort_dicts_by_temporary_production_cluster(items)
self.assertEqual([x["id"] for x in ordered], ["new", "old"])
def test_manual_mixed_by_own_time(self):
items = [
{"id": "m1", "productionWorkOrderId": None, "createTime": "2026-03-01"},
{"id": "p_old", "productionWorkOrderId": "P", "createTime": "2026-01-01"},
{"id": "p_new", "productionWorkOrderId": "P", "createTime": "2026-02-01"},
{"id": "m2", "productionWorkOrderId": "", "createTime": "2026-02-15"},
]
# 组时:m1=03-01 > m2=02-15 > P.max=02-01
ordered = sort_dicts_by_temporary_production_cluster(items)
self.assertEqual([x["id"] for x in ordered], ["m1", "m2", "p_new", "p_old"])
@@ -0,0 +1,136 @@
"""生产派发质检:工单若仍指着已软删产出物,回填时改用同一步骤上的有效产出物。"""
import unittest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import app.crud.qc_task # noqa: F401 先加载任务模块,避免与工单模块循环导入
from app.crud.qc_work_order import _enrich_work_order_step_outputs
from app.models.models import BizProductionTaskStepOutput
def _session():
engine = create_engine("sqlite://")
BizProductionTaskStepOutput.__table__.create(bind=engine)
return sessionmaker(bind=engine)()
def _output(**kwargs):
now = datetime(2026, 9, 2, 9, 11, 9)
data = dict(
step_id="step-1",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
data.update(kwargs)
return BizProductionTaskStepOutput(**data)
class TestEnrichWorkOrderStepOutputs(unittest.TestCase):
def test_deleted_output_uses_the_live_output_on_the_same_step(self):
db = _session()
db.add(
_output(
id="out-old",
output_name="细胞悬液",
output_barcode="NK-UBMC202603009",
output_type_id="type-valid",
del_flag=1,
update_time=datetime(2026, 9, 2, 14, 8, 45),
)
)
db.add(
_output(
id="out-live",
output_name="细胞悬液",
output_barcode="NK-UBMC202603009",
output_type_id="type-valid",
del_flag=0,
create_time=datetime(2026, 9, 2, 14, 8, 45),
)
)
db.commit()
items = _enrich_work_order_step_outputs(
db,
"tenant-a",
[
{
"cellProductId": "",
"cellProductCode": "",
"cellProductName": "",
"productionStepOutputId": "out-old",
}
],
)
self.assertEqual(items[0]["cellProductCode"], "NK-UBMC202603009")
self.assertEqual(items[0]["cellProductName"], "细胞悬液")
self.assertEqual(items[0]["productionStepOutputId"], "out-live")
def test_deleted_output_without_live_match_is_not_used(self):
db = _session()
db.add(
_output(
id="out-old",
output_name="细胞悬液",
output_barcode="NK-UBMC202603009",
del_flag=1,
)
)
db.commit()
items = _enrich_work_order_step_outputs(
db,
"tenant-a",
[
{
"cellProductId": "",
"cellProductCode": "",
"cellProductName": "",
"productionStepOutputId": "out-old",
}
],
)
self.assertEqual(items[0]["cellProductCode"], "")
self.assertEqual(items[0]["cellProductName"], "")
self.assertEqual(items[0]["productionStepOutputId"], "out-old")
def test_other_tenant_output_is_ignored(self):
db = _session()
db.add(
_output(
id="out-other",
tenant_no="tenant-b",
output_name="细胞悬液",
output_barcode="NK-UBMC202603004",
del_flag=1,
)
)
db.commit()
items = _enrich_work_order_step_outputs(
db,
"tenant-a",
[
{
"cellProductId": "",
"cellProductCode": "",
"cellProductName": "",
"productionStepOutputId": "out-other",
}
],
)
self.assertEqual(items[0]["cellProductCode"], "")
self.assertEqual(items[0]["cellProductName"], "")
if __name__ == "__main__":
unittest.main()
+161
View File
@@ -0,0 +1,161 @@
"""保存步骤产出物时保留原行 ID,避免质检工单等外键被整表替换打断。"""
import unittest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.crud.production_task import replace_step_outputs
from app.models.models import BizProductionTaskStepOutput
def _session():
engine = create_engine("sqlite://")
BizProductionTaskStepOutput.__table__.create(bind=engine)
return sessionmaker(bind=engine)()
def _output(**kwargs):
now = datetime(2026, 9, 2, 9, 1, 8)
data = dict(
step_id="step-1",
tenant_no="tenant-a",
output_type_id="type-valid",
output_name="细胞悬液",
output_barcode="NK-UBMC202603009",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
data.update(kwargs)
return BizProductionTaskStepOutput(**data)
class TestReplaceStepOutputsKeepsIdentity(unittest.TestCase):
def test_existing_row_keeps_id_when_fields_change(self):
db = _session()
db.add(_output(id="out-keep"))
db.commit()
saved = replace_step_outputs(
db,
"step-1",
"tenant-a",
"user-1",
[
{
"id": "out-keep",
"output_type_id": "type-valid",
"output_name": "细胞悬液",
"output_barcode": "NK-UBMC202603009-NEW",
"remark": "",
}
],
)
self.assertEqual(saved[0]["id"], "out-keep")
self.assertEqual(saved[0]["outputBarcode"], "NK-UBMC202603009-NEW")
alive = (
db.query(BizProductionTaskStepOutput)
.filter(BizProductionTaskStepOutput.del_flag == 0)
.all()
)
self.assertEqual([row.id for row in alive], ["out-keep"])
def test_insert_new_update_old_and_soft_delete_only_removed(self):
db = _session()
db.add(_output(id="out-keep", output_name="细胞悬液", output_barcode="NK-OLD"))
db.add(_output(id="out-drop", output_name="细胞上清", output_barcode="NK-DROP"))
db.commit()
saved = replace_step_outputs(
db,
"step-1",
"tenant-a",
"user-1",
[
{
"id": "out-keep",
"output_type_id": "type-valid",
"output_name": "细胞悬液",
"output_barcode": "NK-UPDATED",
"remark": "改条码",
},
{
"output_type_id": "type-valid",
"output_name": "废液",
"output_barcode": "NK-NEW",
"remark": "",
},
],
)
self.assertEqual(saved[0]["id"], "out-keep")
self.assertEqual(saved[0]["outputBarcode"], "NK-UPDATED")
self.assertTrue(saved[1]["id"])
self.assertNotEqual(saved[1]["id"], "out-keep")
self.assertEqual(saved[1]["outputName"], "废液")
rows = {row.id: row for row in db.query(BizProductionTaskStepOutput).all()}
self.assertEqual(rows["out-keep"].del_flag, 0)
self.assertEqual(rows["out-keep"].output_barcode, "NK-UPDATED")
self.assertEqual(rows["out-drop"].del_flag, 1)
self.assertEqual(rows[saved[1]["id"]].del_flag, 0)
self.assertEqual(rows[saved[1]["id"]].output_barcode, "NK-NEW")
def test_removed_row_is_soft_deleted_and_kept_row_stays(self):
db = _session()
db.add(_output(id="out-keep", output_name="细胞悬液"))
db.add(_output(id="out-drop", output_name="细胞上清"))
db.commit()
replace_step_outputs(
db,
"step-1",
"tenant-a",
"user-1",
[
{
"id": "out-keep",
"output_type_id": "type-valid",
"output_name": "细胞悬液",
"output_barcode": "NK-UBMC202603009",
"remark": "",
}
],
)
rows = {row.id: row for row in db.query(BizProductionTaskStepOutput).all()}
self.assertEqual(rows["out-keep"].del_flag, 0)
self.assertEqual(rows["out-drop"].del_flag, 1)
def test_id_from_another_step_is_rejected(self):
db = _session()
db.add(_output(id="out-other", step_id="step-2"))
db.commit()
with self.assertRaises(ValueError):
replace_step_outputs(
db,
"step-1",
"tenant-a",
"user-1",
[
{
"id": "out-other",
"output_type_id": "type-valid",
"output_name": "细胞悬液",
"output_barcode": "NK-UBMC202603009",
"remark": "",
}
],
)
other = db.query(BizProductionTaskStepOutput).filter_by(id="out-other").one()
self.assertEqual(other.step_id, "step-2")
self.assertEqual(other.del_flag, 0)
if __name__ == "__main__":
unittest.main()
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff. Show more