Author SHA1 Message Date
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
144 changed files with 95601 additions and 65739 deletions

No files matched your search

+13 -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,22 @@ 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
- 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 +46,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
+14
View File
@@ -86,3 +86,17 @@ SECRET_KEY=
# WX_MP_TPL_PROGRESS_FIELD_APPROVER=thing6
# WX_MP_TPL_PROGRESS_FIELD_RESULT=const4
# WX_MP_TPL_PROGRESS_FIELD_TIME=time11
# 基础实验交付日前一天提醒(出库提醒模板)
# WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY=oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM
# WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER=thing6
# WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT=thing10
# WX_MP_TPL_BE_DELIVERY_FIELD_QTY=character_string8
# WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT=thing7
# WX_MP_TPL_BE_DELIVERY_FIELD_TIME=time14
# 协议新增样本采集通知(工单类模板)
# WX_MP_TEMPLATE_SAMPLE_COLLECT=iOnQ9k2mavAOwFbp_6yH4lD-RCNmM_OiwyTgrJfU-_A
# WX_MP_SAMPLE_COLLECT_ROLE_NAME=样本采集通知人员
# WX_MP_TPL_SC_FIELD_TITLE=thing2
# WX_MP_TPL_SC_FIELD_APPLICANT=thing8
# WX_MP_TPL_SC_FIELD_TIME=time3
# WX_MP_TPL_SC_FIELD_TYPE=phrase10
+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",
]
+68 -17
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))
+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. 细胞产品价格
# ---------------------------------------------------------------------------
+4
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,
+32
View File
@@ -15,6 +15,12 @@ from app.core.response import error_response, success_response
from app.crud import research_donation as rd_svc
from app.models.models import SysUser
from app.models.research_donation import BizResearchDonation
from app.services.sample_collect_wx_notify import (
PROTOCOL_KIND_DONATION,
sample_codes_from_orm_rows,
sample_codes_from_payload,
schedule_sample_collect_notify,
)
router = APIRouter(prefix="/web/admin", tags=["科研捐赠管理"])
@@ -277,6 +283,17 @@ async def create_research_donation(
tenant_no=tenant_no,
user_id=user_id,
)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=obj.id,
protocol_kind=PROTOCOL_KIND_DONATION,
before_codes=[],
submitted_codes=(
sample_codes_from_payload(body.samples)
if body.samples is not None
else None
),
)
return success_response(
rd_svc._donation_to_dict(obj, db, tenant_no),
message="创建成功",
@@ -302,6 +319,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 +330,17 @@ async def update_research_donation(
tenant_no=tenant_no,
user_id=user_id,
)
schedule_sample_collect_notify(
tenant_no=tenant_no,
protocol_id=obj.id,
protocol_kind=PROTOCOL_KIND_DONATION,
before_codes=before_codes,
submitted_codes=(
sample_codes_from_payload(body.samples)
if "samples" in unset_fields
else None
),
)
return success_response(
rd_svc._donation_to_dict(obj, db, tenant_no),
message="更新成功",
+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:
+21 -1
View File
@@ -133,13 +133,22 @@ class Settings(BaseSettings):
WX_TEMPLATE_DELIVERY_WARNING: str = "" # 配送时效预警模板ID
# 微信服务号(OA 审批/抄送通知)
# 总开关:false 时不发送审批/抄送/申请人进度模板消息(模板未就绪或微信能力不可用时关闭)
# 总开关:false 时不发送审批/抄送/申请人进度/评论@提到/基础实验交付提醒/样本采集通知模板消息
WX_MP_NOTIFY_ENABLED: bool = False
WX_MP_APPID: str = ""
WX_MP_SECRET: str = ""
WX_MP_TEMPLATE_APPROVAL: str = ""
WX_MP_TEMPLATE_CC: str = ""
WX_MP_TEMPLATE_APPLICANT_PROGRESS: str = ""
# 基础实验交付日前一天提醒(出库提醒模板)
WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY: str = (
"oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM"
)
# 协议新增样本采集通知(工单类模板)
WX_MP_TEMPLATE_SAMPLE_COLLECT: str = (
"iOnQ9k2mavAOwFbp_6yH4lD-RCNmM_OiwyTgrJfU-_A"
)
WX_MP_SAMPLE_COLLECT_ROLE_NAME: str = "样本采集通知人员"
# 审批模板:申请事项/操作人(申请人)/当前环节/操作时间/申请类型(流程名)
# 生产字段:thing12 / thing18 / thing7 / time17 / thing8
WX_MP_TPL_FIELD_TITLE: str = "thing12"
@@ -158,6 +167,17 @@ class Settings(BaseSettings):
WX_MP_TPL_PROGRESS_FIELD_APPROVER: str = "thing6"
WX_MP_TPL_PROGRESS_FIELD_RESULT: str = "const4"
WX_MP_TPL_PROGRESS_FIELD_TIME: str = "time11"
# 基础实验交付提醒:客户名称/产品名称/件数/收货联系人/出库时间
WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER: str = "thing6"
WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT: str = "thing10"
WX_MP_TPL_BE_DELIVERY_FIELD_QTY: str = "character_string8"
WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT: str = "thing7"
WX_MP_TPL_BE_DELIVERY_FIELD_TIME: str = "time14"
# 样本采集通知:工单标题/发起人/发起时间/任务类型
WX_MP_TPL_SC_FIELD_TITLE: str = "thing2"
WX_MP_TPL_SC_FIELD_APPLICANT: str = "thing8"
WX_MP_TPL_SC_FIELD_TIME: str = "time3"
WX_MP_TPL_SC_FIELD_TYPE: str = "phrase10"
# 与 docs/sql/zkxw.sql 基线一致:登录按租户 + sys_user.user_no
DEFAULT_TENANT_NO: str = "zkxw"
+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}%"))
+41 -4
View File
@@ -169,6 +169,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
@@ -2989,6 +3018,7 @@ def save_samples(
commit: bool = True,
) -> List[BizProtocolSample]:
existing = list_samples(db, collection_protocol_id, tenant_no)
validate_persisted_sample_codes_immutable(existing, samples)
old_id_to_code = {
s.id: (s.sample_code or "").strip()
for s in existing
@@ -4036,6 +4066,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 +4083,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 +4109,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
@@ -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,
@@ -69,6 +69,8 @@ def _row_to_dict_schedule(
"totalCourseCount": course.get("totalCourseCount"),
"cellType": row.cell_type or "",
"specText": row.spec_text or "",
"packageId": getattr(row, "package_id", None) or "",
"packageText": getattr(row, "package_text", None) or "",
"infusionTime": row.infusion_time.strftime("%Y-%m-%d %H:%M:%S") if row.infusion_time else "",
"infusionLocation": row.infusion_location or "",
"infusionMethod": row.infusion_method or "",
@@ -142,6 +144,8 @@ def _row_to_dict_product(row) -> Dict[str, Any]:
"projectName": row.project_name or "",
"cellTypeName": row.cell_type_name or "",
"specText": row.spec_text or "",
"packageId": getattr(row, "package_id", None) or "",
"packageText": getattr(row, "package_text", None) or "",
"agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None,
"actualPrice": float(row.actual_price) if row.actual_price is not None else None,
"quantity": row.quantity if row.quantity is not None else 1,
@@ -516,6 +520,8 @@ def get_infusion_schedule_detail(
sched["totalCourseCount"] = flat.get("totalCourseCount")
sched["cellType"] = flat.get("cellType") or sched.get("cellType") or ""
sched["specText"] = flat.get("specText") or sched.get("specText") or ""
sched["packageId"] = flat.get("packageId") or sched.get("packageId") or ""
sched["packageText"] = flat.get("packageText") or sched.get("packageText") or ""
sched["infusionTime"] = flat.get("infusionTime") or sched.get("infusionTime") or ""
sched["infusionLocation"] = flat.get("infusionLocation") or sched.get("infusionLocation") or ""
sched["infusionMethod"] = flat.get("infusionMethod") or sched.get("infusionMethod") or ""
@@ -1114,6 +1120,8 @@ def update_infusion_schedule_mgmt(
fields = {
"cellType": "cell_type",
"specText": "spec_text",
"packageId": "package_id",
"packageText": "package_text",
"infusionLocation": "infusion_location",
"infusionMethod": "infusion_method",
}
@@ -1153,6 +1161,8 @@ def update_infusion_schedule_mgmt(
project_name=item.get("projectName") or item.get("project_name") or "",
cell_type_name=item.get("cellTypeName") or item.get("cell_type_name") or "",
spec_text=item.get("specText") or item.get("spec_text") or "",
package_id=item.get("packageId") or item.get("package_id") or "",
package_text=item.get("packageText") or item.get("package_text") or "",
agreement_price=item.get("agreementPrice") if item.get("agreementPrice") is not None else (
item.get("agreement_price") if item.get("agreement_price") is not None else None
),
+136
View File
@@ -14,6 +14,7 @@ from app.crud.collection_protocol import urls_to_attachment_list
from app.models.models import (
BizCellProductType,
BizCellProductSpec,
BizCellProductPackage,
BizCellProductPrice,
BizCellProductProjectSystem,
BizCellProductProjectSystemItem,
@@ -281,6 +282,141 @@ def soft_delete_cell_product_spec(db, spec_id, tenant_no=None):
db.commit()
# ────────────────────────────────────────────
# Entity 2b: BizCellProductPackage 细胞产品包装
# ────────────────────────────────────────────
def _row_to_dict_cell_product_package(row) -> Dict[str, Any]:
return {
"id": row.id,
"packageCode": row.package_code or "",
"packageName": row.package_name or "",
"tenantNo": row.tenant_no or "",
"orderNum": row.order_num if row.order_num is not None else 0,
"remark": row.remark or "",
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
}
def list_cell_product_packages(db, *, page=1, page_size=10, keyword=None, tenant_no=None, user_info=None):
def _filtered():
q0 = db.query(BizCellProductPackage).filter(BizCellProductPackage.del_flag == 0)
if tenant_no:
q0 = q0.filter(BizCellProductPackage.tenant_no == tenant_no)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q0 = q0.filter(
or_(
BizCellProductPackage.package_code.like(like),
BizCellProductPackage.package_name.like(like),
)
)
if user_info:
from app.core.data_scope import apply_data_scope
q0 = apply_data_scope(q0, BizCellProductPackage, user_info, db, tenant_no)
return q0
total = _filtered().order_by(None).with_entities(func.count(BizCellProductPackage.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
_filtered()
.order_by(BizCellProductPackage.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return [_row_to_dict_cell_product_package(x) for x in rows], int(total)
def get_cell_product_package(db, package_id, tenant_no=None):
q = db.query(BizCellProductPackage).filter(
and_(BizCellProductPackage.id == package_id, BizCellProductPackage.del_flag == 0)
)
if tenant_no:
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
return q.first()
def cell_product_package_code_exists(db, code, tenant_no, exclude_id=None):
q = db.query(BizCellProductPackage).filter(
and_(BizCellProductPackage.package_code == code, BizCellProductPackage.del_flag == 0)
)
if tenant_no:
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
if exclude_id:
q = q.filter(BizCellProductPackage.id != exclude_id)
return db.query(q.exists()).scalar()
def create_cell_product_package(
db, *, package_code, package_name, tenant_no="zkxw", order_num=None, remark=None, user_id=None
):
if cell_product_package_code_exists(db, package_code, tenant_no):
raise ValueError(f"包装编码 {package_code} 已存在")
obj = BizCellProductPackage(
id=generate_uuid(),
package_code=package_code,
package_name=package_name,
tenant_no=tenant_no,
order_num=order_num or 0,
remark=remark or "",
create_by=user_id,
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_cell_product_package(
db,
package_id,
*,
package_code=None,
package_name=None,
order_num=None,
remark=None,
user_id=None,
tenant_no=None,
):
obj = get_cell_product_package(db, package_id, tenant_no)
if not obj:
raise ValueError("记录不存在")
if package_code is not None and package_code != obj.package_code:
if cell_product_package_code_exists(db, package_code, tenant_no, exclude_id=package_id):
raise ValueError(f"包装编码 {package_code} 已存在")
obj.package_code = package_code
if package_name is not None:
obj.package_name = package_name
if order_num is not None:
obj.order_num = order_num
if remark is not None:
obj.remark = remark
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def soft_delete_cell_product_package(db, package_id, tenant_no=None):
obj = get_cell_product_package(db, package_id, tenant_no)
if not obj:
raise ValueError("记录不存在")
obj.del_flag = 1
obj.update_time = datetime.now()
db.add(obj)
db.commit()
# ────────────────────────────────────────────
# Entity 3: BizCellProductPrice 细胞产品价格
# ────────────────────────────────────────────
+41 -3
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import (
BizProcessRoute,
BizProcessRouteStep,
BizProductionTask,
BizProductionTaskSet,
BizProductionTaskSetItem,
@@ -110,6 +111,39 @@ def collect_missing_route_ids(items: List[Dict[str, Any]], existing_ids: Set[str
return missing
def collect_route_ids_without_steps(
items: List[Dict[str, Any]],
route_ids_with_steps: Set[str],
) -> List[str]:
"""返回条目中已绑定路线但无有效工序步骤的 route_id(去重保序)。"""
empty: List[str] = []
seen: Set[str] = set()
with_steps = route_ids_with_steps or set()
for item in items or []:
rid = _strip_or_none(item.get("route_id"))
if not rid or rid in with_steps or rid in seen:
continue
seen.add(rid)
empty.append(rid)
return empty
def _load_route_ids_with_steps(db: Session, route_ids: List[str]) -> Set[str]:
ids = [rid for rid in route_ids if rid]
if not ids:
return set()
rows = (
db.query(BizProcessRouteStep.route_id)
.filter(
BizProcessRouteStep.route_id.in_(ids),
BizProcessRouteStep.del_flag == 0,
)
.distinct()
.all()
)
return {r[0] for r in rows if r and r[0]}
def total_pages(total: int, page_size: int) -> int:
if page_size <= 0:
return 0
@@ -664,12 +698,16 @@ def create_tasks_from_set(
}
for row in rows
]
name_map = _load_route_name_map(
db, tenant_no, [p["route_id"] for p in payloads if p.get("route_id")]
)
route_ids = [p["route_id"] for p in payloads if p.get("route_id")]
name_map = _load_route_name_map(db, tenant_no, route_ids)
missing = collect_missing_route_ids(payloads, set(name_map.keys()))
if missing:
raise ValueError("工艺路线已删除,无法派发:" + "、".join(missing))
with_steps = _load_route_ids_with_steps(db, route_ids)
empty_steps = collect_route_ids_without_steps(payloads, with_steps)
if empty_steps:
labels = [name_map.get(rid) or rid for rid in empty_steps]
raise ValueError("工艺路线无工序步骤,无法派发:" + "、".join(labels))
lab_storage_code = resolve_work_order_lab_storage_code(
db, tenant_no=tenant_no, wo=wo
)
@@ -347,6 +347,8 @@ def _enrich_outbound_items(
"projectName": product.project_name or "",
"cellTypeName": product.cell_type_name or "",
"specText": product.spec_text or "",
"packageId": getattr(product, "package_id", None) or "",
"packageText": getattr(product, "package_text", None) or "",
"agreementPrice": _dec_price(product.agreement_price),
"actualPrice": _dec_price(product.actual_price),
"quantity": product.quantity,
@@ -435,6 +437,8 @@ def _enrich_outbound_items(
it["totalCourseCount"] = course.get("totalCourseCount")
it["cellType"] = sched.cell_type or ""
it["specText"] = sched.spec_text or ""
it["packageId"] = getattr(sched, "package_id", None) or ""
it["packageText"] = getattr(sched, "package_text", None) or ""
it["infusionTime"] = _fmt_dt(sched.infusion_time) if sched.infusion_time else ""
it["infusionLocation"] = sched.infusion_location or ""
it["products"] = products_map.get(it["infusionScheduleId"], [])
@@ -583,6 +587,8 @@ def _attach_outbound_detail(
out["infusionSchedule"] = {
"cellType": sched.cell_type if sched else "",
"specText": sched.spec_text if sched else "",
"packageId": getattr(sched, "package_id", None) or "" if sched else "",
"packageText": getattr(sched, "package_text", None) or "" if sched else "",
"infusionTime": _fmt_dt(sched.infusion_time) if sched and sched.infusion_time else "",
"infusionLocation": sched.infusion_location if sched else "",
"infusionMethod": sched.infusion_method if sched else "",
+9 -2
View File
@@ -1350,8 +1350,12 @@ def save_sample_payments_for_protocol(
tenant_no: str,
user_id: Optional[str],
sample_payments: List[Dict[str, Any]],
commit: bool = True,
) -> None:
"""按样本保存缴费方案;锁定批次/转账所在方案禁止结构性变更。"""
"""按样本保存缴费方案;锁定批次/转账所在方案禁止结构性变更。
commit=False 时仅 flush,由调用方统一提交,避免协议其它子资源已提交后缴费校验失败导致半成功。
"""
existing = (
db.query(BizProtocolSamplePayment)
.filter(
@@ -1477,7 +1481,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(
@@ -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(),
)
)
+79 -3
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
@@ -298,6 +306,8 @@ def _enrich_outbound_work_orders(
"projectName": product.project_name or "",
"cellTypeName": product.cell_type_name or "",
"specText": product.spec_text or "",
"packageId": getattr(product, "package_id", None) or "",
"packageText": getattr(product, "package_text", None) or "",
"agreementPrice": _dec(product.agreement_price),
"actualPrice": _dec(product.actual_price),
"quantity": product.quantity,
@@ -381,6 +391,8 @@ def _enrich_outbound_work_orders(
item["totalCourseCount"] = course.get("totalCourseCount")
item["cellType"] = sched.cell_type or ""
item["specText"] = sched.spec_text or ""
item["packageId"] = getattr(sched, "package_id", None) or ""
item["packageText"] = getattr(sched, "package_text", None) or ""
item["infusionTime"] = _fmt_dt(sched.infusion_time)
item["products"] = products_map.get(sid, [])
return items
@@ -1566,6 +1578,52 @@ def _protocol_sample_ids_for_sample_type(
return ids
def _research_donation_status_exists(statuses: List[str]):
"""质检工单关联科研捐赠单且 donation_status 命中 statuses。"""
return exists().where(
and_(
eq_unicode(BizResearchDonation.id, BizQcWorkOrder.collection_protocol_id),
BizResearchDonation.del_flag == 0,
BizResearchDonation.donation_status.in_(statuses),
)
)
def apply_research_donation_protocol_status_filter(
query,
protocol_status: Optional[str],
*,
reception_source: Optional[str] = None,
force_research_donation_scope: bool = False,
):
"""科研捐赠质检:按协议/捐赠状态过滤。
- force_research_donation_scope=True:用于全量 Tab 统计,仅剔除 RD 废弃行,其它来源不动。
- 否则仅当 reception_source == research_donation 时生效。
"""
rs = (reception_source or "").strip()
if force_research_donation_scope:
# 角标与列表默认一致:科研捐赠仅计待生效/生效中
not_default = and_(
BizQcWorkOrder.reception_source == "research_donation",
~_research_donation_status_exists(list(DEFAULT_QC_DONATION_PROTOCOL_STATUSES)),
)
return query.filter(~not_default)
if rs != "research_donation":
return query
mode, statuses = resolve_research_donation_protocol_status_filter(protocol_status)
if mode == "none":
return query.filter(BizQcWorkOrder.id.in_([]))
if mode == "in":
return query.filter(_research_donation_status_exists(statuses))
# default:与前端默认勾选一致
return query.filter(
_research_donation_status_exists(list(DEFAULT_QC_DONATION_PROTOCOL_STATUSES))
)
def list_work_orders(
db: Session,
*,
@@ -1578,6 +1636,7 @@ def list_work_orders(
reception_source: Optional[str] = None,
trigger_source: Optional[str] = None,
sample_type_id: Optional[str] = None,
protocol_status: Optional[str] = None,
create_time_start: Optional[str] = None,
create_time_end: Optional[str] = None,
infusion_time_start: Optional[str] = None,
@@ -1708,6 +1767,11 @@ def list_work_orders(
if user_info:
from app.core.data_scope import apply_data_scope
q0 = apply_data_scope(q0, BizQcWorkOrder, user_info, db, tenant_no)
q0 = apply_research_donation_protocol_status_filter(
q0,
protocol_status,
reception_source=rs,
)
return q0
total = _filtered().order_by(None).with_entities(func.count(BizQcWorkOrder.id)).scalar() or 0
@@ -1716,6 +1780,8 @@ def list_work_orders(
q_page = _filtered()
if uses_inbound_lab_storage_list_sort(reception_source):
q_page = q_page.order_by(*inbound_lab_storage_order_by_clauses())
elif uses_temporary_production_cluster_list_sort(work_order_type):
q_page = apply_temporary_production_cluster_order(q_page)
else:
q_page = q_page.order_by(BizQcWorkOrder.create_time.desc())
rows = (
@@ -1941,6 +2007,9 @@ def count_unfinished_work_orders_by_tab(
BizQcWorkOrder.tenant_no == tenant_no,
)
q = _unfinished_work_order_filter(q)
q = apply_research_donation_protocol_status_filter(
q, None, force_research_donation_scope=True
)
if user_info:
from app.core.data_scope import apply_data_scope
@@ -1997,6 +2066,9 @@ def count_mine_unfinished_work_orders_by_tab(
)
)
q = _unfinished_work_order_filter(q)
q = apply_research_donation_protocol_status_filter(
q, None, force_research_donation_scope=True
)
rows = (
q.group_by(
BizQcWorkOrder.work_order_type,
@@ -3215,9 +3287,9 @@ def list_temp_qc_work_orders(
total = _filtered().order_by(None).with_entities(func.count(BizQcWorkOrder.id)).scalar() or 0
page = max(1, page)
page_size = min(max(1, page_size), 100)
q_page = apply_temporary_production_cluster_order(_filtered())
rows = (
_filtered()
.order_by(BizQcWorkOrder.create_time.desc())
q_page
.offset((page - 1) * page_size)
.limit(page_size)
.all()
@@ -3422,6 +3494,8 @@ def build_outbound_pilot_context_by_schedule(
"infusionMethod": sched.infusion_method or "",
"cellType": sched.cell_type or "",
"specText": sched.spec_text or "",
"packageId": getattr(sched, "package_id", None) or "",
"packageText": getattr(sched, "package_text", None) or "",
"scheduleStatus": sched.schedule_status or "",
"infusionStatus": sched.infusion_status or "",
"qcStatus": sched.qc_status or "",
@@ -3510,6 +3584,8 @@ def build_outbound_pilot_context_by_schedule(
"projectName": p.project_name or "",
"cellTypeName": p.cell_type_name or "",
"specText": p.spec_text or "",
"packageId": getattr(p, "package_id", None) or "",
"packageText": getattr(p, "package_text", None) or "",
"agreementPrice": _dec(p.agreement_price),
"actualPrice": _dec(p.actual_price),
"quantity": p.quantity,
+3 -1
View File
@@ -17,6 +17,7 @@ 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,
@@ -1332,6 +1333,8 @@ 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
@@ -1339,7 +1342,6 @@ def update_donation(
}
_soft_delete_samples(db, donation_id, tenant_no, user_id)
_soft_delete_transports(db, donation_id, tenant_no, user_id)
samples_raw = raw["samples"] or []
new_samples = _create_samples(
db, donation_id, tenant_no, user_id, samples_raw, now,
create_dept_id=create_dept_id,
+14 -4
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,
@@ -26,6 +27,7 @@ from app.services.sample_storage_cert_qc import (
def _row_to_dict(
row: BizSampleStorageCertQc,
*,
lab_flow_code: str = "",
protocol_code: str = "",
mother_name: str = "",
sample_type_name: str = "",
@@ -33,7 +35,7 @@ def _row_to_dict(
return {
"id": row.id,
"sampleCode": row.sample_code or "",
"labFlowCode": row.sample_code or "",
"labFlowCode": lab_flow_code or "",
"protocolCode": protocol_code or "",
"motherName": mother_name or "",
"sampleTypeName": sample_type_name or "",
@@ -99,7 +101,12 @@ def _load_sample_context(db: Session, tenant_no: str, sample_code: str):
)
if pc:
mother_name = pc.mother_name or ""
return sample, type_name, protocol_code, mother_name
lab_flow_code = ""
sc = (sample.sample_code or "").strip()
if sc:
code_map = reception_svc.get_lab_storage_code_map_by_sample_codes(db, tenant_no, [sc])
lab_flow_code = code_map.get(sc, "") or ""
return sample, type_name, protocol_code, mother_name, lab_flow_code
def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[str, Any]]:
@@ -107,7 +114,7 @@ def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[s
ctx = _load_sample_context(db, tenant_no, sample_code)
if not ctx:
return None
sample, type_name, protocol_code, mother_name = ctx
sample, type_name, protocol_code, mother_name, lab_flow_code = ctx
key = resolve_sample_type_key(type_name)
row = (
db.query(BizSampleStorageCertQc)
@@ -121,6 +128,7 @@ def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[s
if not row:
return empty_detail(
sample_code=sample.sample_code or "",
lab_flow_code=lab_flow_code,
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
@@ -129,6 +137,7 @@ def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[s
)
return _row_to_dict(
row,
lab_flow_code=lab_flow_code,
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
@@ -147,7 +156,7 @@ def upsert(
ctx = _load_sample_context(db, tenant_no, sample_code)
if not ctx:
raise ValueError("样本不存在")
sample, type_name, protocol_code, mother_name = ctx
sample, type_name, protocol_code, mother_name, lab_flow_code = ctx
key = resolve_sample_type_key(type_name)
if not key:
raise ValueError("该样本类型不支持填写储存证质检信息")
@@ -193,6 +202,7 @@ def upsert(
db.refresh(row)
return _row_to_dict(
row,
lab_flow_code=lab_flow_code,
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
+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,
+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"""
@@ -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()
+1
View File
@@ -1346,6 +1346,7 @@ def _persist_sample_payments_if_any(
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
sample_payments=raw,
commit=False,
)
+17 -2
View File
@@ -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,
+133
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 = ""
@@ -224,6 +233,130 @@ def _oa_wx_notify_enabled() -> bool:
return bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False))
def _send_mention_one(
db,
*,
instance_id: str,
mentioned_user_id: str,
commenter_user_id: str,
) -> None:
"""评论 @:复用抄送模板;thing2=评论人,thing5=「xxx 在评论中提到了你」。"""
uid = (mentioned_user_id or "").strip()
if not uid:
return
user = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
mp = (getattr(user, "wx_mp_openid", None) or "").strip() if user else ""
if not mp:
logger.info(
"oa wx mention skip: no mp_openid user=%s inst=%s", uid, instance_id
)
return
iid = (instance_id or "").strip()
inst = (
db.query(OaInstance)
.filter(OaInstance.id == iid, OaInstance.del_flag == 0)
.first()
if iid
else None
)
process_name = ""
if inst and inst.app_id:
app = (
db.query(OaApp)
.filter(OaApp.id == inst.app_id, OaApp.del_flag == 0)
.first()
)
process_name = (app.app_name if app else "") or ""
if not process_name and inst:
process_name = (inst.title or "").strip()
commenter_name = ""
cid = (commenter_user_id or "").strip()
if cid:
commenter = (
db.query(SysUser)
.filter(SysUser.id == cid, SysUser.del_flag == 0)
.first()
)
commenter_name = (commenter.nick_name if commenter else "") or ""
pagepath = build_oa_detail_pagepath(
instance_id=iid,
task_id="",
mode="view",
return_path=RETURN_MENTIONS,
)
send_oa_cc_notify(
mp_openid=mp,
pagepath=pagepath,
title=_build_mention_title(commenter_name),
applicant=commenter_name or "未知",
node_name="",
time_text=datetime.now().strftime("%Y-%m-%d %H:%M"),
process_name=process_name or "审批",
)
def notify_oa_mentions_sync(
*,
instance_id: str,
commenter_user_id: str,
mentioned_user_ids: Iterable[str],
) -> None:
if not _oa_wx_notify_enabled():
logger.info("oa wx mention skipped: WX_MP_NOTIFY_ENABLED=false")
return
iid = (instance_id or "").strip()
ids = [str(x).strip() for x in (mentioned_user_ids or []) if str(x).strip()]
if not iid or not ids:
return
db = None
try:
db = SessionLocal()
for uid in ids:
try:
_send_mention_one(
db,
instance_id=iid,
mentioned_user_id=uid,
commenter_user_id=commenter_user_id,
)
except Exception:
logger.exception(
"oa wx mention failed user=%s inst=%s", uid, iid
)
except Exception:
logger.exception("oa wx mention batch failed inst=%s", iid)
finally:
if db is not None:
db.close()
def schedule_oa_mention_notifies(
*,
instance_id: str,
commenter_user_id: str,
mentioned_user_ids: List[str],
) -> None:
if not _oa_wx_notify_enabled():
logger.info("oa wx mention schedule skipped: WX_MP_NOTIFY_ENABLED=false")
return
iid = (instance_id or "").strip()
ids = [str(x).strip() for x in (mentioned_user_ids or []) if str(x).strip()]
if not iid or not ids:
return
threading.Thread(
target=notify_oa_mentions_sync,
kwargs={
"instance_id": iid,
"commenter_user_id": (commenter_user_id or "").strip(),
"mentioned_user_ids": ids,
},
daemon=True,
name="oa-wx-mention",
).start()
def notify_oa_tasks_sync(task_ids: Iterable[str]) -> None:
if not _oa_wx_notify_enabled():
logger.info("oa wx notify skipped: WX_MP_NOTIFY_ENABLED=false")
@@ -0,0 +1,676 @@
"""样本编码全链路纠错:预览影响面 + 事务级联更新。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import (
BizCellProduct,
BizProductionTaskInput,
BizProductionWorkOrder,
BizProtocolPaymentSchedule,
BizProtocolPaymentTransfer,
BizProtocolSample,
BizProtocolSamplePayment,
BizProtocolSampleTransport,
BizQcTask,
BizQcWorkOrder,
BizSampleCode,
BizSampleCodeCorrectionLog,
BizSampleCustomerService,
BizSampleFreightItem,
BizSampleInsurance,
BizSampleProductionReception,
BizSampleReception,
BizSampleStorageCert,
BizSampleStorageCertQc,
BizStorageReleaseReview,
)
from app.models.research_donation import (
BizResearchDonationSample,
BizResearchDonationSampleTransport,
)
SOURCE_CELL = "cell_storage"
SOURCE_DONATION = "research_donation"
VALID_SOURCES = {SOURCE_CELL, SOURCE_DONATION}
# (key, label, model, code_attr, has_reception_source, cell_only)
_TABLE_SPECS: List[Tuple[str, str, Any, str, bool, bool]] = [
("biz_protocol_sample_payment", "按样本缴费", BizProtocolSamplePayment, "sample_code", False, True),
("biz_protocol_payment_schedule", "缴费期次", BizProtocolPaymentSchedule, "sample_code", False, True),
("biz_protocol_payment_transfer", "缴费转账", BizProtocolPaymentTransfer, "sample_code", False, True),
("biz_protocol_sample_transport", "协议样本运输", BizProtocolSampleTransport, "sample_code", False, True),
(
"biz_research_donation_sample_transport",
"捐赠样本运输",
BizResearchDonationSampleTransport,
"sample_code",
False,
False,
),
("biz_sample_freight_item", "运费明细", BizSampleFreightItem, "sample_code", False, False),
("biz_sample_reception", "样本接收", BizSampleReception, "sample_code", True, False),
("biz_qc_work_order", "质检工单", BizQcWorkOrder, "sample_code", True, False),
("biz_qc_task", "质检任务", BizQcTask, "sample_code", False, False),
("biz_storage_release_review", "入库放行", BizStorageReleaseReview, "sample_code", True, False),
("biz_sample_storage_cert_qc", "储存证质检", BizSampleStorageCertQc, "sample_code", False, True),
(
"biz_sample_production_reception",
"生产部门接收",
BizSampleProductionReception,
"sample_code",
True,
False,
),
("biz_production_work_order", "生产工单", BizProductionWorkOrder, "sample_code", True, False),
("biz_sample_storage_cert", "储存证", BizSampleStorageCert, "sample_code", False, True),
("biz_sample_insurance", "样本保险", BizSampleInsurance, "sample_code", False, True),
("biz_sample_customer_service", "客户服务扩展", BizSampleCustomerService, "sample_code", False, True),
]
_UNIQUE_TABLES = {
"biz_sample_storage_cert",
"biz_sample_insurance",
"biz_sample_customer_service",
"biz_sample_storage_cert_qc",
}
class SampleCodeCorrectionError(ValueError):
"""业务校验失败(可直接返回给前端)。"""
def _norm(code: Optional[str]) -> str:
return (code or "").strip()
def _now() -> datetime:
return datetime.now()
def _load_source_sample(
db: Session,
*,
tenant_no: str,
reception_source: str,
sample_code: str,
):
code = _norm(sample_code)
if reception_source == SOURCE_CELL:
return (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code == code,
)
.first()
)
return (
db.query(BizResearchDonationSample)
.filter(
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
BizResearchDonationSample.sample_code == code,
)
.first()
)
def _code_used_by_other_sample(
db: Session,
*,
tenant_no: str,
sample_code: str,
exclude_sample_id: Optional[str],
) -> bool:
code = _norm(sample_code)
q1 = db.query(BizProtocolSample.id).filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code == code,
)
q2 = db.query(BizResearchDonationSample.id).filter(
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
BizResearchDonationSample.sample_code == code,
)
if exclude_sample_id:
q1 = q1.filter(BizProtocolSample.id != exclude_sample_id)
q2 = q2.filter(BizResearchDonationSample.id != exclude_sample_id)
return q1.first() is not None or q2.first() is not None
def _count_by_code(
db: Session,
model,
*,
tenant_no: str,
code: str,
code_attr: str = "sample_code",
reception_source: Optional[str] = None,
has_reception_source: bool = False,
) -> int:
col = getattr(model, code_attr)
q = db.query(model).filter(
model.tenant_no == tenant_no,
model.del_flag == 0,
col == code,
)
if has_reception_source and reception_source:
q = q.filter(model.reception_source == reception_source)
return int(q.count())
def _update_by_code(
db: Session,
model,
*,
tenant_no: str,
old_code: str,
new_code: str,
code_attr: str = "sample_code",
reception_source: Optional[str] = None,
has_reception_source: bool = False,
user_id: Optional[str] = None,
) -> int:
col = getattr(model, code_attr)
q = db.query(model).filter(
model.tenant_no == tenant_no,
model.del_flag == 0,
col == old_code,
)
if has_reception_source and reception_source:
q = q.filter(model.reception_source == reception_source)
rows = q.all()
now = _now()
for row in rows:
setattr(row, code_attr, new_code)
if hasattr(row, "update_by"):
row.update_by = user_id
if hasattr(row, "update_time"):
row.update_time = now
return len(rows)
def _count_task_inputs(db: Session, tenant_no: str, code: str) -> int:
return int(
db.query(BizProductionTaskInput)
.filter(
BizProductionTaskInput.tenant_no == tenant_no,
BizProductionTaskInput.del_flag == 0,
BizProductionTaskInput.input_type == "sample",
BizProductionTaskInput.input_code == code,
)
.count()
)
def _update_task_inputs(
db: Session,
*,
tenant_no: str,
old_code: str,
new_code: str,
user_id: Optional[str],
) -> int:
rows = (
db.query(BizProductionTaskInput)
.filter(
BizProductionTaskInput.tenant_no == tenant_no,
BizProductionTaskInput.del_flag == 0,
BizProductionTaskInput.input_type == "sample",
BizProductionTaskInput.input_code == old_code,
)
.all()
)
now = _now()
for row in rows:
row.input_code = new_code
row.update_by = user_id
row.update_time = now
return len(rows)
def _count_same_product_code(db: Session, tenant_no: str, code: str) -> int:
return int(
db.query(BizCellProduct)
.filter(
BizCellProduct.tenant_no == tenant_no,
BizCellProduct.del_flag == 0,
BizCellProduct.product_code == code,
)
.count()
)
def _unique_conflict(
db: Session,
model,
*,
tenant_no: str,
new_code: str,
) -> bool:
return (
db.query(model.id)
.filter(
model.tenant_no == tenant_no,
model.del_flag == 0,
model.sample_code == new_code,
)
.first()
is not None
)
def _spec_applies(reception_source: str, cell_only: bool, table_key: str) -> bool:
if reception_source == SOURCE_CELL:
if table_key == "biz_research_donation_sample_transport":
return False
return True
# donation
if cell_only:
return False
if table_key.startswith("biz_protocol_"):
return False
return True
def preview_correction(
db: Session,
*,
tenant_no: str,
reception_source: str,
old_sample_code: str,
new_sample_code: str,
new_sample_code_id: Optional[str] = None,
) -> Dict[str, Any]:
source = _norm(reception_source) or SOURCE_CELL
old_code = _norm(old_sample_code)
new_code = _norm(new_sample_code)
blockers: List[str] = []
if source not in VALID_SOURCES:
blockers.append("receptionSource 必须是 cell_storage 或 research_donation")
if not old_code:
blockers.append("旧样本编码不能为空")
if not new_code:
blockers.append("新样本编码不能为空")
if old_code and new_code and old_code == new_code:
blockers.append("新样本编码不能与旧编码相同")
sample = None
if not blockers:
sample = _load_source_sample(
db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
)
if sample is None:
blockers.append(f"未找到来源「{source}」下样本编码「{old_code}」")
elif _code_used_by_other_sample(
db,
tenant_no=tenant_no,
sample_code=new_code,
exclude_sample_id=sample.id,
):
blockers.append(f"样本编码「{new_code}」已被其它协议/捐赠样本占用")
impacts: List[Dict[str, Any]] = []
if sample is not None and not blockers:
sot_key = (
"biz_protocol_sample"
if source == SOURCE_CELL
else "biz_research_donation_sample"
)
impacts.append(
{
"table": sot_key,
"label": "样本赋值源表",
"count": 1,
}
)
pool_count = 0
if source == SOURCE_CELL:
pool_id = _norm(new_sample_code_id) or _norm(getattr(sample, "sample_code_id", None))
if pool_id:
pool_row = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.id == pool_id,
)
.first()
)
if pool_row and _norm(pool_row.sample_code) == old_code:
pool_count = 1
elif not pool_row and _norm(new_sample_code_id):
blockers.append(f"指定的新编码池 ID「{new_sample_code_id}」不存在")
existing_new_pool = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.sample_code == new_code,
)
.first()
)
if existing_new_pool and pool_count == 0:
impacts.append(
{
"table": "biz_sample_code",
"label": "编码池(切换占用)",
"count": 1,
}
)
elif pool_count:
impacts.append(
{
"table": "biz_sample_code",
"label": "编码池(就地改码)",
"count": pool_count,
}
)
for key, label, model, attr, has_rs, cell_only in _TABLE_SPECS:
if not _spec_applies(source, cell_only, key):
continue
cnt = _count_by_code(
db,
model,
tenant_no=tenant_no,
code=old_code,
code_attr=attr,
reception_source=source if has_rs else None,
has_reception_source=has_rs,
)
if cnt:
impacts.append({"table": key, "label": label, "count": cnt})
if (
key in _UNIQUE_TABLES
and source == SOURCE_CELL
and cnt > 0
and _unique_conflict(db, model, tenant_no=tenant_no, new_code=new_code)
):
blockers.append(
f"表 {key} 已存在样本编码「{new_code}」记录,无法直接改码"
)
ti = _count_task_inputs(db, tenant_no, old_code)
if ti:
impacts.append(
{
"table": "biz_production_task_input",
"label": "生产任务投入物(样本)",
"count": ti,
}
)
same_product_old = (
_count_same_product_code(db, tenant_no, old_code) if old_code else 0
)
same_product_new = (
_count_same_product_code(db, tenant_no, new_code) if new_code else 0
)
total = sum(int(i.get("count") or 0) for i in impacts)
return {
"ok": len(blockers) == 0,
"blockers": blockers,
"receptionSource": source,
"oldSampleCode": old_code,
"newSampleCode": new_code,
"sampleId": getattr(sample, "id", None) if sample else None,
"impacts": impacts,
"totalAffectedRows": total,
"hints": {
"sameProductCodeAsOld": same_product_old,
"sameProductCodeAsNew": same_product_new,
"labStorageCodeUnchanged": True,
"cellProductCodeNotAutoUpdated": True,
},
}
def _resolve_pool(
db: Session,
*,
tenant_no: str,
sample: BizProtocolSample,
old_code: str,
new_code: str,
new_sample_code_id: Optional[str],
user_id: Optional[str],
) -> Dict[str, Any]:
"""返回 pool 处理结果摘要。"""
now = _now()
result = {"action": "none", "poolId": None}
preferred_id = _norm(new_sample_code_id)
existing_new = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.sample_code == new_code,
)
.first()
)
if preferred_id:
preferred = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.id == preferred_id,
)
.first()
)
if preferred is None:
raise SampleCodeCorrectionError(f"指定的新编码池 ID「{preferred_id}」不存在")
if _norm(preferred.sample_code) not in {new_code, old_code}:
# 允许指向已是新码的池行,或指向即将就地改码的旧池行
if _norm(preferred.sample_code) != new_code:
preferred.sample_code = new_code
preferred.update_by = user_id
preferred.update_time = now
result["action"] = "rename_preferred"
existing_new = preferred
old_pool_id = _norm(getattr(sample, "sample_code_id", None))
old_pool = None
if old_pool_id:
old_pool = (
db.query(BizSampleCode)
.filter(
BizSampleCode.tenant_no == tenant_no,
BizSampleCode.del_flag == 0,
BizSampleCode.id == old_pool_id,
)
.first()
)
if existing_new is not None:
sample.sample_code_id = existing_new.id
sample.sample_code = new_code
existing_new.issued = 1
existing_new.update_by = user_id
existing_new.update_time = now
result["action"] = result["action"] if result["action"] != "none" else "reassign"
result["poolId"] = existing_new.id
if (
old_pool
and old_pool.id != existing_new.id
and _norm(old_pool.sample_code) == old_code
):
still_used = (
db.query(BizProtocolSample.id)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code_id == old_pool.id,
BizProtocolSample.id != sample.id,
)
.first()
)
if still_used is None:
old_pool.issued = 0
old_pool.update_by = user_id
old_pool.update_time = now
return result
if old_pool and _norm(old_pool.sample_code) == old_code:
old_pool.sample_code = new_code
old_pool.issued = 1
old_pool.update_by = user_id
old_pool.update_time = now
sample.sample_code = new_code
sample.sample_code_id = old_pool.id
result["action"] = "rename_inplace"
result["poolId"] = old_pool.id
return result
sample.sample_code = new_code
result["action"] = "sample_text_only"
return result
def apply_correction(
db: Session,
*,
tenant_no: str,
reception_source: str,
old_sample_code: str,
new_sample_code: str,
new_sample_code_id: Optional[str] = None,
reason: Optional[str] = None,
user_id: Optional[str] = None,
) -> Dict[str, Any]:
preview = preview_correction(
db,
tenant_no=tenant_no,
reception_source=reception_source,
old_sample_code=old_sample_code,
new_sample_code=new_sample_code,
new_sample_code_id=new_sample_code_id,
)
if not preview.get("ok"):
raise SampleCodeCorrectionError(
";".join(preview.get("blockers") or ["校验未通过"])
)
source = preview["receptionSource"]
old_code = preview["oldSampleCode"]
new_code = preview["newSampleCode"]
sample = _load_source_sample(
db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
)
if sample is None:
raise SampleCodeCorrectionError(f"未找到样本编码「{old_code}」")
now = _now()
impact_counts: Dict[str, int] = {}
if source == SOURCE_CELL:
pool_info = _resolve_pool(
db,
tenant_no=tenant_no,
sample=sample,
old_code=old_code,
new_code=new_code,
new_sample_code_id=new_sample_code_id,
user_id=user_id,
)
impact_counts["biz_sample_code"] = 1 if pool_info.get("action") != "none" else 0
sample.update_by = user_id
sample.update_time = now
impact_counts["biz_protocol_sample"] = 1
else:
sample.sample_code = new_code
sample.update_by = user_id
sample.update_time = now
impact_counts["biz_research_donation_sample"] = 1
# UNIQUE 表先更新,避免其它逻辑读到半新半旧
for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
if key not in _UNIQUE_TABLES:
continue
if not _spec_applies(source, cell_only, key):
continue
n = _update_by_code(
db,
model,
tenant_no=tenant_no,
old_code=old_code,
new_code=new_code,
code_attr=attr,
reception_source=source if has_rs else None,
has_reception_source=has_rs,
user_id=user_id,
)
if n:
impact_counts[key] = n
for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
if key in _UNIQUE_TABLES:
continue
if not _spec_applies(source, cell_only, key):
continue
n = _update_by_code(
db,
model,
tenant_no=tenant_no,
old_code=old_code,
new_code=new_code,
code_attr=attr,
reception_source=source if has_rs else None,
has_reception_source=has_rs,
user_id=user_id,
)
if n:
impact_counts[key] = n
ti = _update_task_inputs(
db,
tenant_no=tenant_no,
old_code=old_code,
new_code=new_code,
user_id=user_id,
)
if ti:
impact_counts["biz_production_task_input"] = ti
log = BizSampleCodeCorrectionLog(
id=generate_uuid(),
reception_source=source,
old_sample_code=old_code,
new_sample_code=new_code,
impact_json=json.dumps(impact_counts, ensure_ascii=False),
reason=_norm(reason) or None,
tenant_no=tenant_no,
order_num=0,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(log)
db.commit()
return {
"ok": True,
"logId": log.id,
"receptionSource": source,
"oldSampleCode": old_code,
"newSampleCode": new_code,
"impactCounts": impact_counts,
"totalAffectedRows": sum(impact_counts.values()),
"hints": preview.get("hints") or {},
}
@@ -0,0 +1,483 @@
"""协议新增样本:服务号模板消息通知。"""
from __future__ import annotations
import logging
import threading
import uuid
from datetime import datetime
from typing import Any, Dict, Iterable, List, Optional, Set
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import SessionLocal
from app.models.models import (
BizCollectionProtocol,
BizProtocolSample,
BizProtocolSampleTransport,
BizWxNotifyLog,
SysUser,
)
from app.models.research_donation import (
BizResearchDonation,
BizResearchDonationSample,
BizResearchDonationSampleTransport,
)
from app.services.web_auth_roles import list_user_ids_with_effective_role_name
logger = logging.getLogger(__name__)
NOTIFY_BIZ_TYPE = "sample_collect_notify"
NOTIFY_DATE_ONCE = "1970-01-01"
PROTOCOL_KIND_CELL = "cell_storage"
PROTOCOL_KIND_DONATION = "research_donation"
TASK_TYPE_CELL = "细胞存储"
TASK_TYPE_DONATION = "科研捐赠"
def sample_codes_from_payload(samples: Optional[Iterable[Any]]) -> Set[str]:
"""从请求体样本列表提取非空 sample_code(兼容 camelCase)。"""
out: Set[str] = set()
for raw in samples or []:
if isinstance(raw, dict):
code = (raw.get("sample_code") or raw.get("sampleCode") or "").strip()
else:
code = (getattr(raw, "sample_code", None) or "").strip()
if code:
out.add(code)
return out
def sample_codes_from_orm_rows(rows: Optional[Iterable[Any]]) -> Set[str]:
out: Set[str] = set()
for row in rows or []:
code = (getattr(row, "sample_code", None) or "").strip()
if code:
out.add(code)
return out
def diff_new_sample_codes(
*,
before_codes: Iterable[str],
submitted_codes: Iterable[str],
) -> List[str]:
"""提交相对保存前的新增 sample_code;稳定排序便于测试。"""
before = {str(c).strip() for c in before_codes if str(c).strip()}
submitted = {str(c).strip() for c in submitted_codes if str(c).strip()}
return sorted(submitted - before)
def format_sample_collect_title(eta_time: Optional[datetime] = None) -> str:
"""工单标题:有 ETA → 样本采集(预计到达 M月D日);否则到达待定。"""
if eta_time is not None:
title = f"样本采集(预计到达 {eta_time.month}月{eta_time.day}日)"
else:
title = "样本采集(到达待定)"
return title
def format_protocol_create_time(dt: Optional[datetime]) -> str:
if not dt:
return ""
return dt.strftime("%Y年%m月%d日 %H:%M")
def _notify_enabled() -> bool:
if not bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False)):
return False
tid = (getattr(settings, "WX_MP_TEMPLATE_SAMPLE_COLLECT", "") or "").strip()
return bool(tid)
def _role_name() -> str:
return (
getattr(settings, "WX_MP_SAMPLE_COLLECT_ROLE_NAME", "") or "样本采集通知人员"
).strip() or "样本采集通知人员"
def _task_type_for_kind(protocol_kind: str) -> str:
if protocol_kind == PROTOCOL_KIND_DONATION:
return TASK_TYPE_DONATION
return TASK_TYPE_CELL
def _biz_id(sample_code: str, user_id: str) -> str:
return f"{(sample_code or '').strip()}:{(user_id or '').strip()}"
def _already_success(
db: Session, *, tenant_no: str, sample_code: str, user_id: str
) -> bool:
row = (
db.query(BizWxNotifyLog)
.filter(
BizWxNotifyLog.tenant_no == tenant_no,
BizWxNotifyLog.biz_type == NOTIFY_BIZ_TYPE,
BizWxNotifyLog.biz_id == _biz_id(sample_code, user_id),
BizWxNotifyLog.notify_date == NOTIFY_DATE_ONCE,
BizWxNotifyLog.status == "success",
BizWxNotifyLog.del_flag == 0,
)
.first()
)
return row is not None
def _write_success_log(
db: Session,
*,
tenant_no: str,
sample_code: str,
user_id: str,
) -> None:
now = datetime.now()
db.add(
BizWxNotifyLog(
id=str(uuid.uuid4()).replace("-", "").upper(),
biz_type=NOTIFY_BIZ_TYPE,
biz_id=_biz_id(sample_code, user_id),
notify_date=NOTIFY_DATE_ONCE,
user_id=user_id or "",
status="success",
errcode=None,
errmsg=None,
tenant_no=tenant_no,
order_num=0,
remark="",
create_by=user_id or "",
create_dept_id="",
create_time=now,
update_by=user_id or "",
update_time=now,
del_flag=0,
)
)
def list_notify_recipient_users(db: Session, *, tenant_no: str) -> List[SysUser]:
"""有效拥有「样本采集通知人员」角色且已绑定服务号的用户。
有效角色 = 用户直接绑角色 ∪ 经启用职位继承(sys_user_post → sys_post → sys_post_role),
与登录权限解析一致。
"""
role_name = _role_name()
user_ids = list_user_ids_with_effective_role_name(
db, tenant_no=tenant_no, role_name=role_name
)
if not user_ids:
return []
rows = (
db.query(SysUser)
.filter(
SysUser.id.in_(list(user_ids)),
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0,
SysUser.wx_mp_openid.isnot(None),
SysUser.wx_mp_openid != "",
)
.all()
)
# 按 user_id 已去重;再按 openid 去重,避免一人多职位/多账号同微信重复推送
seen_openid: Set[str] = set()
out: List[SysUser] = []
for u in rows:
oid = (getattr(u, "wx_mp_openid", None) or "").strip()
if not oid or oid in seen_openid:
continue
seen_openid.add(oid)
out.append(u)
return out
def _load_cell_context(
db: Session, *, protocol_id: str, tenant_no: str, new_codes: List[str]
) -> Optional[Dict[str, Any]]:
protocol = (
db.query(BizCollectionProtocol)
.filter(
BizCollectionProtocol.id == protocol_id,
BizCollectionProtocol.tenant_no == tenant_no,
BizCollectionProtocol.del_flag == 0,
)
.first()
)
if not protocol:
return None
samples = (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.collection_protocol_id == protocol_id,
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code.in_(new_codes),
)
.all()
)
code_to_id = {
(s.sample_code or "").strip(): s.id
for s in samples
if (s.sample_code or "").strip()
}
sample_ids = list(code_to_id.values())
eta_by_code: Dict[str, Optional[datetime]] = {c: None for c in new_codes}
if sample_ids:
transports = (
db.query(BizProtocolSampleTransport)
.filter(
BizProtocolSampleTransport.collection_protocol_id == protocol_id,
BizProtocolSampleTransport.tenant_no == tenant_no,
BizProtocolSampleTransport.del_flag == 0,
BizProtocolSampleTransport.sample_id.in_(sample_ids),
)
.all()
)
id_to_code = {sid: code for code, sid in code_to_id.items()}
for t in transports:
code = id_to_code.get((t.sample_id or "").strip()) or (
getattr(t, "sample_code", None) or ""
).strip()
if code and code in eta_by_code and t.eta_time and eta_by_code[code] is None:
eta_by_code[code] = t.eta_time
creator = None
if protocol.create_by:
creator = (
db.query(SysUser)
.filter(SysUser.id == protocol.create_by, SysUser.del_flag == 0)
.first()
)
return {
"create_time": protocol.create_time,
"applicant": (creator.nick_name if creator else "") or "",
"eta_by_code": eta_by_code,
}
def _load_donation_context(
db: Session, *, protocol_id: str, tenant_no: str, new_codes: List[str]
) -> Optional[Dict[str, Any]]:
donation = (
db.query(BizResearchDonation)
.filter(
BizResearchDonation.id == protocol_id,
BizResearchDonation.tenant_no == tenant_no,
BizResearchDonation.del_flag == 0,
)
.first()
)
if not donation:
return None
samples = (
db.query(BizResearchDonationSample)
.filter(
BizResearchDonationSample.research_donation_id == protocol_id,
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
BizResearchDonationSample.sample_code.in_(new_codes),
)
.all()
)
code_to_id = {
(s.sample_code or "").strip(): s.id
for s in samples
if (s.sample_code or "").strip()
}
sample_ids = list(code_to_id.values())
eta_by_code: Dict[str, Optional[datetime]] = {c: None for c in new_codes}
if sample_ids:
transports = (
db.query(BizResearchDonationSampleTransport)
.filter(
BizResearchDonationSampleTransport.research_donation_id
== protocol_id,
BizResearchDonationSampleTransport.tenant_no == tenant_no,
BizResearchDonationSampleTransport.del_flag == 0,
BizResearchDonationSampleTransport.sample_id.in_(sample_ids),
)
.all()
)
id_to_code = {sid: code for code, sid in code_to_id.items()}
for t in transports:
code = id_to_code.get((t.sample_id or "").strip()) or (
getattr(t, "sample_code", None) or ""
).strip()
if code and code in eta_by_code and t.eta_time and eta_by_code[code] is None:
eta_by_code[code] = t.eta_time
creator = None
if donation.create_by:
creator = (
db.query(SysUser)
.filter(SysUser.id == donation.create_by, SysUser.del_flag == 0)
.first()
)
return {
"create_time": donation.create_time,
"applicant": (creator.nick_name if creator else "") or "",
"eta_by_code": eta_by_code,
}
def run_sample_collect_notify(
db: Session,
*,
tenant_no: str,
protocol_id: str,
protocol_kind: str,
new_sample_codes: Iterable[str],
) -> Dict[str, int]:
"""对新增样本向角色用户发送通知。返回计数。"""
stats = {"codes": 0, "sent": 0, "skipped": 0, "failed": 0}
if not _notify_enabled():
logger.info("sample collect notify skipped: notify disabled or no template")
return stats
codes = [c for c in (str(x).strip() for x in new_sample_codes) if c]
if not codes:
return stats
stats["codes"] = len(codes)
tenant = (tenant_no or "").strip() or "zkxw"
kind = (protocol_kind or "").strip() or PROTOCOL_KIND_CELL
if kind == PROTOCOL_KIND_DONATION:
ctx = _load_donation_context(
db, protocol_id=protocol_id, tenant_no=tenant, new_codes=codes
)
else:
ctx = _load_cell_context(
db, protocol_id=protocol_id, tenant_no=tenant, new_codes=codes
)
if not ctx:
logger.info(
"sample collect notify skip: protocol missing id=%s kind=%s",
protocol_id,
kind,
)
return stats
recipients = list_notify_recipient_users(db, tenant_no=tenant)
if not recipients:
logger.info(
"sample collect notify skip: no recipients tenant=%s role=%s",
tenant,
_role_name(),
)
return stats
from app.services.wechat_mp_message import send_sample_collect_notify
applicant = ctx.get("applicant") or ""
time_text = format_protocol_create_time(ctx.get("create_time"))
task_type = _task_type_for_kind(kind)
eta_by_code: Dict[str, Optional[datetime]] = ctx.get("eta_by_code") or {}
for code in codes:
title = format_sample_collect_title(eta_by_code.get(code))
for user in recipients:
uid = (user.id or "").strip()
mp = (getattr(user, "wx_mp_openid", None) or "").strip()
if not uid or not mp:
stats["skipped"] += 1
continue
if _already_success(db, tenant_no=tenant, sample_code=code, user_id=uid):
stats["skipped"] += 1
continue
result = send_sample_collect_notify(
mp_openid=mp,
title=title,
applicant=applicant,
time_text=time_text,
task_type=task_type,
pagepath="",
)
if result.get("success"):
_write_success_log(
db, tenant_no=tenant, sample_code=code, user_id=uid
)
stats["sent"] += 1
else:
logger.error(
"sample collect notify failed code=%s user=%s err=%s",
code,
uid,
result,
)
stats["failed"] += 1
try:
db.commit()
except Exception:
db.rollback()
logger.exception("sample collect notify commit failed")
raise
return stats
def notify_sample_collect_sync(
*,
tenant_no: str,
protocol_id: str,
protocol_kind: str,
new_sample_codes: List[str],
) -> None:
db = None
try:
db = SessionLocal()
stats = run_sample_collect_notify(
db,
tenant_no=tenant_no,
protocol_id=protocol_id,
protocol_kind=protocol_kind,
new_sample_codes=new_sample_codes,
)
logger.info(
"sample collect notify done protocol=%s kind=%s stats=%s",
protocol_id,
protocol_kind,
stats,
)
except Exception:
logger.exception(
"sample collect notify batch failed protocol=%s kind=%s",
protocol_id,
protocol_kind,
)
finally:
if db is not None:
db.close()
def schedule_sample_collect_notify(
*,
tenant_no: str,
protocol_id: str,
protocol_kind: str,
before_codes: Iterable[str],
submitted_codes: Optional[Iterable[str]],
) -> None:
"""保存成功后调度:submitted_codes 为 None 表示本次未改样本,不推。"""
if not _notify_enabled():
logger.info("sample collect notify schedule skipped: disabled")
return
if submitted_codes is None:
return
new_codes = diff_new_sample_codes(
before_codes=before_codes, submitted_codes=submitted_codes
)
if not new_codes:
return
pid = (protocol_id or "").strip()
if not pid:
return
threading.Thread(
target=notify_sample_collect_sync,
kwargs={
"tenant_no": (tenant_no or "").strip() or "zkxw",
"protocol_id": pid,
"protocol_kind": (protocol_kind or "").strip() or PROTOCOL_KIND_CELL,
"new_sample_codes": new_codes,
},
daemon=True,
name="sample-collect-wx-notify",
).start()
@@ -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 "",
+67
View File
@@ -234,3 +234,70 @@ 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,
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()
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()}
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
+118 -101
View File
@@ -1,11 +1,11 @@
# Graph Report - app (2026-10-22)
# Graph Report - app (2026-10-26)
## 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: 317 files · ~388,611 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)
- 6520 nodes · 36151 edges · 46 communities detected
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 24733 edges (avg confidence: 0.6)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
@@ -46,253 +46,270 @@
- [[_COMMUNITY_Community 34|Community 34]]
- [[_COMMUNITY_Community 35|Community 35]]
- [[_COMMUNITY_Community 36|Community 36]]
- [[_COMMUNITY_Community 37|Community 37]]
- [[_COMMUNITY_Community 38|Community 38]]
- [[_COMMUNITY_Community 39|Community 39]]
- [[_COMMUNITY_Community 40|Community 40]]
- [[_COMMUNITY_Community 41|Community 41]]
- [[_COMMUNITY_Community 42|Community 42]]
- [[_COMMUNITY_Community 43|Community 43]]
- [[_COMMUNITY_Community 44|Community 44]]
- [[_COMMUNITY_Community 45|Community 45]]
- [[_COMMUNITY_Community 46|Community 46]]
- [[_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` - 753 edges
3. `error_response()` - 710 edges
4. `BizQcTaskResult` - 396 edges
5. `BizProductionMaterial` - 378 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]
core/api_request_log_middleware.py → models/models.py
- `get_menu_list()` --calls--> `success_response()` [INFERRED]
api/menu.py → core/response.py
- `菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms)` --uses--> `SysUser` [INFERRED]
core/menu_guard.py → models/models.py
- `运营端角色守卫:admin / super_admin 可访问。` --uses--> `SysUser` [INFERRED]
core/menu_guard.py → models/models.py
- `读取用户授权的菜单权限标识集合(与前端 meta.menuCode / perms 对齐)。` --uses--> `SysUser` [INFERRED]
core/menu_guard.py → models/models.py
- `接口菜单权限守卫(已统一放行 menuCode 校验,仅保留运营端登录)。` --uses--> `SysUser` [INFERRED]
core/menu_guard.py → models/models.py
## Communities
### Community 0 - "Community 0"
Cohesion: 0.01
Nodes (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 (1216): create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post(), create_role_management() (+1208 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)
Nodes (783): validate_ymd(), BaseSettings, Config, secret_key_must_be_strong(), Settings, format_date(), generate_uuid(), allocate_next_biz_no() (+775 more)
### Community 2 - "Community 2"
Cohesion: 0.01
Nodes (730): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+722 more)
Nodes (678): GarmentBatchDeleteBody, GarmentBatchDiscardBody, GarmentCreateBody, GarmentDiscardBody, GarmentUpdateBody, SterilizeLogBatchBody, SterilizeLogCreateBody, SterilizeLogUpdateBody (+670 more)
### Community 3 - "Community 3"
Cohesion: 0.04
Nodes (754): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+746 more)
Cohesion: 0.05
Nodes (549): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+541 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)
Nodes (440): _assert_user_tenant_access(), attachment_download_url(), AttachmentDownloadUrlBody, delete_user(), DeptCreateBody, DeptUpdateBody, dict_data_options(), DictDataCreateBody (+432 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 (382): _normalize_date_bound(), _persist_protocol_sub_resources(), list_ethnics_for_select(), _leave_local_callback(), apply_data_scope(), _get_child_dept_ids(), 数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤) data_scope 值: 1 = 全部数据 2 = 本部门数据 3, 根据用户角色的数据权限过滤查询。 Args: query: SQLAlchemy query 对象 model: 查询 (+374 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 (376): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), _lookup_attachment_by_url(), lookup_attachments_by_urls() (+368 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)
Cohesion: 0.06
Nodes (322): AuditCreateBody, BatchDetailIdsBody, BatchRefundFinanceSubmitBody, FinanceConfirmBody, FullmoonHandleBody, FullmoonTodoInsuranceBody, InvoiceRequestCompleteBody, InvoiceRequestCreateBody (+314 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 (201): 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。, 服务号网页授权 code 绑定当前用户(需 Bearer)。, 微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, web_qr_confirm(), web_qr_scanned(), 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线) (+193 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)
Nodes (146): assert_protocol_status_transition(), format_protocol_status_label(), 解析科研捐赠质检列表的协议状态筛选语义。 Returns: ("in", statuses) — 按所选状态 IN 过滤, 状态流转被拒绝时的提示,状态码展示为中文名称。, resolve_research_donation_protocol_status_filter(), resolve_void_type_label(), status_transition_denied_message(), _ancestor_link_from_cell_product_input() (+138 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)
Nodes (124): _save_sample_payments(), get_protocol_nos_used_in_protocols(), _cell_row_to_dict(), _enrich_cell_dicts_with_live_status(), _find_outbound_cell_row(), _find_outbound_qc_material_row(), _fmt_dt(), get_outbound_material() (+116 more)
### Community 11 - "Community 11"
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)
Cohesion: 0.05
Nodes (114): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, OccupancyCreateBody, 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约) (+106 more)
### Community 12 - "Community 12"
Cohesion: 0.06
Nodes (103): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+95 more)
Cohesion: 0.04
Nodes (104): _apply_submit_receipt(), auto_create_payment(), batch_submit_receipt(), batch_submit_refund(), confirm_refund(), _enrich_payment(), _get_or_raise(), get_payment_by_schedule() (+96 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)
Nodes (98): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+90 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)
Nodes (80): _register_attachment_routes(), _register_freight_routes(), _register_payment_routes(), _register_presign_routes(), _register_protocol_routes(), _collect_descendants(), _find_mobile_root(), list_mobile_menu_items() (+72 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)
Cohesion: 0.06
Nodes (67): _get_current_user_id(), get_sample_insurance(), get_sample_storage_cert(), _get_tenant_no(), put_sample_insurance(), put_sample_storage_cert(), _resolve_protocol_id(), _fmt_dt() (+59 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)
Nodes (64): add_transfer(), assert_presign_convertible(), confirm_receipt(), confirmed_deposit_amount(), confirmed_deposit_amount_map(), convert_to_protocol(), create_presign_order(), _dec() (+56 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)
Cohesion: 0.06
Nodes (45): get_designer_data(), list_results_by_task(), _list_saved_results_by_task(), list_saved_results_by_task_ids(), _merge_saved_into_template(), _resolve_task_item_ref(), _row_to_dict(), save_results_for_task() (+37 more)
### Community 18 - "Community 18"
Cohesion: 0.12
Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块 支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3) Args: out_trade_no: 商户订单号 total, 申请退款 Args: out_trade_no: 商户订单号 total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API) Args: out_trade_no: 商户订单号 tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
### Community 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)
Cohesion: 0.19
Nodes (27): _amount_for_row(), _batch_map(), deduct_for_task(), _deduct_task_usage_legacy_batch(), _display_remark(), _fmt_date_val(), _linked_qc_task_no_map(), list_linked_task_usages() (+19 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)
### 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.12
Nodes (18): get_customer_service_dict(), _get_tenant_no(), ensure_dict_value(), list_dict_values(), _format_date(), _format_datetime(), get_by_sample_code(), _row_field_dict() (+10 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)
### Community 23 - "Community 23"
Cohesion: 0.21
Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more)
### Community 23 - "Community 23"
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)
Cohesion: 0.22
Nodes (15): apply_trip_kit_days(), calc_trip_leg_days(), _day_index(), _end_offset(), _extract_rules(), is_half_day_multiple(), 出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐), 遍历 tripKit:行程未手改则覆盖 duration;出差天数未手改则覆盖为各行程时长之和。 (+7 more)
### Community 25 - "Community 25"
Cohesion: 0.32
Nodes (12): _attach_owner_names(), create_account(), _filtered_query(), get_account(), list_accounts(), list_options(), _owner_name_map(), OA 收款账户 CRUD(oa_receive_account) (+4 more)
Cohesion: 0.24
Nodes (9): 微信消息通知服务 支持微信小程序模板消息推送 使用方式: from app.core.wechat_message import Wech, 发送模板消息 Args: openid: 用户openid templ, send_delivery_notice(), send_delivery_warning(), send_order_status(), send_payment_success(), send_refund_result(), send_urge_notification() (+1 more)
### Community 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"
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 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 (11): _is_empty(), _is_required(), _parse_money(), OA 表单设计器:金额控件 form_data 校验, 校验 form_data 中金额控件的值格式,返回中文错误列表(空表示通过)。, 解析金额值。成功返回 (number, None);失败返回 (None, error_key)。, _rule_props(), _schema_rules() (+3 more)
### Community 30 - "Community 30"
Cohesion: 0.31
Nodes (10): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。, _row_to_dict() (+2 more)
### Community 31 - "Community 31"
Cohesion: 0.29
Nodes (2): BaseModel, SoftDeleteModel
### Community 29 - "Community 29"
### Community 32 - "Community 32"
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 33 - "Community 33"
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"
### Community 34 - "Community 34"
Cohesion: 0.5
Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
### Community 33 - "Community 33"
### Community 35 - "Community 35"
Cohesion: 0.67
Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
### Community 36 - "Community 36"
Cohesion: 0.67
Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。
### Community 34 - "Community 34"
### Community 37 - "Community 37"
Cohesion: 1.0
Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。
### Community 35 - "Community 35"
### Community 38 - "Community 38"
Cohesion: 1.0
Nodes (1): 请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
### Community 36 - "Community 36"
### Community 39 - "Community 39"
Cohesion: 1.0
Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
### Community 39 - "Community 39"
### Community 42 - "Community 42"
Cohesion: 1.0
Nodes (1): 发送订单状态通知 Args: openid: 用户openid ord
### Community 40 - "Community 40"
### Community 43 - "Community 43"
Cohesion: 1.0
Nodes (1): 发送支付成功通知 Args: openid: 用户openid ord
### Community 41 - "Community 41"
### Community 44 - "Community 44"
Cohesion: 1.0
Nodes (1): 发送配送通知 Args: openid: 配送员openid task
### Community 42 - "Community 42"
### Community 45 - "Community 45"
Cohesion: 1.0
Nodes (1): 发送配送时效预警 Args: openid: 配送员openid ta
### Community 43 - "Community 43"
### Community 46 - "Community 46"
Cohesion: 1.0
Nodes (1): 发送退款结果通知 Args: openid: 用户openid ord
### Community 44 - "Community 44"
### Community 47 - "Community 47"
Cohesion: 1.0
Nodes (1): 发送催单通知给配送员 Args: deliver_openid: 配送员openid
## Knowledge Gaps
- **208 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `严格判断当前租户用户是否拥有指定移动市场权限码。`, `为移动市场 API 提供租户化、精确匹配的菜单权限依赖。` (+203 more)
- **209 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `严格判断当前租户用户是否拥有指定移动市场权限码。`, `为移动市场 API 提供租户化、精确匹配的菜单权限依赖。` (+204 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 31`** (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`
- **Thin community `Community 34`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 33`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
- **Thin community `Community 35`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 34`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
- **Thin community `Community 36`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 35`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
- **Thin community `Community 37`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 36`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
- **Thin community `Community 38`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 39`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
- **Thin community `Community 39`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 40`** (1 nodes): `发送支付成功通知 Args: openid: 用户openid ord`
- **Thin community `Community 42`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 41`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
- **Thin community `Community 43`** (1 nodes): `发送支付成功通知 Args: openid: 用户openid ord`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 42`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
- **Thin community `Community 44`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 43`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
- **Thin community `Community 45`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
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`
- **Thin community `Community 46`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
- **Thin community `Community 47`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid`
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `SysUser` connect `Community 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 1`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 17`, `Community 27`, `Community 28`?**
_High betweenness centrality (0.118) - this node is a cross-community bridge._
- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 17`, `Community 19`, `Community 21`, `Community 22`, `Community 23`, `Community 26`, `Community 27`, `Community 28`?**
_High betweenness centrality (0.031) - this node is a cross-community bridge._
- **Why does `OaEngineError` connect `Community 4` to `Community 0`, `Community 1`, `Community 2`?**
_High betweenness centrality (0.031) - this node is a cross-community bridge._
- **Are the 1201 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
_`str` has 1201 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 751 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 751 INFERRED edges - model-reasoned connections that need verification._
- **Are the 709 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
_`error_response()` has 709 INFERRED edges - model-reasoned connections that need verification._
+38479 -29329
View File
File diff suppressed because it is too large. Load diff
+37 -32
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": 1792721833.766789,
"/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,
@@ -88,7 +88,7 @@
"/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/outbound_release_review.py": 1792646414.7740386,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_stock_check.py": 1781423988.9720724,
"/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,
@@ -96,7 +96,7 @@
"/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/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,
@@ -116,17 +116,18 @@
"/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": 1792983363.240947,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/cleanroom_garment.py": 1782264452.4901433,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/temp_qc_target.py": 1787193516.0555077,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_usage.py": 1783428516.0973594,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/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/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/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": 1792646454.611255,
"/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,
@@ -134,7 +135,7 @@
"/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,
@@ -147,7 +148,7 @@
"/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,
@@ -157,7 +158,7 @@
"/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": 1792981510.4123862,
"/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,
@@ -166,13 +167,13 @@
"/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": 1792646845.2075555,
"/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/production_task_set.py": 1792640195.5268464,
"/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/pilot_service.py": 1792641856.3293736,
"/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,
@@ -184,9 +185,9 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_prep.py": 1781514953.3551314,
"/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/sample_storage_cert_qc.py": 1792722971.2429159,
"/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,7 +198,7 @@
"/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/protocol_sample_payment.py": 1792983355.6709538,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_item_param.py": 1782380067.962216,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/leave.py": 1779265839.500242,
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/output_type.py": 1779265703.7363207,
@@ -209,24 +210,24 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/api/material.py": 1782894693.6582327,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/production_material.py": 1782404982.2474263,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/mini_menu.py": 1791861013.3286748,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/qc.py": 1787193588.7157009,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/qc.py": 1792641617.3771758,
"/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": 1792983395.8769655,
"/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/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,
@@ -234,26 +235,28 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_cert_insurance.py": 1791912978.5275548,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/admin.py": 1790217215.444356,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/env_detect.py": 1786945487.513935,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1792199703.3429806,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1792720970.884106,
"/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/protocol_sample_collection.py": 1792377889.4818213,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_payment.py": 1791913783.0750406,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/leave.py": 1790217143.1910717,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/clinical_payment.py": 1781750841.074568,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1792580527.7126133,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1792722281.2525048,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sf_express_settlement.py": 1792575921.313689,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_info_list_enrich.py": 1792376536.1772416,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_period_summary.py": 1791799261.7547023,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_collect_wx_notify.py": 1792980974.5445201,
"/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,
@@ -279,7 +282,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": 1792720843.8847578,
"/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 +290,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": 1792983397.087853,
"/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": 1792721823.419619,
"/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
+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,326 @@
"""样本编码全链路纠错服务测试。"""
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.base import Base
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,
)
from app.services import sample_code_correction as svc
def _session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
BizProtocolSample.__table__,
BizResearchDonationSample.__table__,
BizSampleCode.__table__,
BizProtocolSamplePayment.__table__,
BizProtocolPaymentSchedule.__table__,
BizProtocolPaymentTransfer.__table__,
BizProtocolSampleTransport.__table__,
BizResearchDonationSampleTransport.__table__,
BizSampleFreightItem.__table__,
BizSampleReception.__table__,
BizQcWorkOrder.__table__,
BizQcTask.__table__,
BizStorageReleaseReview.__table__,
BizSampleStorageCertQc.__table__,
BizSampleProductionReception.__table__,
BizProductionWorkOrder.__table__,
BizProductionTaskInput.__table__,
BizSampleStorageCert.__table__,
BizSampleInsurance.__table__,
BizSampleCustomerService.__table__,
BizSampleCodeCorrectionLog.__table__,
BizCellProduct.__table__,
],
)
return sessionmaker(bind=engine)()
def _add_protocol_sample(db, *, code="OLD-001", sample_code_id=None, sid="ps-1"):
now = datetime.now()
row = BizProtocolSample(
id=sid,
collection_protocol_id="cp-1",
sample_code_id=sample_code_id,
sample_code=code,
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
db.add(row)
return row
def test_preview_and_apply_cascades_downstream():
db = _session()
now = datetime.now()
pool = BizSampleCode(
id="pool-old",
sample_code="OLD-001",
issued=1,
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
db.add(pool)
_add_protocol_sample(db, code="OLD-001", sample_code_id="pool-old")
db.add(
BizSampleReception(
id="recv-1",
protocol_sample_id="ps-1",
collection_protocol_id="cp-1",
reception_time=now,
accept_conclusion="js",
sample_code="OLD-001",
reception_source="cell_storage",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizQcWorkOrder(
id="qc-1",
work_order_no="QC-001",
sample_code="OLD-001",
reception_source="cell_storage",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizQcTask(
id="qct-1",
work_order_id="qc-1",
task_no="QCT-001",
task_type="sysz",
source_line_id="line-1",
item_code="ITEM",
item_name="项目",
sample_code="OLD-001",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizProductionWorkOrder(
id="pwo-1",
work_order_no="PWO-001",
sample_code="OLD-001",
reception_source="cell_storage",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizProductionTaskInput(
id="pti-1",
task_id="pt-1",
input_type="sample",
input_code="OLD-001",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizSampleStorageCert(
id="cert-1",
sample_code="OLD-001",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.commit()
preview = svc.preview_correction(
db,
tenant_no="tenant-a",
reception_source="cell_storage",
old_sample_code="OLD-001",
new_sample_code="NEW-001",
)
assert preview["ok"] is True
assert preview["totalAffectedRows"] >= 5
tables = {i["table"] for i in preview["impacts"]}
assert "biz_protocol_sample" in tables
assert "biz_sample_reception" in tables
assert "biz_qc_work_order" in tables
assert "biz_production_task_input" in tables
result = svc.apply_correction(
db,
tenant_no="tenant-a",
reception_source="cell_storage",
old_sample_code="OLD-001",
new_sample_code="NEW-001",
reason="填错编码",
user_id="u1",
)
assert result["ok"] is True
ps = (
db.query(BizProtocolSample)
.filter(BizProtocolSample.id == "ps-1")
.one()
)
assert ps.sample_code == "NEW-001"
pool_row = db.query(BizSampleCode).filter(BizSampleCode.id == "pool-old").one()
assert pool_row.sample_code == "NEW-001"
assert (
db.query(BizSampleReception)
.filter(BizSampleReception.id == "recv-1")
.one()
.sample_code
== "NEW-001"
)
assert (
db.query(BizQcTask).filter(BizQcTask.id == "qct-1").one().sample_code
== "NEW-001"
)
assert (
db.query(BizProductionTaskInput)
.filter(BizProductionTaskInput.id == "pti-1")
.one()
.input_code
== "NEW-001"
)
assert (
db.query(BizSampleStorageCert)
.filter(BizSampleStorageCert.id == "cert-1")
.one()
.sample_code
== "NEW-001"
)
log = db.query(BizSampleCodeCorrectionLog).one()
assert log.old_sample_code == "OLD-001"
assert log.new_sample_code == "NEW-001"
assert log.reason == "填错编码"
def test_preview_blocks_when_new_code_occupied():
db = _session()
_add_protocol_sample(db, code="OLD-001", sid="ps-1")
_add_protocol_sample(db, code="NEW-001", sid="ps-2")
db.commit()
preview = svc.preview_correction(
db,
tenant_no="tenant-a",
reception_source="cell_storage",
old_sample_code="OLD-001",
new_sample_code="NEW-001",
)
assert preview["ok"] is False
assert any("占用" in b for b in preview["blockers"])
def test_donation_source_updates_donation_sample():
db = _session()
now = datetime.now()
db.add(
BizResearchDonationSample(
id="ds-1",
research_donation_id="rd-1",
sample_code="JZ2026010001",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizSampleReception(
id="recv-d",
protocol_sample_id="",
collection_protocol_id="",
donation_sample_id="ds-1",
research_donation_id="rd-1",
reception_time=now,
accept_conclusion="js",
sample_code="JZ2026010001",
reception_source="research_donation",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.commit()
result = svc.apply_correction(
db,
tenant_no="tenant-a",
reception_source="research_donation",
old_sample_code="JZ2026010001",
new_sample_code="JZ2026010099",
reason="捐赠码纠错",
user_id="u1",
)
assert result["ok"] is True
assert (
db.query(BizResearchDonationSample)
.filter(BizResearchDonationSample.id == "ds-1")
.one()
.sample_code
== "JZ2026010099"
)
assert (
db.query(BizSampleReception)
.filter(BizSampleReception.id == "recv-d")
.one()
.sample_code
== "JZ2026010099"
)
@@ -0,0 +1,277 @@
"""协议新增样本服务号通知单测。"""
from datetime import datetime
from unittest.mock import MagicMock, patch
from app.services.sample_collect_wx_notify import (
PROTOCOL_KIND_CELL,
PROTOCOL_KIND_DONATION,
TASK_TYPE_CELL,
TASK_TYPE_DONATION,
diff_new_sample_codes,
format_protocol_create_time,
format_sample_collect_title,
run_sample_collect_notify,
sample_codes_from_orm_rows,
sample_codes_from_payload,
schedule_sample_collect_notify,
)
def test_sample_codes_from_payload_camel_and_snake():
assert sample_codes_from_payload(
[
{"sampleCode": "A1"},
{"sample_code": "A2"},
{"sampleCode": ""},
{},
]
) == {"A1", "A2"}
def test_sample_codes_from_orm_rows():
a = MagicMock(sample_code="X1")
b = MagicMock(sample_code=" ")
c = MagicMock(sample_code="X2")
assert sample_codes_from_orm_rows([a, b, c]) == {"X1", "X2"}
def test_diff_new_sample_codes_create_and_edit():
assert diff_new_sample_codes(before_codes=[], submitted_codes=["b", "a"]) == [
"a",
"b",
]
assert diff_new_sample_codes(
before_codes=["a", "b"], submitted_codes=["a", "b", "c"]
) == ["c"]
assert (
diff_new_sample_codes(before_codes=["a"], submitted_codes=["a"]) == []
)
def test_format_sample_collect_title_with_and_without_eta():
eta = datetime(2022, 11, 22, 16, 17)
assert format_sample_collect_title(eta) == "样本采集(预计到达 11月22日)"
assert format_sample_collect_title(None) == "样本采集(到达待定)"
assert len(format_sample_collect_title(eta)) <= 20
assert len(format_sample_collect_title(None)) <= 20
def test_format_protocol_create_time():
assert (
format_protocol_create_time(datetime(2022, 11, 22, 16, 17))
== "2022年11月22日 16:17"
)
assert format_protocol_create_time(None) == ""
def test_send_sample_collect_notify_builds_data_and_truncates_title():
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_SAMPLE_COLLECT = "tpl-sc"
st.WX_MP_TPL_SC_FIELD_TITLE = "thing2"
st.WX_MP_TPL_SC_FIELD_APPLICANT = "thing8"
st.WX_MP_TPL_SC_FIELD_TIME = "time3"
st.WX_MP_TPL_SC_FIELD_TYPE = "phrase10"
send_tpl.return_value = {"success": True, "msgid": "1"}
from app.services.wechat_mp_message import send_sample_collect_notify
long_title = "样本采集(预计到达 11月22日)额外超长文字会被截断"
result = send_sample_collect_notify(
mp_openid="oid",
title=long_title,
applicant="彭某某",
time_text="2022年11月22日 16:17",
task_type=TASK_TYPE_CELL,
)
assert result["success"] is True
kwargs = send_tpl.call_args.kwargs
assert kwargs["template_id"] == "tpl-sc"
data = kwargs["data"]
assert len(data["thing2"]["value"]) <= 20
assert data["thing8"]["value"] == "彭某某"
assert data["time3"]["value"] == "2022年11月22日 16:17"
assert data["phrase10"]["value"] == "细胞存储"
def test_schedule_skips_when_samples_not_in_payload():
with patch(
"app.services.sample_collect_wx_notify._notify_enabled", return_value=True
), patch(
"app.services.sample_collect_wx_notify.threading.Thread"
) as th:
schedule_sample_collect_notify(
tenant_no="zkxw",
protocol_id="p1",
protocol_kind=PROTOCOL_KIND_CELL,
before_codes=["a"],
submitted_codes=None,
)
th.assert_not_called()
def test_schedule_skips_when_no_new_codes():
with patch(
"app.services.sample_collect_wx_notify._notify_enabled", return_value=True
), patch(
"app.services.sample_collect_wx_notify.threading.Thread"
) as th:
schedule_sample_collect_notify(
tenant_no="zkxw",
protocol_id="p1",
protocol_kind=PROTOCOL_KIND_CELL,
before_codes=["a"],
submitted_codes=["a"],
)
th.assert_not_called()
def test_schedule_starts_thread_for_new_codes():
with patch(
"app.services.sample_collect_wx_notify._notify_enabled", return_value=True
), patch(
"app.services.sample_collect_wx_notify.threading.Thread"
) as th:
th.return_value = MagicMock()
schedule_sample_collect_notify(
tenant_no="zkxw",
protocol_id="p1",
protocol_kind=PROTOCOL_KIND_DONATION,
before_codes=["a"],
submitted_codes=["a", "b"],
)
th.assert_called_once()
kwargs = th.call_args.kwargs
assert kwargs["kwargs"]["new_sample_codes"] == ["b"]
assert kwargs["kwargs"]["protocol_kind"] == PROTOCOL_KIND_DONATION
th.return_value.start.assert_called_once()
def test_run_notify_sends_per_code_per_user_and_idempotent():
db = MagicMock()
user = MagicMock()
user.id = "u1"
user.wx_mp_openid = "mp1"
user.nick_name = "通知人"
with patch(
"app.services.sample_collect_wx_notify._notify_enabled", return_value=True
), patch(
"app.services.sample_collect_wx_notify._load_cell_context",
return_value={
"create_time": datetime(2022, 11, 22, 16, 17),
"applicant": "彭某某",
"eta_by_code": {"S1": datetime(2022, 11, 22, 16, 17), "S2": None},
},
), patch(
"app.services.sample_collect_wx_notify.list_notify_recipient_users",
return_value=[user],
), patch(
"app.services.sample_collect_wx_notify._already_success",
side_effect=[False, True],
) as already, patch(
"app.services.wechat_mp_message.send_sample_collect_notify",
return_value={"success": True, "msgid": "1"},
) as send, patch(
"app.services.sample_collect_wx_notify._write_success_log"
) as write_log:
stats = run_sample_collect_notify(
db,
tenant_no="zkxw",
protocol_id="p1",
protocol_kind=PROTOCOL_KIND_CELL,
new_sample_codes=["S1", "S2"],
)
assert stats["codes"] == 2
assert stats["sent"] == 1
assert stats["skipped"] == 1
assert send.call_count == 1
assert send.call_args.kwargs["task_type"] == TASK_TYPE_CELL
assert send.call_args.kwargs["title"] == "样本采集(预计到达 11月22日)"
assert write_log.call_count == 1
assert already.call_count == 2
db.commit.assert_called_once()
def test_run_notify_donation_task_type():
db = MagicMock()
user = MagicMock()
user.id = "u1"
user.wx_mp_openid = "mp1"
with patch(
"app.services.sample_collect_wx_notify._notify_enabled", return_value=True
), patch(
"app.services.sample_collect_wx_notify._load_donation_context",
return_value={
"create_time": datetime(2022, 11, 22, 16, 17),
"applicant": "李某",
"eta_by_code": {"D1": None},
},
), patch(
"app.services.sample_collect_wx_notify.list_notify_recipient_users",
return_value=[user],
), patch(
"app.services.sample_collect_wx_notify._already_success",
return_value=False,
), patch(
"app.services.wechat_mp_message.send_sample_collect_notify",
return_value={"success": True},
) as send, patch(
"app.services.sample_collect_wx_notify._write_success_log"
):
run_sample_collect_notify(
db,
tenant_no="zkxw",
protocol_id="d1",
protocol_kind=PROTOCOL_KIND_DONATION,
new_sample_codes=["D1"],
)
assert send.call_args.kwargs["task_type"] == TASK_TYPE_DONATION
assert send.call_args.kwargs["title"] == "样本采集(到达待定)"
def test_list_notify_recipient_users_uses_effective_role_including_post():
"""接收人须走有效角色(含职位继承),不能只查 sys_user_role。"""
db = MagicMock()
user = MagicMock()
user.id = "u-post"
user.wx_mp_openid = "mp-openid"
db.query.return_value.filter.return_value.all.return_value = [user]
with patch(
"app.services.sample_collect_wx_notify.list_user_ids_with_effective_role_name",
return_value={"u-post"},
) as list_ids:
from app.services.sample_collect_wx_notify import list_notify_recipient_users
rows = list_notify_recipient_users(db, tenant_no="zkxw")
list_ids.assert_called_once_with(
db, tenant_no="zkxw", role_name="样本采集通知人员"
)
assert rows == [user]
def test_list_notify_recipient_users_dedupes_same_openid():
db = MagicMock()
u1 = MagicMock()
u1.id = "u1"
u1.wx_mp_openid = "same-oid"
u2 = MagicMock()
u2.id = "u2"
u2.wx_mp_openid = "same-oid"
u3 = MagicMock()
u3.id = "u3"
u3.wx_mp_openid = "other-oid"
db.query.return_value.filter.return_value.all.return_value = [u1, u2, u3]
with patch(
"app.services.sample_collect_wx_notify.list_user_ids_with_effective_role_name",
return_value={"u1", "u2", "u3"},
):
from app.services.sample_collect_wx_notify import list_notify_recipient_users
rows = list_notify_recipient_users(db, tenant_no="zkxw")
assert len(rows) == 2
assert {r.wx_mp_openid for r in rows} == {"same-oid", "other-oid"}
+9 -2
View File
@@ -46,6 +46,7 @@ class TestEmptyDetail(unittest.TestCase):
def test_shape(self):
d = empty_detail(
sample_code="B1",
lab_flow_code="LAB-001",
protocol_code="P1",
mother_name="张三",
sample_type_name="围产期造血干细胞",
@@ -54,7 +55,11 @@ class TestEmptyDetail(unittest.TestCase):
self.assertFalse(d["filledFlag"])
self.assertEqual(d["payload"], {})
self.assertEqual(d["sampleCode"], "B1")
self.assertEqual(d["labFlowCode"], "B1")
self.assertEqual(d["labFlowCode"], "LAB-001")
def test_lab_flow_defaults_empty(self):
d = empty_detail(sample_code="B1")
self.assertEqual(d["labFlowCode"], "")
class TestRowToDict(unittest.TestCase):
@@ -72,13 +77,15 @@ class TestRowToDict(unittest.TestCase):
)
d = _row_to_dict(
row,
lab_flow_code="LAB-001",
protocol_code="C1",
mother_name="李",
sample_type_name="围产期造血干细胞",
)
self.assertTrue(d["filledFlag"])
self.assertEqual(d["payload"]["viability_pct"], "90")
self.assertEqual(d["labFlowCode"], "B1")
self.assertEqual(d["sampleCode"], "B1")
self.assertEqual(d["labFlowCode"], "LAB-001")
self.assertEqual(d["protocolCode"], "C1")
@@ -30,6 +30,13 @@ PAYMENT_CONFIRM = "biz:cellStoragePaymentConfirm:list"
REFUND_CONFIRM = "biz:cellStorageRefundConfirm:list"
STORAGE_CERT = "cell-storage:storage-cert:list"
QC_MENUS = {"biz:qcWorkOrder:list", "biz:myQcTask:list"}
STORAGE_CERT_QC_MENUS = {
"biz:qcWorkOrder:list",
"biz:myQcTask:list",
"production:work-order:list",
"production:my-task:list",
"cell-storage:sample-info:list",
}
def _session():
@@ -358,7 +365,7 @@ def _route_strict_codes(router, endpoint_name):
(
"sample_storage_cert_qc",
["put_sample_storage_cert_qc"],
QC_MENUS,
STORAGE_CERT_QC_MENUS,
),
],
)
@@ -0,0 +1,31 @@
-- 微信服务号通知发送日志(幂等)
-- 对齐 docs/superpowers/specs/2026-10-22-basic-experiment-delivery-remind-design.md
CREATE TABLE IF NOT EXISTS biz_wx_notify_log (
id VARCHAR(64) NOT NULL COMMENT '主键ID',
biz_type VARCHAR(64) NOT NULL COMMENT '业务类型,如 basic_experiment_delivery_remind',
biz_id VARCHAR(64) NOT NULL COMMENT '业务主键(如交付计划ID)',
notify_date VARCHAR(10) NOT NULL COMMENT '提醒日 YYYY-MM-DD',
user_id VARCHAR(64) DEFAULT NULL COMMENT '接收用户ID',
status VARCHAR(16) NOT NULL DEFAULT 'success' COMMENT 'success/fail',
errcode VARCHAR(64) DEFAULT NULL COMMENT '微信/内部错误码',
errmsg VARCHAR(512) DEFAULT NULL COMMENT '错误信息',
tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
order_num INT DEFAULT NULL COMMENT '排序号',
remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
update_time DATETIME NOT NULL COMMENT '更新时间',
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (id),
UNIQUE KEY uk_wx_notify_tenant_biz (
tenant_no,
biz_type,
biz_id,
notify_date
),
KEY idx_wx_notify_tenant_time (tenant_no, create_time),
KEY idx_wx_notify_lookup (tenant_no, biz_type, biz_id, notify_date, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='微信服务号通知发送日志';
@@ -0,0 +1,93 @@
-- 细胞产品包装主数据
-- 执行前请确认目标库;可重复执行:表 IF NOT EXISTS,种子数据按编码去重
CREATE TABLE IF NOT EXISTS `biz_cell_product_package` (
`id` VARCHAR(64) NOT NULL COMMENT '主键ID',
`package_code` VARCHAR(64) NOT NULL COMMENT '包装编码',
`package_name` VARCHAR(200) NOT NULL COMMENT '包装名称',
`tenant_no` VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` INT NULL COMMENT '排序号',
`remark` VARCHAR(512) NULL COMMENT '备注',
`create_by` VARCHAR(64) NULL COMMENT '创建人id',
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` DATETIME NOT NULL COMMENT '创建时间',
`update_by` VARCHAR(64) NULL COMMENT '更新人id',
`update_time` DATETIME NOT NULL COMMENT '更新时间',
`del_flag` SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (`id`),
KEY `idx_cell_product_package_tenant` (`tenant_no`, `del_flag`),
KEY `idx_cell_product_package_code` (`tenant_no`, `package_code`, `del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='细胞产品包装';
-- 初始包装类型(租户 zkxw;编码已存在则跳过)
INSERT INTO `biz_cell_product_package` (
`id`, `package_code`, `package_name`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
)
SELECT * FROM (
SELECT
'PKG000000000000000000000000000001' AS `id`,
'BZ01' AS `package_code`,
'冻存管' AS `package_name`,
'zkxw' AS `tenant_no`,
1 AS `order_num`,
'' AS `remark`,
NULL AS `create_by`,
'' AS `create_dept_id`,
NOW() AS `create_time`,
NULL AS `update_by`,
NOW() AS `update_time`,
0 AS `del_flag`
) AS t
WHERE NOT EXISTS (
SELECT 1 FROM `biz_cell_product_package` p
WHERE p.`tenant_no` = 'zkxw' AND p.`package_code` = 'BZ01' AND p.`del_flag` = 0
);
INSERT INTO `biz_cell_product_package` (
`id`, `package_code`, `package_name`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
)
SELECT * FROM (
SELECT
'PKG000000000000000000000000000002' AS `id`,
'BZ02' AS `package_code`,
'离心管' AS `package_name`,
'zkxw' AS `tenant_no`,
2 AS `order_num`,
'' AS `remark`,
NULL AS `create_by`,
'' AS `create_dept_id`,
NOW() AS `create_time`,
NULL AS `update_by`,
NOW() AS `update_time`,
0 AS `del_flag`
) AS t
WHERE NOT EXISTS (
SELECT 1 FROM `biz_cell_product_package` p
WHERE p.`tenant_no` = 'zkxw' AND p.`package_code` = 'BZ02' AND p.`del_flag` = 0
);
INSERT INTO `biz_cell_product_package` (
`id`, `package_code`, `package_name`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
)
SELECT * FROM (
SELECT
'PKG000000000000000000000000000003' AS `id`,
'BZ03' AS `package_code`,
'一次性塑料血袋' AS `package_name`,
'zkxw' AS `tenant_no`,
3 AS `order_num`,
'' AS `remark`,
NULL AS `create_by`,
'' AS `create_dept_id`,
NOW() AS `create_time`,
NULL AS `update_by`,
NOW() AS `update_time`,
0 AS `del_flag`
) AS t
WHERE NOT EXISTS (
SELECT 1 FROM `biz_cell_product_package` p
WHERE p.`tenant_no` = 'zkxw' AND p.`package_code` = 'BZ03' AND p.`del_flag` = 0
);
@@ -0,0 +1,234 @@
-- 修复内置工艺路线缺步骤(2026-10-22)
-- 对齐 docs/superpowers/specs/2026-10-22-cc-builtin-route-steps-fix-design.md
--
-- 现象:CC样本处理-BUILTIN 等内置路线仅有路线头、无 biz_process_route_step,
-- 导致 B* 入库编码自动派发后任务显示「样本处理 0/0」。
-- 策略:对「内置 + 无有效步骤 + 源路线有步骤」的 *-BUILTIN 路线,
-- 从源路线(route_code 去掉 -BUILTIN)深拷贝步骤及产出/数据采集等子表。
-- 幂等:仅当目标内置路线当前 0 条有效步骤时执行;已有步骤则跳过。
-- MySQL 5.7
-- 1) 需要修复的内置路线 ↔ 源路线
DROP TEMPORARY TABLE IF EXISTS tmp_builtin_route_fix;
CREATE TEMPORARY TABLE tmp_builtin_route_fix (
dst_route_id VARCHAR(64) NOT NULL,
src_route_id VARCHAR(64) NOT NULL,
tenant_no VARCHAR(64) NOT NULL,
PRIMARY KEY (dst_route_id)
) ENGINE=MEMORY;
INSERT INTO tmp_builtin_route_fix (dst_route_id, src_route_id, tenant_no)
SELECT
b.id,
(
SELECT s.id
FROM biz_process_route s
WHERE s.tenant_no = b.tenant_no
AND s.route_code = REPLACE(b.route_code, '-BUILTIN', '')
AND s.del_flag = 0
AND IFNULL(s.is_builtin, '0') = '0'
ORDER BY
CASE WHEN IFNULL(s.is_current, '0') = '1' THEN 0 ELSE 1 END,
s.update_time DESC,
s.create_time DESC
LIMIT 1
) AS src_route_id,
b.tenant_no
FROM biz_process_route b
WHERE IFNULL(b.is_builtin, '0') = '1'
AND b.del_flag = 0
AND b.route_code LIKE '%-BUILTIN'
AND NOT EXISTS (
SELECT 1
FROM biz_process_route_step st
WHERE st.route_id = b.id
AND st.del_flag = 0
);
-- 去掉找不到源、或源也无步骤的行
DELETE FROM tmp_builtin_route_fix
WHERE src_route_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM biz_process_route_step st
WHERE st.route_id = tmp_builtin_route_fix.src_route_id
AND st.del_flag = 0
);
-- 2) 源步骤 → 新步骤 ID 映射
DROP TEMPORARY TABLE IF EXISTS tmp_step_id_map;
CREATE TEMPORARY TABLE tmp_step_id_map (
src_step_id VARCHAR(64) NOT NULL,
dst_step_id VARCHAR(64) NOT NULL,
dst_route_id VARCHAR(64) NOT NULL,
tenant_no VARCHAR(64) NOT NULL,
PRIMARY KEY (src_step_id)
) ENGINE=MEMORY;
INSERT INTO tmp_step_id_map (src_step_id, dst_step_id, dst_route_id, tenant_no)
SELECT
ss.id,
-- MD5+源步骤保证同语句多行不撞主键;前缀避免与历史 UUID 冲突
CONCAT('BF', UPPER(MD5(CONCAT('fix-builtin-step:', f.dst_route_id, ':', ss.id)))),
f.dst_route_id,
f.tenant_no
FROM tmp_builtin_route_fix f
INNER JOIN biz_process_route_step ss
ON ss.route_id = f.src_route_id
AND ss.del_flag = 0;
-- 3) 拷贝步骤
INSERT INTO biz_process_route_step (
id, route_id, process_id, step_order, process_code, category_id,
process_name, description, equipment_ids, equipment_name, estimated_duration,
sop_ids, sop_content, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
m.dst_step_id,
m.dst_route_id,
ss.process_id,
ss.step_order,
ss.process_code,
ss.category_id,
ss.process_name,
ss.description,
ss.equipment_ids,
ss.equipment_name,
ss.estimated_duration,
ss.sop_ids,
ss.sop_content,
ss.tenant_no,
ss.order_num,
ss.remark,
ss.create_by,
ss.create_dept_id,
NOW(),
ss.update_by,
NOW(),
0
FROM tmp_step_id_map m
INNER JOIN biz_process_route_step ss ON ss.id = m.src_step_id;
-- 4) 试剂
INSERT INTO biz_process_route_step_reagent (
id, step_id, reagent_code, reagent_name, spec, standard_usage, unit,
is_key_material, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
CONCAT('BF', UPPER(MD5(CONCAT('fix-builtin-reag:', m.dst_step_id, ':', r.id)))),
m.dst_step_id,
r.reagent_code,
r.reagent_name,
r.spec,
r.standard_usage,
r.unit,
r.is_key_material,
r.tenant_no,
r.order_num,
r.remark,
r.create_by,
r.create_dept_id,
NOW(),
r.update_by,
NOW(),
0
FROM tmp_step_id_map m
INNER JOIN biz_process_route_step_reagent r
ON r.step_id = m.src_step_id
AND r.del_flag = 0;
-- 5) 耗材
INSERT INTO biz_process_route_step_consumable (
id, step_id, consumable_code, consumable_name, spec_model, standard_usage,
unit, is_required, remark, tenant_no, order_num,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
CONCAT('BF', UPPER(MD5(CONCAT('fix-builtin-cons:', m.dst_step_id, ':', c.id)))),
m.dst_step_id,
c.consumable_code,
c.consumable_name,
c.spec_model,
c.standard_usage,
c.unit,
c.is_required,
c.remark,
c.tenant_no,
c.order_num,
c.create_by,
c.create_dept_id,
NOW(),
c.update_by,
NOW(),
0
FROM tmp_step_id_map m
INNER JOIN biz_process_route_step_consumable c
ON c.step_id = m.src_step_id
AND c.del_flag = 0;
-- 6) 产出
INSERT INTO biz_process_route_step_output (
id, step_id, output_type_id, output_name, output_barcode, tenant_no,
order_num, remark, create_by, create_dept_id, create_time, update_by,
update_time, del_flag
)
SELECT
CONCAT('BF', UPPER(MD5(CONCAT('fix-builtin-out:', m.dst_step_id, ':', o.id)))),
m.dst_step_id,
o.output_type_id,
o.output_name,
o.output_barcode,
o.tenant_no,
o.order_num,
o.remark,
o.create_by,
o.create_dept_id,
NOW(),
o.update_by,
NOW(),
0
FROM tmp_step_id_map m
INNER JOIN biz_process_route_step_output o
ON o.step_id = m.src_step_id
AND o.del_flag = 0;
-- 7) 数据采集
INSERT INTO biz_process_route_step_data_collection (
id, step_id, collection_name, unit, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
CONCAT('BF', UPPER(MD5(CONCAT('fix-builtin-dc:', m.dst_step_id, ':', d.id)))),
m.dst_step_id,
d.collection_name,
d.unit,
d.tenant_no,
d.order_num,
d.remark,
d.create_by,
d.create_dept_id,
NOW(),
d.update_by,
NOW(),
0
FROM tmp_step_id_map m
INNER JOIN biz_process_route_step_data_collection d
ON d.step_id = m.src_step_id
AND d.del_flag = 0;
-- 8) 校验(人工看结果)
SELECT
b.route_code,
b.route_name,
(SELECT COUNT(*) FROM biz_process_route_step st
WHERE st.route_id = b.id AND st.del_flag = 0) AS step_count
FROM biz_process_route b
WHERE IFNULL(b.is_builtin, '0') = '1'
AND b.del_flag = 0
AND b.route_code LIKE '%-BUILTIN'
ORDER BY b.route_code;
DROP TEMPORARY TABLE IF EXISTS tmp_step_id_map;
DROP TEMPORARY TABLE IF EXISTS tmp_builtin_route_fix;
@@ -0,0 +1,44 @@
-- 交付/输注计划及产品明细增加产品包装字段(透传至生产/质检出库工单展示)
-- 可重复执行:已存在列则跳过
SET @db := DATABASE();
-- biz_pilot_infusion_schedule.package_id
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_pilot_infusion_schedule' AND COLUMN_NAME = 'package_id'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `biz_pilot_infusion_schedule` ADD COLUMN `package_id` VARCHAR(64) NULL COMMENT ''产品包装ID'' AFTER `spec_text`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- biz_pilot_infusion_schedule.package_text
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_pilot_infusion_schedule' AND COLUMN_NAME = 'package_text'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `biz_pilot_infusion_schedule` ADD COLUMN `package_text` VARCHAR(200) NULL COMMENT ''产品包装名称(冗余展示)'' AFTER `package_id`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- biz_pilot_infusion_schedule_product.package_id
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_pilot_infusion_schedule_product' AND COLUMN_NAME = 'package_id'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `biz_pilot_infusion_schedule_product` ADD COLUMN `package_id` VARCHAR(64) NULL COMMENT ''产品包装ID'' AFTER `spec_text`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- biz_pilot_infusion_schedule_product.package_text
SET @exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_pilot_infusion_schedule_product' AND COLUMN_NAME = 'package_text'
);
SET @sql := IF(@exists = 0,
'ALTER TABLE `biz_pilot_infusion_schedule_product` ADD COLUMN `package_text` VARCHAR(200) NULL COMMENT ''产品包装名称(冗余展示)'' AFTER `package_id`',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,24 @@
-- 样本编码全链路纠错审计日志(2026-10-22)
-- 对齐 docs/superpowers/specs/2026-10-22-sample-code-correction-design.md
CREATE TABLE IF NOT EXISTS biz_sample_code_correction_log (
id VARCHAR(64) NOT NULL COMMENT '主键ID',
reception_source VARCHAR(32) NOT NULL COMMENT '来源 cell_storage/research_donation',
old_sample_code VARCHAR(32) NOT NULL COMMENT '旧样本编码',
new_sample_code VARCHAR(32) NOT NULL COMMENT '新样本编码',
impact_json TEXT COMMENT '各表影响行数 JSON',
reason VARCHAR(512) DEFAULT NULL COMMENT '更正原因',
tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
order_num INT DEFAULT NULL COMMENT '排序号',
remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
update_time DATETIME NOT NULL COMMENT '更新时间',
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (id),
KEY idx_scc_tenant_time (tenant_no, create_time),
KEY idx_scc_tenant_old (tenant_no, old_sample_code, del_flag),
KEY idx_scc_tenant_new (tenant_no, new_sample_code, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本编码纠错审计日志';
@@ -0,0 +1,7 @@
-- OA「提到我的」未读:oa_instance_view_grant.is_read
-- 存量行置已读,避免上线后角标暴涨
ALTER TABLE `oa_instance_view_grant`
ADD COLUMN `is_read` SMALLINT NOT NULL DEFAULT 0 COMMENT '是否已读 0未读 1已读' AFTER `granted_by`;
UPDATE `oa_instance_view_grant` SET `is_read` = 1 WHERE `del_flag` = 0;
@@ -0,0 +1,41 @@
-- 样本采集通知人员角色(幂等)
-- 仅插入角色,不挂菜单、不自动绑用户;由运营在「角色管理」中挂人。
-- 对齐 docs/superpowers/specs/2026-10-23-sample-collect-wx-notify-design.md
INSERT INTO sys_role (
id,
role_name,
data_scope,
status,
tenant_no,
order_num,
remark,
create_by,
create_dept_id,
create_time,
update_by,
update_time,
del_flag
)
SELECT
REPLACE(UUID(), '-', ''),
'样本采集通知人员',
'1',
'1',
'zkxw',
0,
'协议新增样本时接收服务号模板消息',
'system',
'',
NOW(),
'system',
NOW(),
0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1
FROM sys_role r
WHERE r.tenant_no = 'zkxw'
AND r.role_name = '样本采集通知人员'
AND r.del_flag = 0
);
@@ -0,0 +1,469 @@
-- =============================================================================
-- 数据修复:马丽娜 / 邓诗婷 样本编码错乱回正
-- 基准备份:zkxw_2026-10-26_092540.sql.gz(执行前请再备份当前库)
--
-- 目标终态:
-- 马丽娜 协议 XBCC20261008000001 → 样本码 ZKZX34000964
-- + 原质检/生产/接收/入库审核/缴费(曾被误改到 1007)一并回到 964
-- 邓诗婷 协议 XBCC20261026000001 → 样本码 ZKZX34000842
-- (不含马丽娜的质检生产)
--
-- 不改:lab_storage_code、biz_cell_product.product_code、短信正文、软删历史行
--
-- 执行方式(MySQL 5.7):
-- 1) 先跑「一、执行前校验」全部 SELECT,确认与预期一致
-- 2) 再跑「二、事务修复」;任一步报错会 ROLLBACK
-- 3) 跑「三、执行后校验」
-- =============================================================================
-- ---------------------------------------------------------------------------
-- 常量(与 09:25 备份一致;若线上 ID 已变,以校验 SELECT 为准再改)
-- ---------------------------------------------------------------------------
-- 租户
-- SET @tenant = 'zkxw';
-- 马丽娜协议 / 活跃样本 / 编码池
-- 6A7CBB85EA024AF08061FD9AFB8DF6EC
-- EC3B60D2880544D9909980D36FF9704E (当前活跃,码=842)
-- BBC01CCFDCA14E7BBE3ED34CE1B1DE47 (池 964)
-- 7C4C8FEDE210453F9D2F357D4F5A46A4 (池 842)
-- 邓诗婷协议 / 活跃样本 / 编码池
-- AAF11BCC7A584B09AFCE17AA8F7B7CD4
-- 21D2D4A063064E0B850F5C807F964079 (当前活跃,码=1007)
-- 817D230359EE4DCEA0F0CB532F099F12 (池 1007)
-- 下游(属马丽娜,当前 sample_code=1007)
-- reception 0E69E16E7E9C4B3AA9D894622BBE237B
-- qc_wo 019FC51312BB4225901014BE90A3AB2E
-- prod_wo 92F20C8B731E474985BB2C6D74A2BA3A
/* =============================================================================
一、执行前校验(只读)—— 全部应对上,再执行第二节
============================================================================= */
SELECT '【预检1】活跃协议样本(期望:马丽娜=842 一条;邓诗婷=1007 一条;964 无活跃)' AS step;
SELECT ps.id,
ps.sample_code,
ps.sample_code_id,
ps.collection_protocol_id,
cp.protocol_no,
ppc.mother_name,
ps.del_flag,
ps.update_time
FROM biz_protocol_sample ps
JOIN biz_collection_protocol cp ON cp.id = ps.collection_protocol_id AND cp.del_flag = 0
LEFT JOIN biz_protocol_party_customer ppc
ON ppc.collection_protocol_id = ps.collection_protocol_id AND ppc.del_flag = 0
WHERE ps.tenant_no = 'zkxw'
AND ps.del_flag = 0
AND ps.sample_code IN ('ZKZX34000842', 'ZKZX34000964', 'ZKZX34001007')
ORDER BY ps.sample_code, ps.create_time;
SELECT '【预检2】马丽娜下游当前码(期望均为 1007,协议仍为马丽娜)' AS step;
SELECT 'reception' AS tbl, id, sample_code, protocol_sample_id, collection_protocol_id, lab_storage_code
FROM biz_sample_reception
WHERE tenant_no = 'zkxw' AND del_flag = 0
AND collection_protocol_id = '6A7CBB85EA024AF08061FD9AFB8DF6EC'
UNION ALL
SELECT 'qc_wo', id, sample_code, protocol_sample_id, collection_protocol_id, work_order_no
FROM biz_qc_work_order
WHERE tenant_no = 'zkxw' AND del_flag = 0
AND collection_protocol_id = '6A7CBB85EA024AF08061FD9AFB8DF6EC'
UNION ALL
SELECT 'prod_wo', id, sample_code, protocol_sample_id, collection_protocol_id, work_order_no
FROM biz_production_work_order
WHERE tenant_no = 'zkxw' AND del_flag = 0
AND collection_protocol_id = '6A7CBB85EA024AF08061FD9AFB8DF6EC'
UNION ALL
SELECT 'storage_rev', id, sample_code, NULL, protocol_id, protocol_no
FROM biz_storage_release_review
WHERE tenant_no = 'zkxw' AND del_flag = 0
AND protocol_id = '6A7CBB85EA024AF08061FD9AFB8DF6EC';
SELECT '【预检3】码分布计数(活跃)' AS step;
SELECT sample_code, COUNT(*) AS cnt
FROM biz_protocol_sample
WHERE tenant_no = 'zkxw' AND del_flag = 0
AND sample_code IN ('ZKZX34000842', 'ZKZX34000964', 'ZKZX34001007')
GROUP BY sample_code;
SELECT sample_code, COUNT(*) AS qc_task_cnt
FROM biz_qc_task
WHERE tenant_no = 'zkxw' AND del_flag = 0
AND sample_code IN ('ZKZX34000842', 'ZKZX34000964', 'ZKZX34001007')
GROUP BY sample_code;
SELECT sample_code, collection_protocol_id, COUNT(*) AS pay_cnt
FROM biz_protocol_sample_payment
WHERE tenant_no = 'zkxw' AND del_flag = 0
AND sample_code IN ('ZKZX34000842', 'ZKZX34000964', 'ZKZX34001007')
GROUP BY sample_code, collection_protocol_id;
/* =============================================================================
二、事务修复
A 马丽娜 842 → 964(仅协议侧;下游此时在 1007,不会被带走)
B 马丽娜协议下 1007 → 964(质检/生产/缴费等,按协议 ID 定向)
C 邓诗婷 1007 → 842
============================================================================= */
START TRANSACTION;
-- 说明:库内同时存在 utf8mb4_unicode_ci / utf8mb4_general_ci 列。
-- 字符串比较一律用 BINARY,避免会话变量 collation 混用报错。
SET NAMES utf8mb4;
SET @now := NOW();
SET @operator := 'data_fix_20261026';
SET @marina_sample_id := NULL;
SET @deng_sample_id := NULL;
SELECT id INTO @marina_sample_id
FROM biz_protocol_sample
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
AND BINARY sample_code = BINARY 'ZKZX34000842'
LIMIT 1;
SELECT id INTO @deng_sample_id
FROM biz_protocol_sample
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY 'AAF11BCC7A584B09AFCE17AA8F7B7CD4'
AND BINARY sample_code = BINARY 'ZKZX34001007'
LIMIT 1;
SET @gate := 0;
SELECT IF(@marina_sample_id IS NULL OR @marina_sample_id = '', 1, 0)
+ IF(@deng_sample_id IS NULL OR @deng_sample_id = '', 1, 0)
+ (SELECT COUNT(*) FROM biz_protocol_sample
WHERE del_flag = 0 AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY sample_code = BINARY 'ZKZX34000964')
+ (SELECT COUNT(*) FROM biz_protocol_sample
WHERE del_flag = 0 AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY sample_code = BINARY 'ZKZX34000842'
AND id <> @marina_sample_id)
INTO @gate;
SELECT @marina_sample_id AS marina_sample_id,
@deng_sample_id AS deng_sample_id,
@gate AS gate_should_be_0;
SET @__abort := (0 + 1) / IF(@gate = 0, 1, 0);
/* ---------- A. 马丽娜活跃样本 842 → 964 + 编码池切换 ---------- */
UPDATE biz_protocol_sample
SET sample_code = 'ZKZX34000964',
sample_code_id = 'BBC01CCFDCA14E7BBE3ED34CE1B1DE47',
update_by = @operator,
update_time = @now
WHERE id = @marina_sample_id
AND del_flag = 0
AND BINARY sample_code = BINARY 'ZKZX34000842';
UPDATE biz_sample_code
SET issued = 1, update_by = @operator, update_time = @now
WHERE BINARY id = BINARY 'BBC01CCFDCA14E7BBE3ED34CE1B1DE47'
AND BINARY tenant_no = BINARY 'zkxw' AND del_flag = 0;
UPDATE biz_sample_code
SET issued = 0, update_by = @operator, update_time = @now
WHERE BINARY id = BINARY '7C4C8FEDE210453F9D2F357D4F5A46A4'
AND BINARY tenant_no = BINARY 'zkxw' AND del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM biz_protocol_sample
WHERE del_flag = 0 AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY sample_code_id = BINARY '7C4C8FEDE210453F9D2F357D4F5A46A4'
AND id <> @marina_sample_id
);
UPDATE biz_protocol_sample_transport
SET sample_code = 'ZKZX34000964',
sample_id = @marina_sample_id,
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
AND BINARY sample_code = BINARY 'ZKZX34000842';
INSERT INTO biz_sample_code_correction_log (
id, reception_source, old_sample_code, new_sample_code, impact_json, reason,
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
) VALUES (
REPLACE(UUID(), '-', ''),
'cell_storage', 'ZKZX34000842', 'ZKZX34000964',
JSON_OBJECT(
'biz_protocol_sample', 1,
'biz_sample_code', 1,
'biz_protocol_sample_transport', 1,
'note', 'stepA_marina_protocol_only'
),
'数据修复:马丽娜协议样本 842→964(下游另按协议定向迁移)',
'zkxw', 0, 'fix_marina_deng_20261026_stepA',
@operator, '', @now, @operator, @now, 0
);
/* ---------- B. 马丽娜协议下 1007 → 964(质检/生产/缴费) ---------- */
UPDATE biz_sample_reception
SET sample_code = 'ZKZX34000964',
protocol_sample_id = @marina_sample_id,
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
AND BINARY sample_code = BINARY 'ZKZX34001007';
UPDATE biz_qc_work_order
SET sample_code = 'ZKZX34000964',
protocol_sample_id = @marina_sample_id,
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
AND BINARY sample_code = BINARY 'ZKZX34001007';
UPDATE biz_qc_task t
JOIN biz_qc_work_order w
ON w.id = t.work_order_id
AND w.del_flag = 0
AND BINARY w.tenant_no = BINARY 'zkxw'
AND BINARY w.collection_protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
SET t.sample_code = 'ZKZX34000964',
t.update_by = @operator,
t.update_time = @now
WHERE t.del_flag = 0
AND BINARY t.tenant_no = BINARY 'zkxw'
AND BINARY t.sample_code = BINARY 'ZKZX34001007';
UPDATE biz_production_work_order
SET sample_code = 'ZKZX34000964',
protocol_sample_id = @marina_sample_id,
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
AND BINARY sample_code = BINARY 'ZKZX34001007';
UPDATE biz_sample_production_reception pr
JOIN biz_sample_reception r
ON r.id = pr.reception_id
AND r.del_flag = 0
AND BINARY r.tenant_no = BINARY 'zkxw'
AND BINARY r.collection_protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
SET pr.sample_code = 'ZKZX34000964',
pr.update_by = @operator,
pr.update_time = @now
WHERE pr.del_flag = 0
AND BINARY pr.tenant_no = BINARY 'zkxw'
AND BINARY pr.sample_code = BINARY 'ZKZX34001007';
UPDATE biz_production_task_input ti
JOIN biz_production_task pt
ON pt.id = ti.task_id AND pt.del_flag = 0 AND BINARY pt.tenant_no = BINARY 'zkxw'
JOIN biz_production_work_order pw
ON pw.id = pt.work_order_id
AND pw.del_flag = 0
AND BINARY pw.tenant_no = BINARY 'zkxw'
AND BINARY pw.collection_protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
SET ti.input_code = 'ZKZX34000964',
ti.update_by = @operator,
ti.update_time = @now
WHERE ti.del_flag = 0
AND BINARY ti.tenant_no = BINARY 'zkxw'
AND BINARY ti.input_type = BINARY 'sample'
AND BINARY ti.input_code = BINARY 'ZKZX34001007';
UPDATE biz_storage_release_review
SET sample_code = 'ZKZX34000964',
sample_name = 'ZKZX34000964',
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
AND BINARY sample_code = BINARY 'ZKZX34001007';
UPDATE biz_protocol_sample_payment
SET sample_code = 'ZKZX34000964',
protocol_sample_id = @marina_sample_id,
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
AND BINARY sample_code = BINARY 'ZKZX34001007';
UPDATE biz_protocol_payment_schedule
SET sample_code = 'ZKZX34000964',
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
AND BINARY sample_code = BINARY 'ZKZX34001007';
UPDATE biz_protocol_payment_transfer
SET sample_code = 'ZKZX34000964',
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY '6A7CBB85EA024AF08061FD9AFB8DF6EC'
AND BINARY sample_code = BINARY 'ZKZX34001007';
INSERT INTO biz_sample_code_correction_log (
id, reception_source, old_sample_code, new_sample_code, impact_json, reason,
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
) VALUES (
REPLACE(UUID(), '-', ''),
'cell_storage', 'ZKZX34001007', 'ZKZX34000964',
JSON_OBJECT('note', 'stepB_marina_downstream_by_protocol'),
'数据修复:马丽娜下游质检/生产/缴费 1007→964(按协议定向,不含邓诗婷)',
'zkxw', 0, 'fix_marina_deng_20261026_stepB',
@operator, '', @now, @operator, @now, 0
);
/* ---------- C. 邓诗婷 1007 → 842 ---------- */
SELECT COUNT(*) INTO @deng_1007_left_qc
FROM biz_qc_work_order
WHERE del_flag = 0 AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY sample_code = BINARY 'ZKZX34001007';
SELECT COUNT(*) INTO @deng_1007_left_prod
FROM biz_production_work_order
WHERE del_flag = 0 AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY sample_code = BINARY 'ZKZX34001007';
SET @gate2 := IF(@deng_1007_left_qc + @deng_1007_left_prod = 0, 0, 1)
+ (SELECT COUNT(*) FROM biz_protocol_sample
WHERE del_flag = 0 AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY sample_code = BINARY 'ZKZX34000842');
SELECT @deng_1007_left_qc AS leftover_qc_on_1007,
@deng_1007_left_prod AS leftover_prod_on_1007,
@gate2 AS gate2_should_be_0;
SET @__abort2 := (0 + 1) / IF(@gate2 = 0, 1, 0);
UPDATE biz_protocol_sample
SET sample_code = 'ZKZX34000842',
sample_code_id = '7C4C8FEDE210453F9D2F357D4F5A46A4',
update_by = @operator,
update_time = @now
WHERE id = @deng_sample_id
AND del_flag = 0
AND BINARY sample_code = BINARY 'ZKZX34001007';
UPDATE biz_sample_code
SET issued = 1, update_by = @operator, update_time = @now
WHERE BINARY id = BINARY '7C4C8FEDE210453F9D2F357D4F5A46A4'
AND BINARY tenant_no = BINARY 'zkxw' AND del_flag = 0;
UPDATE biz_sample_code
SET issued = 0, update_by = @operator, update_time = @now
WHERE BINARY id = BINARY '817D230359EE4DCEA0F0CB532F099F12'
AND BINARY tenant_no = BINARY 'zkxw' AND del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM biz_protocol_sample
WHERE del_flag = 0 AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY sample_code_id = BINARY '817D230359EE4DCEA0F0CB532F099F12'
);
UPDATE biz_protocol_sample_payment
SET sample_code = 'ZKZX34000842',
protocol_sample_id = @deng_sample_id,
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY 'AAF11BCC7A584B09AFCE17AA8F7B7CD4'
AND BINARY sample_code = BINARY 'ZKZX34001007';
UPDATE biz_protocol_payment_schedule
SET sample_code = 'ZKZX34000842',
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY 'AAF11BCC7A584B09AFCE17AA8F7B7CD4'
AND BINARY sample_code = BINARY 'ZKZX34001007';
UPDATE biz_protocol_payment_transfer
SET sample_code = 'ZKZX34000842',
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY 'AAF11BCC7A584B09AFCE17AA8F7B7CD4'
AND BINARY sample_code = BINARY 'ZKZX34001007';
UPDATE biz_protocol_sample_transport
SET sample_code = 'ZKZX34000842',
sample_id = @deng_sample_id,
update_by = @operator,
update_time = @now
WHERE del_flag = 0
AND BINARY tenant_no = BINARY 'zkxw'
AND BINARY collection_protocol_id = BINARY 'AAF11BCC7A584B09AFCE17AA8F7B7CD4'
AND BINARY sample_code = BINARY 'ZKZX34001007';
INSERT INTO biz_sample_code_correction_log (
id, reception_source, old_sample_code, new_sample_code, impact_json, reason,
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
) VALUES (
REPLACE(UUID(), '-', ''),
'cell_storage', 'ZKZX34001007', 'ZKZX34000842',
JSON_OBJECT('note', 'stepC_deng_1007_to_842'),
'数据修复:邓诗婷协议样本 1007→842',
'zkxw', 0, 'fix_marina_deng_20261026_stepC',
@operator, '', @now, @operator, @now, 0
);
COMMIT;
/* =============================================================================
三、执行后校验(期望)
- 马丽娜活跃样本 = 964;质检/生产 sample_code = 964;protocol_sample_id = 马丽娜活跃样本
- 邓诗婷活跃样本 = 842;无 ZJ202610090001 / SC202610090001
- 1007 无活跃协议样本
============================================================================= */
SELECT '【验1】活跃样本归属' AS step;
SELECT ps.sample_code, cp.protocol_no, ppc.mother_name, ps.id AS protocol_sample_id
FROM biz_protocol_sample ps
JOIN biz_collection_protocol cp ON cp.id = ps.collection_protocol_id
LEFT JOIN biz_protocol_party_customer ppc
ON ppc.collection_protocol_id = ps.collection_protocol_id AND ppc.del_flag = 0
WHERE ps.tenant_no = 'zkxw' AND ps.del_flag = 0
AND ps.sample_code IN ('ZKZX34000842', 'ZKZX34000964', 'ZKZX34001007')
ORDER BY ps.sample_code;
SELECT '【验2】马丽娜下游' AS step;
SELECT 'qc_wo' AS tbl, work_order_no, sample_code, protocol_sample_id, collection_protocol_id, conclusion
FROM biz_qc_work_order
WHERE id = '019FC51312BB4225901014BE90A3AB2E'
UNION ALL
SELECT 'prod_wo', work_order_no, sample_code, protocol_sample_id, collection_protocol_id, work_order_status
FROM biz_production_work_order
WHERE id = '92F20C8B731E474985BB2C6D74A2BA3A'
UNION ALL
SELECT 'reception', id, sample_code, protocol_sample_id, collection_protocol_id, lab_storage_code
FROM biz_sample_reception
WHERE id = '0E69E16E7E9C4B3AA9D894622BBE237B';
SELECT '【验3】三码活跃样本数(期望 842=1, 964=1, 1007=0)' AS step;
SELECT sample_code, COUNT(*) AS cnt
FROM biz_protocol_sample
WHERE tenant_no = 'zkxw' AND del_flag = 0
AND sample_code IN ('ZKZX34000842', 'ZKZX34000964', 'ZKZX34001007')
GROUP BY sample_code;
SELECT '【验4】邓诗婷协议下不应有该质检/生产工单' AS step;
SELECT COUNT(*) AS deng_should_be_0
FROM biz_qc_work_order
WHERE tenant_no = 'zkxw' AND del_flag = 0
AND collection_protocol_id = 'AAF11BCC7A584B09AFCE17AA8F7B7CD4';
SELECT COUNT(*) AS leftover_1007_qc_should_be_0
FROM biz_qc_task
WHERE tenant_no = 'zkxw' AND del_flag = 0 AND sample_code = 'ZKZX34001007';
@@ -0,0 +1,81 @@
# 基础实验交付日前一天服务号提醒 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> Spec: `docs/superpowers/specs/2026-10-22-basic-experiment-delivery-remind-design.md`
**Goal:** 每天 10:00 扫描次日交付的基础实验计划,向申请单创建人发送服务号模板消息。
**Architecture:** 纯函数拼接产品名 + 服务层扫描/幂等/发送;复用 `wechat_mp_message._send_template`;APScheduler Cron 注册;`biz_wx_notify_log` 防重。
**Tech Stack:** FastAPI、SQLAlchemy、APScheduler、pytest、微信服务号 template/send
---
### File map
| 文件 | 职责 |
|------|------|
| `docs/sql/migrations/2026-10-22-biz-wx-notify-log.sql` | 幂等日志表 |
| `backend/app/models/models.py` | `BizWxNotifyLog` |
| `backend/app/core/config.py` | 模板 ID 与字段名配置 |
| `backend/app/services/wechat_mp_message.py` | `send_basic_experiment_delivery_remind` |
| `backend/app/services/basic_experiment_delivery_remind.py` | 拼接、扫描、发送、幂等 |
| `backend/app/tasks/__init__.py` | Cron 10:00 |
| `backend/tests/test_basic_experiment_delivery_remind.py` | 单测 |
---
### Task 1: 产品名称拼接(纯函数 TDD)
**Files:**
- Create: `backend/app/services/basic_experiment_delivery_remind.py`
- Test: `backend/tests/test_basic_experiment_delivery_remind.py`
- [x] **Step 1: Write failing tests for `format_delivery_product_name`**
- [x] **Step 2: Implement `format_delivery_product_name`**
- [x] **Step 3: pytest pass**
---
### Task 2: 配置 + 模板发送封装
**Files:**
- Modify: `backend/app/core/config.py`
- Modify: `backend/app/services/wechat_mp_message.py`
- Test: extend `test_basic_experiment_delivery_remind.py`(mock `_send_template`)
- [x] 增加 `WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY` 与字段配置
- [x] 增加 `send_basic_experiment_delivery_remind`
- [x] 单测校验 data 字段键与截断
---
### Task 3: SQL + Model + 扫描发送 + 幂等
**Files:**
- Create: `docs/sql/migrations/2026-10-22-biz-wx-notify-log.sql`
- Modify: `backend/app/models/models.py`
- Modify: `backend/app/services/basic_experiment_delivery_remind.py`
- Test: `backend/tests/test_basic_experiment_delivery_remind.py`
- [x] DDL + `BizWxNotifyLog`
- [x] `run_delivery_remind_for_date(db, *, today)`:查明天交付、basic_experiment、非 yqx;幂等;发送
- [x] 单测:命中/跳过取消/跳过无 openid/幂等二次不发
---
### Task 4: 注册定时任务
**Files:**
- Modify: `backend/app/tasks/__init__.py`
- [x] Cron `hour=10, minute=0` 调用 `run_delivery_remind_job(get_db_session)`
---
### Task 5: 验证 + 图谱
- [x] `pytest backend/tests/test_basic_experiment_delivery_remind.py -v`
- [x] `./scripts/update-graph.sh backend`
@@ -0,0 +1,44 @@
# 细胞产品基础信息维护 · 三 Tab 列表 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将细胞产品基础信息维护从三列看板改为三个 Tab 的标准列表页。
**Architecture:** `index.vue` 仅作 `panel-pill-tabs` 壳 + `?tab=` 同步;价格/规格复用现有列表模式并删除 kanban;新建 `CellProductTypeTab.vue` 承载类型列表 CRUD。
**Tech Stack:** Vue 3 + Element Plus + 现有 `@/api/admin` 细胞产品接口
**Spec:** `docs/superpowers/specs/2026-10-22-cell-product-basic-info-tabs-list-design.md`
---
### Task 1: 新建 CellProductTypeTab.vue
**Files:**
- Create: `web-admin/src/views/pilot-service/cell-product-type/CellProductTypeTab.vue`
- [x] 按 `CellProductSpecTab` 列表模式实现类型列表(筛选/工具栏/表格/分页/弹窗/查看)
- [x] `defineExpose({ handleAdd, loadData })`;类型变更后由壳页可调价格 `loadData`
### Task 2: 重写 index.vue 为 Tab 壳
**Files:**
- Modify: `web-admin/src/views/pilot-service/cell-product-type/index.vue`
- [x] `el-tabs`:price / type / spec;默认 price
- [x] 同步 `route.query.tab`(合法值 price|type|spec)
- [x] 挂载三个子组件;类型变更后刷新价格 Tab(若已挂载)
- [x] 移除统计卡与看板
### Task 3: 价格/规格去掉 kanban
**Files:**
- Modify: `CellProductPriceTab.vue`、`CellProductSpecTab.vue`
- [x] 删除 kanban 模板、`layout` prop、`meta-change`、kanban 样式与 scss import
- [x] **不删除** `cell-product-kanban.scss` 文件本身
### Task 4: 验证
- [x] ESLint / 类型相关无明显错误;手动验收三 Tab 与 `?tab=`
- [x] `./scripts/update-graph.sh frontend`
@@ -0,0 +1,20 @@
# 细胞产品包装 Implementation Plan
> **For agentic workers:** 本计划已在会话内直接执行。
**Goal:** 在细胞产品基础信息维护页追加「产品包装」主数据 Tab(CRUD)。
**Architecture:** 对齐规格主数据:`biz_cell_product_package` + pilot_service CRUD/API + `CellProductPackageTab`。
**Tech Stack:** FastAPI + SQLAlchemy + Vue 3 Element Plus
---
### Task 1: SQL / Model / CRUD / API — done
### Task 2: Frontend Tab + API 类型 — done
### Task 3: 执行迁移 SQL(需用户在目标库执行)
```bash
# 在目标 MySQL 执行
# docs/sql/migrations/2026-10-22-cell-product-package.sql
```
@@ -0,0 +1,29 @@
# 细胞产品按储物格位置搜索 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 「选择细胞产品」弹窗支持按储物格位置与编码/名称同一关键词搜索。
**Architecture:** 前端本地过滤补 `storageSlotName`;后端 `keyword` 通过复用储物格路径匹配得到 slot id 集合后 OR 进列表查询。
**Tech Stack:** Vue 3、FastAPI、SQLAlchemy、既有 `storage_facility` 关键词匹配。
---
### Task 1: 后端 keyword 支持储物格
**Files:**
- Modify: `backend/app/crud/storage_facility.py`(导出 `search_slot_ids_by_keyword`)
- Modify: `backend/app/crud/cell_product.py`(`list_cell_products` keyword)
- Test: `backend/tests/test_cell_product_keyword_slot.py`
- [x] Step 1: 增加 `search_slot_ids_by_keyword` 并在 cell product 列表 keyword 中 OR
- [x] Step 2: 补充单元/轻量测试并跑通
### Task 2: 前端弹窗过滤与文案
**Files:**
- Modify: `web-admin/src/views/production/production-work-order/TaskInputEnhancedTable.vue`
- [x] Step 1: `filteredCellProducts` 匹配 `storageSlotName`;更新 placeholder
- [x] Step 2: 更新 graphify(frontend + backend)
@@ -0,0 +1,349 @@
# 侧栏菜单角标 1 分钟轮询 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 登录后每 60 秒静默刷新 web-admin 侧栏可见菜单的红色角标数字,页签隐藏时暂停,退出登录时停止。
**Architecture:** 新增 `menu-badge-polling.ts` 封装 `refreshAllMenuBadges` + 幂等 start/stop + `visibilitychange` + `inFlight` 锁;在路由守卫菜单就绪后 `startMenuBadgePolling()`(替代三处独立 `void refresh*`),在 `clearSession` 中先 `stop` 再 `clearBadges`。复用现有 OA/质检/生产 count 刷新,不改后端。
**Tech Stack:** Vue 3 + Pinia + TypeScript(web-admin);`node:test` 单测与现有 `src/utils/*.test.ts` 一致。
**Spec:** `docs/superpowers/specs/2026-10-22-menu-badge-polling-design.md`
---
## File map
| 文件 | 职责 |
|------|------|
| Create: `web-admin/src/utils/menu-badge-polling.ts` | 轮询生命周期与 `refreshAll` |
| Create: `web-admin/src/utils/menu-badge-polling.test.ts` | 幂等、inFlight、visibility、stop |
| Modify: `web-admin/src/router/guards/beforeEach.ts` | 菜单就绪后 start 轮询 |
| Modify: `web-admin/src/store/modules/user.ts` | clearSession 时 stop |
| Modify: `docs/superpowers/specs/2026-10-22-menu-badge-polling-design.md` | 状态改为已定稿 |
---
### Task 1: 轮询工具模块 + 单元测试
**Files:**
- Create: `web-admin/src/utils/menu-badge-polling.ts`
- Create: `web-admin/src/utils/menu-badge-polling.test.ts`
- [x] **Step 1: 写失败测试(先造期望行为)**
创建 `web-admin/src/utils/menu-badge-polling.test.ts`:
```ts
import { describe, it, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
// 用可注入依赖测逻辑:测试文件直接测导出函数;对 refresh* 用 mock.module 若环境不支持则改为
// 将 refreshAll 的依赖通过内部可测结构验证 start/stop/visibility/inFlight。
// 本仓库部分测试为纯函数;轮询模块将导出可测状态帮助断言。
import {
refreshAllMenuBadges,
startMenuBadgePolling,
stopMenuBadgePolling,
isMenuBadgePollingActive,
MENU_BADGE_POLL_INTERVAL_MS
} from './menu-badge-polling'
describe('menu-badge-polling', () => {
beforeEach(() => {
stopMenuBadgePolling()
})
afterEach(() => {
stopMenuBadgePolling()
})
it('exports 60s interval', () => {
assert.equal(MENU_BADGE_POLL_INTERVAL_MS, 60_000)
})
it('start is idempotent (single active flag)', () => {
startMenuBadgePolling()
startMenuBadgePolling()
assert.equal(isMenuBadgePollingActive(), true)
stopMenuBadgePolling()
assert.equal(isMenuBadgePollingActive(), false)
})
it('stop is safe when not started', () => {
stopMenuBadgePolling()
assert.equal(isMenuBadgePollingActive(), false)
})
})
```
说明:对 `refreshAllMenuBadges` 与真实 `refreshOa/Qc/Production` 的集成,在实现里用 `Promise.allSettled`;单测以间隔常量、幂等 start/stop 为主,避免依赖 Pinia/HTTP。
- [x] **Step 2: 运行测试确认失败**
```bash
cd web-admin && node --import tsx --test src/utils/menu-badge-polling.test.ts
```
Expected: FAIL(模块不存在)
- [x] **Step 3: 实现 `menu-badge-polling.ts`**
```ts
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
import { refreshQcMenuBadges } from '@/utils/qc-menu-badge'
import { refreshProductionMenuBadges } from '@/utils/production-menu-badge'
export const MENU_BADGE_POLL_INTERVAL_MS = 60_000
let timerId: ReturnType<typeof setInterval> | null = null
let visibilityBound = false
let inFlight = false
let started = false
export function isMenuBadgePollingActive(): boolean {
return started
}
export async function refreshAllMenuBadges(): Promise<void> {
if (inFlight) return
inFlight = true
try {
await Promise.allSettled([
refreshOaMenuBadges(),
refreshQcMenuBadges(),
refreshProductionMenuBadges()
])
} finally {
inFlight = false
}
}
function onVisibilityChange(): void {
if (typeof document === 'undefined') return
if (document.hidden) {
if (timerId != null) {
clearInterval(timerId)
timerId = null
}
return
}
// 恢复可见:立即补刷,再重启 interval
void refreshAllMenuBadges()
if (started && timerId == null) {
timerId = setInterval(() => {
void refreshAllMenuBadges()
}, MENU_BADGE_POLL_INTERVAL_MS)
}
}
function bindVisibility(): void {
if (visibilityBound || typeof document === 'undefined') return
document.addEventListener('visibilitychange', onVisibilityChange)
visibilityBound = true
}
function unbindVisibility(): void {
if (!visibilityBound || typeof document === 'undefined') return
document.removeEventListener('visibilitychange', onVisibilityChange)
visibilityBound = false
}
/** 幂等:已启动则忽略。启动时立即刷一轮,再按 60s 轮询。 */
export function startMenuBadgePolling(): void {
if (started) return
started = true
bindVisibility()
void refreshAllMenuBadges()
if (typeof document !== 'undefined' && document.hidden) {
// 隐藏态不启 timer,等可见时 onVisibilityChange 重启
return
}
timerId = setInterval(() => {
void refreshAllMenuBadges()
}, MENU_BADGE_POLL_INTERVAL_MS)
}
export function stopMenuBadgePolling(): void {
started = false
if (timerId != null) {
clearInterval(timerId)
timerId = null
}
unbindVisibility()
inFlight = false
}
```
注意:路径别名 `@/` 在 `node --test` 下可能解析失败。若 Step 4 因别名失败,改为相对导入:
```ts
import { refreshOaMenuBadges } from './oa-menu-badge'
import { refreshQcMenuBadges } from './qc-menu-badge'
import { refreshProductionMenuBadges } from './production-menu-badge'
```
(与同目录其它 util 互相引用风格一致即可;`beforeEach` / 业务页仍可用 `@/`。)
若相对导入仍因 Pinia/网络依赖导致单测加载失败:将测试收敛为只断言常量与「模块可导出 start/stop 且幂等不抛错」,或把 `refresh*` 注入改为可选参数(YAGNI:优先相对导入 + 仅测 start/stop 幂等;加载连锁失败时在测试文件顶部用注释说明改为手工验收 Task 3)。
推荐稳健写法:轮询模块顶部相对导入三个 refresh;单测只跑 start/stop/常量,不 await `refreshAll`(避免未初始化 Pinia)。`startMenuBadgePolling` 会 `void refreshAllMenuBadges()`——若因此炸测,给 `refreshAllMenuBadges` 增加可选参数或在测试环境吞掉:
```ts
export async function refreshAllMenuBadges(): Promise<void> {
if (inFlight) return
inFlight = true
try {
await Promise.allSettled([
refreshOaMenuBadges().catch(() => undefined),
refreshQcMenuBadges().catch(() => undefined),
refreshProductionMenuBadges().catch(() => undefined)
])
} finally {
inFlight = false
}
}
```
各 `refresh*` 内部已有 catch;`allSettled` 足够。单测环境若在 import 链上失败,则 **跳过自动测**,在 Task 3 手工验收,并在本 Task commit message 注明。
- [x] **Step 4: 再跑测试**
```bash
cd web-admin && node --import tsx --test src/utils/menu-badge-polling.test.ts
```
Expected: PASS(或按上文降级为手工验收并删除/精简测文件)
- [ ] **Step 5: Commit**(仅当用户要求提交时执行;默认实现后由用户决定是否 commit)
```bash
git add web-admin/src/utils/menu-badge-polling.ts web-admin/src/utils/menu-badge-polling.test.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add 60s sidebar menu badge polling utility
EOF
)"
```
---
### Task 2: 路由守卫启动 + 退出停止
**Files:**
- Modify: `web-admin/src/router/guards/beforeEach.ts`(约 305–307 行)
- Modify: `web-admin/src/store/modules/user.ts`(`clearSession` 内 clearBadges 之前)
- [x] **Step 1: 改 beforeEach**
将:
```ts
void refreshQcMenuBadges()
void refreshProductionMenuBadges()
void refreshOaMenuBadges()
```
替换为:
```ts
startMenuBadgePolling()
```
并调整 import:删除三个 `refresh*` 的 import,增加:
```ts
import { startMenuBadgePolling } from '@/utils/menu-badge-polling'
```
`startMenuBadgePolling` 内部已立即 `refreshAll`,行为覆盖原「首次三刷」。
- [x] **Step 2: 改 clearSession**
在 `useQcMenuBadgeStore().clearBadges()` 等三行**之前**插入:
```ts
stopMenuBadgePolling()
```
并增加 import:
```ts
import { stopMenuBadgePolling } from '@/utils/menu-badge-polling'
```
完整片段顺序:
```ts
stopMenuBadgePolling()
useQcMenuBadgeStore().clearBadges()
useProductionMenuBadgeStore().clearBadges()
useOaMenuBadgeStore().clearBadges()
```
- [x] **Step 3: 确认业务页即时 refresh 仍在**
抽查以下文件仍直接调用各自 `refresh*MenuBadges`(不要删):
- `web-admin/src/views/oa-approval/pending/index.vue`
- `web-admin/src/views/oa-approval/drafts/index.vue`
- `web-admin/src/views/quality-inspection/qc-work-order/index.vue`
- `web-admin/src/views/production/my-production-task/index.vue`
- [ ] **Step 4: Commit**(仅用户要求时)
```bash
git add web-admin/src/router/guards/beforeEach.ts web-admin/src/store/modules/user.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): wire menu badge polling to login and logout
EOF
)"
```
---
### Task 3: 规格状态 + graphify + 手工验收
**Files:**
- Modify: `docs/superpowers/specs/2026-10-22-menu-badge-polling-design.md`(状态 → 已定稿)
- [x] **Step 1: 更新规格状态表**
规格状态已是「已定稿(用户确认:60s 轮询)」,无需再改。
- [x] **Step 2: 更新前端知识图谱**
```bash
./scripts/update-graph.sh frontend
```
- [ ] **Step 3: 手工验收清单**
> **待用户验收**:以下 5 项需在浏览器中由用户亲自验证,Agent 不宣称手工 QA 已通过。
1. 登录后侧栏角标有数;DevTools Network 过滤 count / unfinished,约 60s 再出现一批请求。
2. 切到其他页签 >1 分钟再切回:应立刻出现一轮刷新请求。
3. 待办审批成功:角标立刻变,不等下一分钟。
4. 轮询时列表/表单不闪、不 reload。
5. 退出再登录:Network 无双倍 interval(每分钟大约仍是一批,不是两批叠在一起)。
- [ ] **Step 4: Commit docs + graph**(仅用户要求时)
---
## Spec coverage self-check
| 规格要求 | Task |
|----------|------|
| 60s 轮询 | Task 1 `MENU_BADGE_POLL_INTERVAL_MS` |
| 复用三组 refresh,非总接口 | Task 1 `refreshAllMenuBadges` |
| 不整页刷新 | 仅改 store,无路由/列表 reload |
| visibility 暂停/恢复补刷 | Task 1 `onVisibilityChange` |
| 退出 stop + clearBadges | Task 2 |
| 菜单就绪后 start,幂等 | Task 1 + Task 2 beforeEach |
| 业务即时 refresh 保留 | Task 2 Step 3 |
| inFlight 防堆积 | Task 1 |
| graphify | Task 3 |
无 WebSocket、无后端变更、无小程序 —— 符合非目标。
@@ -0,0 +1,147 @@
# 科研捐赠质检协议状态筛选 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 科研捐赠质检(含我的质检任务同 Tab)默认排除协议已作废/属地废弃,并提供协议状态多选筛选;角标口径一致。
**Architecture:** 后端列表/统计对科研捐赠按 `donation_status` 过滤;前端仅在 `inbound_research_donation` Tab 暴露多选并默认勾非废弃;我的任务在现有前端二次过滤中追加 `protocolStatus`。
**Tech Stack:** FastAPI + SQLAlchemy;Vue 3 + Element Plus;pytest / node:test
**Spec:** `docs/superpowers/specs/2026-10-22-qc-research-donation-protocol-status-filter-design.md`
---
## File map
| File | Responsibility |
|------|----------------|
| `backend/app/crud/qc_work_order.py` | `apply_research_donation_protocol_status_filter`;`list_work_orders` / count APIs 接入 |
| `backend/app/api/qc.py` | Query `protocolStatus` 透传 |
| `backend/tests/test_qc_research_donation_protocol_status_filter.py` | 纯函数/过滤语义单测 |
| `web-admin/src/utils/status-tag-registry.ts` | 默认选项、join/match helpers |
| `web-admin/src/utils/qc-mine-task-groups.ts` | mine 二次过滤 |
| `web-admin/src/utils/qc-mine-task-groups.test.ts` | 前端过滤单测 |
| `web-admin/src/api/qc-work-order.ts` | list params 类型 |
| `web-admin/.../useQcWorkOrderTabPage.ts` | searchForm / reset / query |
| `web-admin/.../QcWorkOrderTabPanel.vue` | 筛选项 UI |
---
### Task 1: Backend filter helper + list/count wiring
**Files:**
- Create: `backend/tests/test_qc_research_donation_protocol_status_filter.py`
- Modify: `backend/app/crud/qc_work_order.py`
- Modify: `backend/app/api/qc.py`
- [ ] **Step 1: Write failing tests for resolve semantics**
```python
# backend/tests/test_qc_research_donation_protocol_status_filter.py
from app.core.protocol_status_display import INACTIVE_PROTOCOL_STATUSES
from app.crud.qc_work_order import resolve_research_donation_protocol_status_filter
def test_inactive_constant():
assert set(INACTIVE_PROTOCOL_STATUSES) == {"yzf", "sdfq"}
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_exclude_inactive_for_research_donation():
assert resolve_research_donation_protocol_status_filter(None) == (
"exclude_inactive",
list(INACTIVE_PROTOCOL_STATUSES),
)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && source venv/bin/activate && pytest tests/test_qc_research_donation_protocol_status_filter.py -v`
Expected: FAIL (function missing)
- [ ] **Step 3: Implement helper + apply in list/count**
Add `resolve_research_donation_protocol_status_filter` and `apply_research_donation_protocol_status_filter(query, protocol_status, *, reception_source)` in `qc_work_order.py`.
- Only when `reception_source == "research_donation"` (list) or always for unfinished counts on RD rows.
- Link: `eq_unicode(BizResearchDonation.id, BizQcWorkOrder.collection_protocol_id)` + `del_flag==0`.
- Mode `in`: `exists(... donation_status.in_(statuses))`
- Mode `none`: `id.in_([])`
- Mode `exclude_inactive`: `~and_(reception_source==research_donation, exists(... status in INACTIVE))` for counts; for list with reception_source already RD: `~exists(... status in INACTIVE)` OR require exists with status notin inactive — prefer: `exists(... status.notin_(INACTIVE))` so missing donation also hidden when excluding (align with empty status edge). Spec: empty status not shown under explicit IN; for default exclude, prefer exclude only known inactive (orphans keep) OR hide orphans. Spec §4: 「无捐赠单 / donation_status 为空:显式 IN 过滤时不命中」。Default exclude: only exclude known inactive via `~exists(inactive)` so orphans remain — OK for badges.
Wire `protocol_status` into `list_work_orders` and call exclude on both count functions for RD inactive.
API: add `protocolStatus` Query to `list_qc_work_orders`, pass as `protocol_status=`.
- [ ] **Step 4: Run tests pass**
Run: `pytest tests/test_qc_research_donation_protocol_status_filter.py -v`
Expected: PASS
- [ ] **Step 5: Commit** — skip unless user asks (user rule)
---
### Task 2: Frontend status helpers + mine filter
**Files:**
- Modify: `web-admin/src/utils/status-tag-registry.ts`
- Modify: `web-admin/src/utils/qc-mine-task-groups.ts`
- Create: `web-admin/src/utils/qc-mine-task-groups.test.ts` (or extend if pattern uses assert)
- [ ] **Step 1: Failing tests for match + mine filter**
```ts
// matchesProtocolStatusFilter: undefined → true; [] → false; ['sxz'] matches sxz only
// filterMineEntriesByWorkOrderSearch with protocolStatus:['sxz'] hides yzf
```
- [ ] **Step 2: Implement**
```ts
export const DEFAULT_DONATION_PROTOCOL_STATUS_FILTER = Object.keys(donationStatus).filter(
(k) => !(INACTIVE_PROTOCOL_STATUSES as readonly string[]).includes(k)
)
export const DONATION_PROTOCOL_STATUS_FILTER_OPTIONS = Object.entries(donationStatus).map(...)
export function joinProtocolStatusFilter(statuses: string[]): string | undefined
// Note: empty array → return '' (not undefined) so API gets explicit empty
export function matchesProtocolStatusFilter(status: string, filter: string[] | undefined): boolean
```
Mine filters: add `protocolStatus?: string[]`; when `tabKey === 'inbound_research_donation'` and `protocolStatus !== undefined`, apply `matchesProtocolStatusFilter`.
- [ ] **Step 3: Run frontend tests**
Run: `cd web-admin && pnpm exec tsx --test src/utils/qc-mine-task-groups.test.ts` (or project’s usual test runner)
---
### Task 3: Tab panel UI + composable + API type
**Files:**
- Modify: `web-admin/src/api/qc-work-order.ts`
- Modify: `web-admin/src/views/quality-inspection/qc-work-order/composables/useQcWorkOrderTabPage.ts`
- Modify: `web-admin/src/views/quality-inspection/qc-work-order/tabs/QcWorkOrderTabPanel.vue`
- [ ] searchForm.protocolStatus default `DEFAULT_DONATION_PROTOCOL_STATUS_FILTER`
- [ ] reset restores default
- [ ] manage loadData: if tab is RD, pass `protocolStatus: joinProtocolStatusFilter(...)` (empty → `''`)
- [ ] mine: include protocolStatus in `buildMineWorkOrderSearchFilters` when RD tab
- [ ] UI: after 工单状态,`v-if="tabKey === 'inbound_research_donation'"` multi select both filter blocks
- [ ] Update graph: `./scripts/update-graph.sh all` after code changes
---
### Task 4: Verification
- [ ] Backend pytest for new tests
- [ ] Frontend unit tests
- [ ] Manual checklist from spec §5 (inform user to smoke-test in UI; agent must not restart servers)
@@ -0,0 +1,15 @@
# 样本编码全链路纠错 Implementation Plan
> **For agentic workers:** Implement task-by-task. Spec: `docs/superpowers/specs/2026-10-22-sample-code-correction-design.md`
**Goal:** 提供预览 + 事务级联更正样本编码,覆盖协议/捐赠 SoT 与质检/生产/缴费等下游快照表。
**Architecture:** 服务层 `sample_code_correction` 统一 count/update;API 暴露 preview/apply;审计表落库;样本信息页弹窗入口。
**Tech Stack:** FastAPI + SQLAlchemy;Vue 3 + Element Plus;pytest(sqlite in-memory)
---
### Task 1: SQL + Model + Service + API + Tests + UI
见设计文档表清单与 API 约定;本计划一次性落地。
@@ -0,0 +1,45 @@
# 储存证质检三端可编辑 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 市场 / 质检 / 生产三端均可编辑同一份储存证质检信息(方案 A)。
**Architecture:** 复用 `StorageCertQcDialog` 与现有 GET/PUT;扩展菜单白名单;生产卡片与市场行操作挂载 edit 模式。
**Tech Stack:** FastAPI menu guard;Vue 3 + Element Plus;既有 `biz_sample_storage_cert_qc`。
**Design Doc:** `docs/superpowers/specs/2026-10-22-storage-cert-qc-multi-dept-edit-design.md`
---
## File map
| File | Change |
|------|--------|
| `backend/app/api/sample_storage_cert_qc.py` | READ/WRITE menus |
| `web-admin/.../storage-cert-qc-dialog/index.vue` | 空态文案 |
| `web-admin/.../ProductionWorkOrderCardList.vue` | 按钮 + 弹窗 |
| `web-admin/.../UnifiedSampleInfoPage.vue` | 查看 → 填写 edit |
---
### Task 1: API 菜单白名单
- [x] 将 `READ_MENUS` / `WRITE_MENUS` 设为设计文档列出的五码(读写一致)
### Task 2: 弹窗空态
- [x] 「质检尚未填写」→「尚未填写」
### Task 3: 生产卡片
- [x] `supportsStorageCertQc` + `inbound_cell_storage` 时显示按钮
- [x] 挂载 `StorageCertQcDialog` mode=edit,传入 sampleCode / sampleTypeName
### Task 4: 市场样本信息
- [x] 按钮文案改为「填写储存证质检信息」;dialog mode=edit
### Task 5: 验证
- [x] grep 确认科研捐赠仍隐藏;三端入口与权限码齐全;权限单测通过
@@ -0,0 +1,97 @@
# 临时质检同生产工单成组排序 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 【质检工单】与【我的质检任务】的「临时质检」Tab 按创建时间倒序为基准,并将同一生产工单派发的质检工单成组相邻展示。
**Architecture:** 后端临时列表用聚合子查询 `MAX(create_time)` 按 `production_work_order_id` 成组后 `ORDER BY`;我的任务侧在工单聚合后用同规则前端排序。对齐入库实验室排序的「后端 clauses + 前端 compare」模式。
**Tech Stack:** FastAPI / SQLAlchemy / MySQL 5.7;Vue 3;pytest
**Spec:** `docs/superpowers/specs/2026-10-22-temporary-qc-production-cluster-sort-design.md`
---
## File map
| File | Responsibility |
|------|----------------|
| Create `backend/app/crud/qc_temporary_production_cluster_sort.py` | `uses_temporary_cluster_list_sort` + `temporary_cluster_order_by_clauses(filtered_query)` |
| Modify `backend/app/crud/qc_work_order.py` | 临时列表启用成组 `order_by` |
| Create `backend/tests/test_qc_temporary_production_cluster_sort.py` | 排序断言(可测纯函数或 clauses 拼装;若有 DB fixture 则测 list) |
| Create `web-admin/src/utils/qc-temporary-production-cluster-sort.ts` | `compareTemporaryProductionClusterDesc` |
| Modify `web-admin/src/utils/qc-mine-task-groups.ts` | `sortMineEntriesForTemporary` |
| Modify `web-admin/src/views/.../useQcWorkOrderTabPage.ts` | mine + temporary 时调用新 sort |
---
### Task 1: 后端成组排序辅助模块 + 接入列表
**Files:**
- Create: `backend/app/crud/qc_temporary_production_cluster_sort.py`
- Modify: `backend/app/crud/qc_work_order.py`(`list_*` 中 `order_by` 分支,约 1717–1720 行一带)
- Test: `backend/tests/test_qc_temporary_production_cluster_sort.py`
- [ ] **Step 1: 写失败测试(比较/规则)**
覆盖:同 PWO 相邻且组内时间倒序;组与组之间按组内最新时间;手动单元素组按自身时间。
- [ ] **Step 2: 实现 `qc_temporary_production_cluster_sort.py`**
- `uses_temporary_cluster_list_sort(work_order_type, qc_biz_type)`:临时为 True。
- 对 `_filtered()` 子查询按非空 `production_work_order_id` 聚合 `MAX(create_time)`,`outerjoin` 后:
`ORDER BY coalesce(group_max, create_time) DESC, coalesce(pwo_id, id), create_time DESC`。
- [ ] **Step 3: 在 `list_qc_work_orders` 接入**
```python
if uses_inbound_lab_storage_list_sort(reception_source):
q_page = q_page.order_by(*inbound_lab_storage_order_by_clauses())
elif uses_temporary_cluster_list_sort(work_order_type, ...):
q_page = apply_temporary_cluster_order(q_page, db) # 或 order_by(*clauses)
else:
q_page = q_page.order_by(BizQcWorkOrder.create_time.desc())
```
注意:若存在独立 `list_temporary_*`,一并改掉,避免双路径不一致。
- [ ] **Step 4: 跑测试**
Run: `cd backend && source venv/bin/activate && pytest tests/test_qc_temporary_production_cluster_sort.py -v`
- [ ] **Step 5: Commit**(仅当用户要求时)
---
### Task 2: 前端我的任务成组排序
**Files:**
- Create: `web-admin/src/utils/qc-temporary-production-cluster-sort.ts`
- Modify: `web-admin/src/utils/qc-mine-task-groups.ts`
- Modify: `web-admin/src/views/quality-inspection/qc-work-order/composables/useQcWorkOrderTabPage.ts`
- [ ] **Step 1: 实现 compare + `sortMineEntriesForTemporary`**
与设计 §3 一致:组键 = `productionWorkOrderId || id`;组时 = 组内 max `createTime`;组 DESC,组内 createTime DESC。
- [ ] **Step 2: `useQcWorkOrderTabPage` mine 分支**
```ts
if (isInboundSampleQcTab(tabKey.value)) {
entries = sortMineEntriesForInboundLabStorage(entries)
} else if (tabKey.value === 'temporary') {
entries = sortMineEntriesForTemporary(entries)
}
```
- [ ] **Step 3: 手动或单测核对示例顺序 Q1→Q2,Q3→Q4**
- [ ] **Step 4: 更新 graph(若改了模块边界)** `./scripts/update-graph.sh all`(或 backend + frontend)
---
### Task 3: 验收核对
- [ ] 质检工单临时 Tab:同生产派发相邻
- [ ] 我的质检任务临时 Tab:同上
- [ ] 其它 Tab 无回归
@@ -0,0 +1,18 @@
# OA「提到我的」未读角标 Implementation Plan
> **For agentic workers:** TDD;每步可验证;完成后更新 graph。
**Goal:** 「提到我的」菜单红字角标 = 未读 grant 数;打开详情标已读。
**Tech:** `oa_instance_view_grant.is_read` + count/mark API;复用 `oa-menu-badge` / `oa-badge.js`。
**Spec:** `docs/superpowers/specs/2026-10-23-oa-mention-unread-badge-design.md`
## Tasks
1. SQL migration + ORM `is_read`
2. Backend: count / mark / grant reset / list `isRead` + tests
3. API routes + frontend API clients
4. Web badge store/poll + mentions list mark-read UI
5. mobile-h5 badge + mentions list mark-read
6. Run tests + `./scripts/update-graph.sh backend`(及前端若改多)
@@ -0,0 +1,44 @@
# 协议新增样本服务号通知 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 细胞存储/科研捐赠协议保存后,对新增 sample_code 向角色「样本采集通知人员」发送服务号模板消息。
**Architecture:** 纯函数拼标题 + `sample_collect_wx_notify` 差集/角色/幂等/发送;复用 `_send_template`;API commit 成功后 `threading` 异步调度。
**Tech Stack:** FastAPI、SQLAlchemy、pytest、微信服务号 template/send
**Spec:** `docs/superpowers/specs/2026-10-23-sample-collect-wx-notify-design.md`
---
### Task 1: 配置 + 发送函数
**Files:** `backend/app/core/config.py`, `backend/.env.example`, `backend/app/services/wechat_mp_message.py`
- [x] 增加 `WX_MP_TEMPLATE_SAMPLE_COLLECT` 及字段/角色名配置
- [x] `send_sample_collect_notify(...)`
### Task 2: 通知服务 + 单测
**Files:** `backend/app/services/sample_collect_wx_notify.py`, `backend/tests/test_sample_collect_wx_notify.py`
- [x] 差集、标题、角色查询、幂等、schedule
- [x] pytest
### Task 3: API 钩子
**Files:** `backend/app/api/collection_protocol.py`, `backend/app/api/research_donation.py`
- [x] create/update 成功路径调度通知
### Task 4: 角色种子 SQL
**Files:** `docs/sql/migrations/2026-10-23-sample-collect-notify-role.sql`
- [x] 幂等插入角色「样本采集通知人员」
### Task 5: 验证
- [x] `pytest tests/test_sample_collect_wx_notify.py`
- [x] `./scripts/update-graph.sh backend`
@@ -0,0 +1,36 @@
# 协议编辑锁定已落库样本编码 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 采集协议 / 科研捐赠编辑时,已落库样本编码只读;后端拒绝改码;新增样本仍可选码。
**Architecture:** 共享校验函数比对 payload `id` 与库中 `sample_code`/`sample_code_id`;Web `SampleTransportFormSection` 与小程序 `ProtocolSampleTransportPanel` 按是否有 `id` 锁控件。
**Tech Stack:** FastAPI/SQLAlchemy、Vue 3、mobile-oa-h5、pytest
**Spec:** `docs/superpowers/specs/2026-10-26-protocol-sample-code-lock-design.md`
---
### Task 1: Backend 校验 + 测试
**Files:**
- Modify: `backend/app/crud/collection_protocol.py`
- Modify: `backend/app/crud/research_donation.py`
- Create: `backend/tests/test_protocol_sample_code_lock.py`
- [x] 写失败用例 → 实现 `validate_persisted_sample_codes_immutable` → 接入两处 save → 测试通过
### Task 2: Web UI 锁定
**Files:**
- Modify: `web-admin/src/components/business/sample-transport-form-section/index.vue`
- [x] 编辑且有 `id` 时编码只读 + 提示
### Task 3: 小程序 UI 锁定
**Files:**
- Modify: `mobile-oa-h5/src/market/components/ProtocolSampleTransportPanel.vue`
- [x] 编辑且有 `id` 时编码只读 + 提示
@@ -0,0 +1,147 @@
# 基础实验交付日前一天 · 服务号微信提醒设计
> 日期:2026-10-22
> 工程:`backend`(定时任务 + 服务号模板消息)
> 相关:`docs/superpowers/specs/2026-08-13-oa-wechat-service-notify-design.md`
> 范围:仅 **基础实验申请单**(`biz_type=basic_experiment`)交付计划
---
## 1. 背景与目标
### 1.1 现状
- 基础实验申请单创建时,每行细胞产品独立生成一条交付计划;交付时间存于 `biz_pilot_infusion_schedule.infusion_time`(前端字段 `pickupTime`,界面文案「交付时间」)。
- 服务号模板消息发送链路已存在(`wechat_mp_message._send_template`、`wx_mp_openid` 绑定),目前仅 OA 事件驱动使用。
- APScheduler 已启动,但无业务 Job。
### 1.2 目标
在交付时间**前一天固定 10:00**,向**创建该基础实验申请单的人**发送微信服务号模板消息,提醒次日交付/出库。
### 1.3 非目标
- 中试服务 / 临床应用申请单。
- 向客户、渠道等非系统用户推送。
- 点击消息跳转小程序详情(首期纯模板通知即可;可选带 `miniprogram`,失败则降级,与 OA 一致)。
- 按「交付时间 − 24 小时」精确推送。
---
## 2. 已确认决策
| 决策点 | 结论 |
|--------|------|
| 触发方式 | 每日 **10:00**(服务器本地时区)定时扫描 |
| 扫描条件 | `biz_type=basic_experiment`;交付日 = **明天**;计划未删除;计划状态非已取消(`schedule_status != yqx`) |
| 接收人 | 申请单 `create_by` → `sys_user.wx_mp_openid`;未绑定则 skip + 日志 |
| 模板 ID | `oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM`(配置可覆盖) |
| 出库时间 | 细胞产品行的**交付时间**(`infusion_time`) |
| 产品名称 | `projectName` **拼接** 规格 `specText`、产品包装 `packageText`,格式:`名称(规格、包装)`;缺段时省略空括号内容 |
| 件数 | 产品 `quantity` |
| 客户名称 / 收货联系人 | 申请单 `customer_name` / `customer_contact` |
| 幂等 | 按「交付计划 ID + 提醒日(交付日前一天的日期)」记发送成功记录,防多实例/重跑重复推 |
| 开关 | 沿用 `WX_MP_NOTIFY_ENABLED`;另需本模板 ID 配置非空才发送 |
---
## 3. 模板字段映射
模板 ID:`oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM`
| 模板字段 | 业务含义 | 数据来源 |
|----------|----------|----------|
| `thing6` | 客户名称 | `biz_pilot_application.customer_name` |
| `thing10` | 产品名称 | `project_name(spec_text、package_text)`,见 §2;`thing` 约 20 字截断 |
| `character_string8` | 产品件数 | `biz_pilot_infusion_schedule_product.quantity`(字符串) |
| `thing7` | 收货联系人 | `biz_pilot_application.customer_contact` |
| `time14` | 出库时间 | `biz_pilot_infusion_schedule.infusion_time`,格式 `YYYY-MM-DD HH:MM` |
产品名称拼接规则(空段跳过):
- 三者皆有:`{projectName}({specText}、{packageText})`
- 仅名称+规格:`{projectName}({specText})`
- 仅名称+包装:`{projectName}({packageText})`
- 仅名称:`{projectName}`
基础实验一产品一行计划,取该计划下唯一(或首条)产品明细。
---
## 4. 架构与数据流
```
APScheduler Cron 每天 10:00
→ job: basic_experiment_delivery_remind
→ 查询:明天有 infusion_time 的基础实验交付计划(未取消)
→ 对每条计划:
查申请单 create_by → wx_mp_openid
查幂等记录,已成功则 skip
组装模板 data → _send_template
成功则写幂等记录
```
### 4.1 新增配置(`Settings` / env)
| 配置项 | 说明 | 默认 |
|--------|------|------|
| `WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY` | 模板 ID | 空;部署填入给定 ID |
| `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` |
发送前提与 OA 一致:`WX_MP_NOTIFY_ENABLED=true`、服务号 appid/secret、接收人已绑 openid、已关注服务号。
### 4.2 幂等表
表名建议:`biz_wx_notify_log`(若首期未建 OA 日志表,本需求一并落地;若已有则复用)。
最小字段:
- `id`、`tenant_no`
- `biz_type`:如 `basic_experiment_delivery_remind`
- `biz_id`:交付计划 `schedule_id`
- `notify_date`:提醒日 `YYYY-MM-DD`(交付日前一天)
- `user_id`、`status`(success/fail)、`errcode`、`errmsg`
- 公共字段:`create_by`、`create_time`、`del_flag` 等(符合 database-conventions)
唯一约束:`(tenant_no, biz_type, biz_id, notify_date)` 或等价业务唯一键。
### 4.3 代码落点
| 模块 | 职责 |
|------|------|
| `app/services/wechat_mp_message.py` | 新增 `send_basic_experiment_delivery_remind(...)` |
| `app/services/basic_experiment_delivery_remind.py` | 扫描、组装、幂等、调用发送 |
| `app/tasks/` | 注册 Cron `hour=10, minute=0` |
| `docs/sql/migrations/` | 幂等表 DDL |
| `backend/tests/` | 拼接规则、扫描日界、幂等、跳过未绑定 |
---
## 5. 错误处理
- 发送失败:记日志 + 幂等表 fail(可选);次日不再针对同一提醒日重试(提醒日已过);同日 Job 重跑可再试未 success 的记录。
- 无 openid / 无交付时间 / 已取消:skip,不写 success。
- 推送失败**不**影响申请单业务读写。
---
## 6. 测试要点
- 产品名称拼接与截断。
- 仅 `basic_experiment`;交付日 = 明天才命中。
- 已取消计划不发。
- 同一 schedule + notify_date 第二次不发。
- 无 `wx_mp_openid` 不发。
---
## 7. 部署注意
1. 执行幂等表迁移 SQL。
2. 配置 `WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY` 与 `WX_MP_NOTIFY_ENABLED=true`。
3. 确认创建人已走服务号绑定(与 OA 同一套)。
4. 多实例部署时依赖幂等表防重;若生产多 worker,仅一实例跑 scheduler 或接受幂等冲突吞掉重复。
@@ -0,0 +1,23 @@
# 造血干内置工艺路线缺步骤修复 — 设计
> 日期:2026-10-22
> 状态:已确认(用户同意排查结论后的修复方案)
> 背景:生产备份显示 `B*` 样本自动派发会建出「样本处理 0/0」空任务
## 根因
- 自动派发逻辑正常:接收成功 → 建工单 → 按入库编码匹配内置任务组合 → `create_tasks_from_set`。
- `CC样本处理-BUILTIN`(造血干样本处理内置模版)路线头存在,但 `biz_process_route_step` 为 **0**。
- `_clone_route_to_task` 按路线步骤克隆 → 任务壳有、步骤无 → UI 显示 `0/0`。
- 推测:内置路线头曾先于深拷贝脚本创建;脚本幂等「已存在 `-BUILTIN` 则跳过」,导致步骤未拷入。
## 方案
1. **数据(主修复)**:幂等 SQL——对所有「内置、无有效步骤、源路线有步骤」的 `*-BUILTIN` 路线,从源路线深拷贝步骤及产出/数据采集等子表。
2. **代码防护**:`create_tasks_from_set` 在创建任务前校验条目关联路线至少有 1 个未删除步骤;否则抛错。自动派发已吞异常并打日志,避免再建空任务。
3. **存量任务**:已手工补步或作废的历史单不强制回填;新接收在 SQL 执行后即可正常。
## 非目标
- 不改入库编码前缀匹配规则。
- 不自动把已存在的 0 步任务改绑到非内置路线。
@@ -0,0 +1,64 @@
# 细胞产品基础信息维护 · 三 Tab 列表模式设计
> 日期:2026-10-22
> 路由:`/#/market/market-info/cell-product-type`
> 源码:`web-admin/src/views/pilot-service/cell-product-type/`
## 1. 背景与目标
当前页为三列看板(P04/kanban):顶部统计卡 + 价格 / 类型 / 规格三列卡片流。目标改为 **三个 Tab + 各 Tab 标准列表**,对齐 `production/process`、`pilot-service/channel` 的 `panel-pill-tabs` 写法。
## 2. 已确认决策
| 项 | 决策 |
|----|------|
| 顶部统计卡 | 去掉 |
| 项目价格 | 本次不纳入本页;`ProjectPriceSystemTab` 不动 |
| 默认 Tab | `price`(细胞产品价格) |
| URL | 读写 `?tab=`:`price` \| `type` \| `spec`;非法值(含 `project-price`)回落 `price` |
| 看板代码 | 本页去掉;价格/规格子组件删除 kanban 分支 |
| `cell-product-kanban.scss` | **保留文件**(`lab-test-item`、`process-category` 仍引用) |
## 3. 页面结构
```
page-container
└ panel
└ el-tabs.panel-pill-tabs.panel-pill-tabs--lg
├ 细胞产品价格 (name=price) → CellProductPriceTab
├ 细胞产品类型 (name=type) → CellProductTypeTab(新建)
└ 细胞产品规格 (name=spec) → CellProductSpecTab
```
壳页职责:Tab 切换、`route.query.tab` 同步。类型 CRUD 迁出到 `CellProductTypeTab.vue`。
## 4. 类型 Tab(新建)
对齐 `CellProductSpecTab` 列表模式:
- 筛选:关键词(类型编码/名称)
- 工具栏:已选 N 项 → 新增类型 → 批量删除
- 列:勾选、序号、类型编码、类型名称、备注、创建时间、操作(查看 + 更多:编辑/删除)
- 弹窗:类型编码*、类型名称*、备注;`SystemViewDialog` 查看
- API:现有 `fetchCellProductType*`
- 类型变更后:`expose loadData`;壳页可选通知价格 Tab 刷新(若已挂载)
## 5. 价格 / 规格 Tab
字段、弹窗、API 不变;删除:
- `layout` prop、`meta-change` emit
- kanban 模板与相关 scoped 样式、对本目录 kanban scss 的 import
- `KANBAN_PAGE_SIZE` 与 onMounted 中的 kanban 分支
## 6. 非目标
- 不改后端 / 菜单 / 旧重定向页文件
- 不把项目价格并入本页
- 不删除 `cell-product-kanban.scss`
## 7. 验收
1. 三 Tab 切换正常,默认价格;`?tab=type|spec` 可直达
2. 每 Tab:筛选 / 新增 / 查看 / 编辑 / 删除 / 分页可用
3. 无统计卡、无看板卡片列
@@ -0,0 +1,25 @@
# 细胞产品基础信息 · 产品包装设计
> 日期:2026-10-22
> 菜单位置:市场基础信息 → 细胞产品基础信息维护
## 需求理解
在现有「价格 / 类型 / 规格」三 Tab 下追加 **产品包装** 独立主数据模块,支持增删改查。当前已知包装:冻存管、离心管、一次性塑料血袋(作初始数据,后续可继续追加)。
## 方案
对齐 `biz_cell_product_spec`:
| 项 | 约定 |
|----|------|
| 表 | `biz_cell_product_package` |
| 业务字段 | `package_code`、`package_name`、公共字段 |
| API | `/web/admin/cell-product-packages` CRUD |
| UI | 第 4 Tab「产品包装」;标准列表;`?tab=package` |
| 初始数据 | BZ01 冻存管、BZ02 离心管、BZ03 一次性塑料血袋 |
## 非目标
- 暂不把包装挂到细胞产品库 / 价格 / 生产产出行(仅主数据维护)
- 不改菜单树(仍挂在现有「细胞产品基础信息维护」页)
@@ -0,0 +1,24 @@
# 选择细胞产品:按储物格位置搜索
**日期:** 2026-10-22
**状态:** 已批准(方案 B)
## 背景
生产工单「任务执行」→「原材料」→「添加细胞产品」弹窗「选择细胞产品」中,搜索框仅支持产品编码/名称。表格已展示「储物格位置」(如 `2-3-7-15`),需支持同一搜索框按该字段检索。
## 行为
- 同一关键词输入框;placeholder 改为「搜索产品编码、名称或储物格位置」。
- 匹配规则:产品编码、产品名称、储物格完整路径(`storageSlotName` / `fullSlotCode`)任一包含关键词(不区分大小写)即命中。
- 远程列表与本地二次过滤均需支持,避免仅改前端导致 remote 重拉后漏结果。
## 范围
- 改:弹窗本地过滤 + placeholder;细胞产品列表 API 的 `keyword`。
- 不改:独立筛选项;细胞产品主列表页 UI(API 增强对主列表 keyword 一并生效,可接受)。
## 实现要点
1. 前端 `TaskInputEnhancedTable.vue`:`filteredCellProducts` 增加 `storageSlotName`。
2. 后端 `list_cell_products`:`keyword` OR 匹配关联储物格 ID(复用储物格路径关键词匹配)。
@@ -0,0 +1,18 @@
# 交付计划 · 产品包装透传设计
> 日期:2026-10-22
## 目标
基础实验申请单「交付计划」在指定细胞产品(含规格)的同时指定**产品包装**;创建计划后自动建出的出库生产/质检工单能看到包装(与规格同路径)。
## 方案
| 层 | 改动 |
|----|------|
| 表 | `biz_pilot_infusion_schedule` / `_product` 增加 `package_id`、`package_text` |
| 写入 | `create_schedule` / `_create_schedule_products` / upsert;基础实验 `create_basic_experiment_sale_schedules` 透传 |
| 读出 | 生产/质检 `_enrich_outbound_*`、详情 products、计划 dict |
| UI | 申请单售卖产品行选包装(必填);生产/质检卡片与查看表展示「产品包装」 |
包装主数据来自既有 `biz_cell_product_package`。
@@ -0,0 +1,90 @@
# 侧栏菜单角标 1 分钟轮询 Design
| 项 | 内容 |
|----|------|
| 日期 | 2026-10-22 |
| 状态 | 已定稿(用户确认:60s 轮询) |
| 范围 | web-admin 侧栏红色数字角标定时刷新 |
## 背景
侧栏部分菜单(如「待办中心」、草稿箱、抄送、质检/生产相关菜单)通过红色数字角标展示待处理数量。当前仅在登录后菜单初始化,以及部分业务页操作成功后手动调用 `refresh*MenuBadges()`,无持续更新,用户感知为「数字渲染一次就不变」。
产品确认采用 **方案 1:准实时轮询**,间隔 **60 秒**;不采用 WebSocket/SSE 真推送。
## 目标
- 登录且菜单就绪后,每 **60 秒** 静默刷新侧栏可见菜单的角标数字。
- 仅更新 Pinia 角标 store 中的数字;**不得**触发整页 reload、当前列表重拉或路由重进。
- 页面不可见时暂停轮询;恢复可见时立即补刷一次并重启定时器。
- 退出登录时停止轮询并清空角标(沿用现有 `clearBadges`)。
- 业务操作成功后的即时 `refresh*MenuBadges()` **保留**,不必等下一分钟。
## 非目标
- 不新建「全菜单角标汇总」后端接口。
- 不改 count 接口语义与统计口径。
- 不做 WebSocket / SSE。
- 不改造小程序 / mobile-h5。
- 不改变角标 UI 样式(`99+`、0 隐藏等既有规则不变)。
## 统计方式(复用现状)
**不是**一次接口统计全部菜单。轮询时并行调用现有三组刷新函数:
| 业务 | 函数 | 行为 |
|------|------|------|
| OA | `refreshOaMenuBadges()` | 按侧栏可见菜单,最多并行:抄送未读、草稿数、待办数 |
| 质检 | `refreshQcMenuBadges()` | 按可见菜单,最多:质检工单、我的质检任务 |
| 生产 | `refreshProductionMenuBadges()` | 按可见菜单,最多:生产工单、我的生产任务 |
仅当对应菜单出现在当前用户侧栏菜单树时才请求;无权限/未挂载则跳过。单用户每分钟请求数约 0~7(视可见角标菜单而定)。
## 设计
### 入口与生命周期
新增前端工具(建议路径:`web-admin/src/utils/menu-badge-polling.ts`):
- `startMenuBadgePolling()`:若未在跑,则立即执行一轮 `refreshAllMenuBadges()`,再 `setInterval(60_000)`。
- `stopMenuBadgePolling()`:清除定时器;移除 `visibilitychange` 监听。
- `refreshAllMenuBadges()`:`Promise.all` 调用上述三个 `refresh*`(互不影响失败)。
启动时机:
- 与现有菜单初始化后首次角标刷新对齐:在 `beforeEach` 中菜单注册并 `setMenuList` 之后(现有三处 `void refresh*MenuBadges()` 附近),改为:先立即刷新(或由 `start` 内立即刷新),再 `startMenuBadgePolling()`。
- 须保证**幂等**:重复进入守卫不叠加多个 interval。
停止时机:
- `userStore.clearSession`(退出登录)时调用 `stopMenuBadgePolling()`,再 `clearBadges`(顺序:先停轮询,再清 store)。
### 页面可见性
- 监听 `document.visibilitychange`。
- `document.hidden === true`:暂停 interval(clear),不发请求。
- 恢复可见:立即 `refreshAllMenuBadges()`,再重启 60s interval。
### 并发与错误
- 上一轮未完成时跳过本轮或串行互斥(推荐:简单 `inFlight` 锁,进行中则跳过,避免堆积)。
- 单接口失败:沿用现有各 `refresh*` 内 `.catch` 置 0 / 静默,**不弹**全局错误提示。
- 未登录 / 无菜单列表:`refresh*` 内部已按可见 codes 早退;轮询仍可跑但无请求。
### 压力估算(参考)
- 间隔 60s;50 在线 × 最多 7 请求 ≈ 350 次/分钟量级,count 接口通常可接受。
- 隐藏页签暂停可进一步降低无效请求。
## 验收
1. 登录进入系统后,侧栏角标有初始数字;约 1 分钟后数字可随后端数据变化更新(可用另一账号制造待办验证)。
2. 切到其他浏览器页签超过 1 分钟再切回:应立即刷新一次,而非干等完整周期。
3. 在待办页审批成功:角标仍立即更新(既有即时刷新不丢)。
4. 轮询过程中当前业务列表、表单状态不被重置;无整页闪烁。
5. 退出登录后再登录:只有一个轮询定时器在跑,无重复请求风暴。
## 实现备注
- 改动集中在 web-admin;后端无 schema / API 变更。
- 实现后按仓库约定更新前端 graphify(若触及模块依赖)。
@@ -0,0 +1,90 @@
# 科研捐赠质检 · 协议状态筛选(默认排除废弃)
> 日期:2026-10-22
> 状态:已批准
> 范围:【质检工单】【我的质检任务】→「科研捐赠质检」Tab;Tab 角标与菜单徽章
> 方案:后端 `protocolStatus` 多选 + 前端默认勾选非废弃态
---
## 1. 目标与范围
### 1.1 目标
科研捐赠质检列表默认不展示捐赠协议处于废弃态(`yzf` 已作废、`sdfq` 属地废弃)的样本/工单;用户可在筛选中勾选废弃态查看。角标/徽章与列表默认口径一致。
### 1.2 范围
- 【质检工单】→ `inbound_research_donation` Tab
- 【我的质检任务】→ 同一 Tab
- Tab 未完成角标、菜单徽章中科研捐赠部分
### 1.3 非目标
- 其他质检 Tab(细胞存储入库、出库、临时、「全部」等)
- 样本状态过滤
- 生产工单现有 `excludeVoidProtocol` 逻辑
---
## 2. 产品决策
| 项 | 决策 |
|----|------|
| 筛选形态 | 「协议状态」多选,放在「工单状态」之后 |
| 默认勾选 | `dsx` 待生效、`sxz` 生效中 |
| 其它态 | `ygq`/`zckc`/`yzf`/`sdfq` 默认不勾,可手动勾选 |
| 清空多选 | 无匹配,列表为空(不得视为「全部」) |
| 重置 | 恢复默认「待生效 + 生效中」 |
| 角标 | 与默认列表一致,排除废弃 |
---
## 3. 筛选交互与接口
### 3.1 前端
仅 `tabKey === 'inbound_research_donation'` 展示「协议状态」控件(manage / mine 两套筛选栏)。
选项来自 `donationStatus` 字典;默认值为 `dsx`、`sxz`。
### 3.2 后端列表
`GET /web/admin/qc-work-orders` 增加可选 Query:`protocolStatus`(逗号分隔)。
关联字段:科研捐赠工单 `collection_protocol_id` = 捐赠单 id;过滤 `BizResearchDonation.donation_status`。
语义:
| `protocolStatus` | `receptionSource` | 行为 |
|------------------|-------------------|------|
| 有值(含空串) | `research_donation` | 按解析出的状态 IN 过滤;空列表 → 无结果 |
| 未传(`None`) | `research_donation` | 默认仅 `dsx`/`sxz`(与筛选项默认一致) |
| 任意 | 非科研捐赠 | 忽略该参数 |
### 3.3 我的质检任务
延续现有「任务拉全量 → 工单维度二次过滤」:在 `filterMineEntriesByWorkOrderSearch` 增加 `protocolStatus`;默认与重置同管理端。条目已有 enrich 的 `protocolStatus`。
### 3.4 角标 / 徽章
`count_unfinished_work_orders_by_tab` / `count_mine_unfinished_work_orders_by_tab`:对 `reception_source=research_donation` 的工单默认仅计捐赠状态 ∈ `dsx`/`sxz`。
---
## 4. 数据流与边界
- 无捐赠单 / `donation_status` 为空:显式 IN 过滤时不命中 → 不展示。
- 工单状态与协议状态 AND 生效。
- 卡片协议状态展示逻辑不变。
---
## 5. 验收
1. 打开科研捐赠质检:协议状态默认仅勾「待生效」「生效中」;已过期/转细胞存储/已作废/属地废弃默认不展示。
2. 勾上废弃态后可搜到对应数据。
3. 重置恢复默认。
4. 我的质检任务 · 科研捐赠 Tab 行为一致。
5. 该 Tab 角标与菜单徽章不含废弃口径。
6. 其他 Tab 无回归。
@@ -0,0 +1,137 @@
# 样本编码全链路纠错 — 设计
> 日期:2026-10-22
> 状态:已确认(实现默认)
> 关联:采集协议单 / 科研捐赠;质检接收与工单;生产工单;储存证 / 保险 / 客服
---
## 1. 范围决策
| 项 | 决定 |
|----|------|
| 交付形态 | **产品化能力**:预览 + 事务级联更正 + 审计日志(非一次性手工 SQL) |
| 细胞产品码 | **不自动修改** `biz_cell_product.product_code` / 出库快照;预览可提示「同名产品码条数」供人工评估 |
| 实验室入库码 | **不修改** `lab_storage_code` |
| 来源 | 支持 `cell_storage` 与 `research_donation` |
---
## 2. 目标与非目标
### 2.1 目标
- 将错误样本编码 `oldCode` 更正为 `newCode`,在同一租户、同一来源下级联更新所有以 `sample_code` 为匹配键或快照的业务表。
- 更正前可预览各表影响行数与阻断原因。
- 写入审计日志(操作人、时间、旧→新、影响明细、原因)。
- 入口挂在「样本信息管理 / 客户服务」列表操作列。
### 2.2 非目标
- 不批量改多条样本编码。
- 不改短信历史正文、附件内容。
- 不自动改细胞产品码、出库产品快照。
- 不通过「编辑协议单换码再保存」实现纠错(该路径不回写下游快照)。
---
## 3. 关联键与校验
- 权威键:`tenant_no` + `sample_code`(OpenSpec api-conventions)。
- 来源:`reception_source` ∈ {`cell_storage`, `research_donation`};无来源列的表仅在对应来源下更新。
- 旧码必须存在于对应 SoT 样本表(`biz_protocol_sample` 或 `biz_research_donation_sample`,`del_flag=0`)。
- 新码不得已被其它活跃样本占用(协议 + 捐赠共用命名空间);允许等于旧码时直接拒绝。
- UNIQUE 表(储存证 / 保险 / 客服 / 储存证 QC)若已存在 `newCode` 行且不是本样本迁移目标,则阻断。
### 编码池(细胞存储)
1. 若 `newCode` 已在 `biz_sample_code`:将样本的 `sample_code_id` 指向该池行,并标记 `issued=1`;若旧池行不再被其它活跃样本引用,可释放 `issued=0`。
2. 否则若样本当前绑定池行:将该池行的 `sample_code` 字符串就地改为 `newCode`。
3. 否则仅更新样本行文本码(捐赠场景常见)。
---
## 4. 级联表清单
### 4.1 SoT
- `biz_protocol_sample`(cell_storage)
- `biz_research_donation_sample`(research_donation)
- `biz_sample_code`(按上节策略)
### 4.2 市场附属(主要为 cell_storage)
- `biz_protocol_sample_payment`
- `biz_protocol_payment_schedule`
- `biz_protocol_payment_transfer`
- `biz_protocol_sample_transport`
- `biz_research_donation_sample_transport`(research_donation)
- `biz_sample_freight_item`
### 4.3 质检 / 生产(按 `reception_source` 过滤,无该列则仅按租户+码)
- `biz_sample_reception`
- `biz_qc_work_order`
- `biz_qc_task`
- `biz_storage_release_review`
- `biz_sample_storage_cert_qc`(UNIQUE;细胞存储)
- `biz_sample_production_reception`
- `biz_production_work_order`
- `biz_production_task_input`(`input_type=sample` 且 `input_code=oldCode`)
### 4.4 扩展 UNIQUE(细胞存储)
- `biz_sample_storage_cert`
- `biz_sample_insurance`
- `biz_sample_customer_service`
### 4.5 明确不改
- `lab_storage_code`、`biz_cell_product.product_code`、`biz_outbound_release_cell`、附件与短信正文。
---
## 5. API
前缀:`/web/admin/sample-code-corrections`
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/preview` | 校验 + 返回各表影响行数、阻断原因、可选同名产品码提示 |
| POST | `/apply` | 事务内级联更新 + 写审计日志 |
请求体:
```json
{
"receptionSource": "cell_storage",
"oldSampleCode": "OLD",
"newSampleCode": "NEW",
"newSampleCodeId": null,
"reason": "客户填错编码"
}
```
权限:运营端登录 + 严格菜单码(细胞存储或科研捐赠样本信息 list 任一即可预览/执行;前端按钮随当前 Tab 展示)。
---
## 6. 审计
表 `biz_sample_code_correction_log`:记录租户、来源、旧码、新码、影响 JSON、原因、操作人、公共字段。
---
## 7. UI
样本信息页操作列增加「更正编码」→ 弹窗录入新码与原因 → 先预览 → 确认后 apply → 刷新列表。
---
## 8. 事务顺序
1. 锁/读取 SoT 旧码行,校验新码空闲与 UNIQUE 冲突
2. 更新编码池策略
3. 更新 UNIQUE 扩展表
4. 更新 SoT 与全部快照表
5. 写审计日志并 commit
@@ -0,0 +1,80 @@
# 储存证质检信息 — 三端可编辑(方案 A)
> 日期:2026-10-22
> 状态:已确认
> 关联:`docs/superpowers/specs/2026-10-09-storage-cert-qc-info-design.md`
> 问题2:科研捐赠不展示「填写储存证质检信息」(已落地)
---
## 1. 背景
原设计为质检填写、市场只读查看;生产无入口。客户要求市场、质检、生产三部门共同填写同一张储存证质检信息表。
产品决策:**方案 A** — 三端均可打开完整表编辑保存;后写覆盖;市场由「查看」改为可填。
---
## 2. 目标与非目标
### 2.1 目标
- 质检:保持「细胞存储质检」卡片可填(科研捐赠不展示)。
- 生产:生产工单 / 我的生产任务「细胞储存」卡片增加「填写储存证质检信息」;弹窗与质检相同。
- 市场:样本信息管理(细胞存储)行操作改为可编辑填写(同弹窗)。
- 后端读写权限覆盖三端菜单;共用表 `biz_sample_storage_cert_qc`,不改 schema。
- 未填空态文案改为中性「尚未填写」。
### 2.2 非目标
- 不做字段级部门权限、不做乐观锁/冲突提示。
- 不自动回填检测结果;不扩展 Excel 模板类型。
- 科研捐赠及非两种样本类型仍无填写/查看入口。
---
## 3. 已确认决策
| 决策点 | 结论 |
|--------|------|
| 协作模式 | 同一完整表;后写覆盖 |
| 市场 | 由只读改为可编辑 |
| 生产入口 | 卡片操作区;复用 `StorageCertQcDialog` |
| 显示范围 | 仅细胞储存 + 围产期造血干细胞 / 脐带间充质干细胞 |
| 科研捐赠 | 不展示按钮(问题2) |
| 数据模型 | 不改表;继续整包 payload upsert |
---
## 4. API 权限
路径不变:`GET/PUT /web/admin/sample-storage-cert-qc/{sample_code}`。
**READ_MENUS / WRITE_MENUS**(读写一致):
- `biz:qcWorkOrder:list`
- `biz:myQcTask:list`
- `production:work-order:list`
- `production:my-task:list`
- `cell-storage:sample-info:list`
---
## 5. 前端入口
| 端 | 落点 | 按钮文案 | mode |
|----|------|----------|------|
| 质检 | `QcWorkOrderSingleCard` | 填写储存证质检信息 | edit |
| 生产 | `ProductionWorkOrderCardList` | 填写储存证质检信息 | edit |
| 市场 | `UnifiedSampleInfoPage` | 填写储存证质检信息 | edit |
生产显示条件:`supportsStorageCertQc(sampleTypeName)` 且有效 tab 为 `inbound_cell_storage`(「所有」tab 按行解析)。
---
## 6. 测试要点
- 任一端保存后,另两端打开可见最新 payload。
- 生产/市场菜单用户可 PUT;无菜单 403。
- 科研捐赠卡片无按钮;非支持样本类型无按钮。
- 未填时空态为「尚未填写」。
@@ -0,0 +1,97 @@
# 临时质检:同生产工单成组 + 创建时间倒序 — 设计
> 日期:2026-10-22
> 状态:已确认
> 关联:【质检工单】/【我的质检任务】→「临时质检」Tab
---
## 1. 背景
当前「临时质检」列表统一按质检工单(或任务)`create_time DESC` 排序。同一生产工单多次派发的临时质检会分散在列表各处,不便对照。
客户要求:**排序基准仍是创建时间倒序**;若某条质检工单由生产工单 A 派发,则把 A 派发的相关质检工单**聚在一起展示**。
---
## 2. 范围
| 项 | 决定 |
|----|------|
| 页面 | 【质检工单】临时质检 + 【我的质检任务】临时质检(两处一致) |
| 其它 Tab | 不改(入库 / 出库 / 全部) |
| UI 形态 | **仅排序成组**,不做生产工单可视分组头(非方案 C) |
| 手动创建 | 无 `production_work_order_id`:各自独立,按自身创建时间参与排序(与生产派发混排) |
### 非目标
- 不按生产工单号字典序作为主排序。
- 不强制把手动创建沉到底。
- 不把**未命中当前筛选条件**的同生产工单质检单强行塞进列表(避免破坏状态/关键字筛选语义与分页总数)。
---
## 3. 排序规则(权威)
以**质检工单**为列表行(我的任务侧先按工单聚合后再排):
1. **成组键**
- `production_work_order_id` 非空 → 该生产工单 ID
- 否则 → 本工单 `id`(单元素组)
2. **组位置(基准仍是创建时间)**
- 组排序时间 = 当前**筛选结果集内**、同成组键各行的 `create_time` 的 **MAX**
- 组按该时间 **DESC**(越新的组越靠前)
3. **组内**
- 按质检工单 `create_time` **DESC**
- 稳定次序:同时间再按 `id` DESC(或 `work_order_no` DESC,实现时与现有习惯对齐)
### 示例
| 质检单 | 生产工单 | create_time |
|--------|----------|-------------|
| Q1 | (手动) | 10 |
| Q2 | A | 9 |
| Q3 | A | 5 |
| Q4 | B | 8 |
展示顺序:`Q1` → `Q2, Q3` → `Q4`
(组 A 的位置由组内最新 Q2=9 决定;组内再倒序。)
---
## 4. 实现要点
### 4.1 【质检工单】后端
- 入口:`list_qc_work_orders`(或等价列表 CRUD)在判定为**临时质检查询**时启用成组排序。
- MySQL 5.7 **无窗口函数**:对当前 `_filtered()` 结果做按 `production_work_order_id` 聚合 `MAX(create_time)`,再 `OUTER JOIN` 回主查询。
- `ORDER BY`:`COALESCE(group_max_time, create_time) DESC` → 成组键 → `create_time DESC`。
- 非临时列表仍走现有逻辑(入库实验室编码排序 / 默认 `create_time DESC`)。
### 4.2 【我的质检任务】前端
- 仍:`fetchAllMineTasks` → `groupMineTasksByWorkOrder` → 筛选 → 分页。
- 临时 Tab:在分页前增加与后端规则一致的 `sortMineEntriesForTemporary`(按工单上的 `productionWorkOrderId` + `createTime`)。
- 入库 Tab 的 `sortMineEntriesForInboundLabStorage` 不变。
### 4.3 测试
- 后端:给定多条临时工单(同 PWO / 不同 PWO / 手动),断言列表顺序。
- 前端:纯函数单测或轻量断言成组比较逻辑(若项目已有同类 util 测试则跟随;否则以后端为主)。
---
## 5. 验收
- [ ] 质检工单 → 临时质检:同生产工单派发的卡片相邻;组序符合「组内最新创建时间」倒序;组内创建时间倒序。
- [ ] 我的质检任务 → 临时质检:同上。
- [ ] 筛选(状态/关键字/来源)仍只显示命中行;未命中的同组行不强插。
- [ ] 入库/出库 Tab 排序无回归。
---
## 6. 决策记录
- 2026-10-22:两页都改;方案 A(后端成组排序 + 我的任务前端对齐);手动混排;组位置 = 筛选集内 MAX(create_time)。
@@ -0,0 +1,41 @@
# OA「提到我的」未读角标设计
> 日期:2026-10-23
> 范围:`backend` + `web-admin` + `mobile-h5`
> 相关:抄送未读 `is_read` / `tasks/cc/unread-count`;菜单角标 `oa-menu-badge`
## 1. 目标
「提到我的」菜单展示红色数字角标(与待办/抄送一致):数字 = **当前用户未读的 @ 授权条数**;打开单据详情前标记已读,角标减少。
## 2. 决策
| 点 | 结论 |
|----|------|
| 未读载体 | `oa_instance_view_grant.is_read`(0 未读 / 1 已读) |
| 历史数据 | 迁移时已有行置 `is_read=1`,上线角标从 0 起 |
| 再 @ 同一人同一单 | 已有 grant 时将 `is_read` 重置为 0 |
| 已读时机 | 对齐抄送:从「提到我的」列表打开详情前调 mark-read |
| 角标展示 | Web 侧栏 + 小程序宫格;`>99` 显示 `99+` |
| 列表 UI | 未读行展示红点/未读态(对齐抄送轻量样式) |
## 3. 后端
- SQL:`ALTER` 增加 `is_read SMALLINT NOT NULL DEFAULT 0`;`UPDATE ... SET is_read=1` 对存量。
- ORM:`OaInstanceViewGrant.is_read`。
- `count_mention_unread(tenant, user)`:`del_flag=0` 且 `is_read=0` 的 grant 数(实例仍有效)。
- `mark_mention_read(instance_id)`:当前用户对该实例的 grant 置已读。
- API:`GET /mentions/unread-count`;`POST /mentions/{instance_id}/read`。
- 任务列表 `kind=mention` 行数据带 `isRead`。
- `_ensure_view_grants`:已存在则若未读保持;若已读则重置为未读并更新 `comment_id`/`update_*`。
## 4. 前端
- Web:`oa:mention` 接入 `oa-menu-badge` 轮询;提到我的列表打开前 mark-read + refresh badge。
- 小程序:`oa-badge.js` 增加 mentions path;列表打开前 mark-read;工作台刷新角标。
## 5. 非目标
- 未读筛选 Tab(可后续对齐抄送)。
- 批量已读按钮(可后续)。
- 独立微信模板(已复用抄送模板推送)。
@@ -0,0 +1,186 @@
# 协议新增样本 · 服务号微信通知设计
> 日期:2026-10-23
> 工程:`backend`(协议保存钩子 + 服务号模板消息);角色数据在 `sys_role` / `sys_user_role`
> 相关:`docs/superpowers/specs/2026-08-13-oa-wechat-service-notify-design.md`、`docs/superpowers/specs/2026-10-22-basic-experiment-delivery-remind-design.md`
> 范围:**细胞存储**采集协议单 + **科研捐赠**采集单;Web / 小程序保存入口共用后端
---
## 1. 背景与目标
### 1.1 现状
- Web「采集协议单」与小程序市场专员/地区经理「协议单」保存均走后端协议 create/update(含样本)。
- 服务号模板消息链路已存在:`wechat_mp_message._send_template`、用户 `wx_mp_openid` 绑定、总开关 `WX_MP_NOTIFY_ENABLED`。
- 幂等表 `biz_wx_notify_log` 已落地(基础实验交付提醒复用中)。
- 曾申请「细胞样本采集」类模板被拒;现已选用「工单」类类目模板,按字段长度约束组装文案。
### 1.2 目标
协议**新建或编辑**保存成功后,若存在**新增样本**(相对保存前的 `sample_code`),则向租户内角色 **「样本采集通知人员」** 下已绑定服务号的用户,按**每个新增样本一条**发送服务号模板消息。
### 1.3 非目标
- 服务号一次性订阅通知 / 小程序订阅消息作为主通道。
- 企业微信、短信、邮件。
- 对「仅修改已有样本字段、未新增 sample_code」重复推送。
- 强制未绑定用户完成关注绑定(沿用现有关注/绑定入口即可)。
- 首期可不依赖消息点击跳转;若配置小程序 path,失败则降级为纯通知(与 OA `40165` 策略一致)。
---
## 2. 已确认决策
| 决策点 | 结论 |
|--------|------|
| 推送通道 | 已认证服务号**类目模板消息**(`template/send`),可重复下发 |
| 业务范围 | **细胞存储** + **科研捐赠** |
| 触发时机 | 协议保存成功且 commit 之后;异步发送,失败不阻断保存 |
| 新增样本 | 保存前已有 `sample_code` 集合与本次提交对比;差集为新增;**不用样本行 id**(细胞存储 `save_samples` 会换新 id) |
| 消息粒度 | 一个新增 `sample_code` × 一个通知人 = 一条微信消息 |
| 接收人 | 角色名称精确为「样本采集通知人员」的启用用户;须 `wx_mp_openid` 非空 |
| 模板 ID | `iOnQ9k2mavAOwFbp_6yH4lD-RCNmM_OiwyTgrJfU-_A`(env 可覆盖) |
| 工单标题 | 有 ETA:`样本采集(预计到达 {M}月{D}日)`;无 ETA:`样本采集(到达待定)`;`thing` ≤20 字截断 |
| 发起人 | 协议创建人姓名(`create_by` → `nick_name`) |
| 发起时间 | 协议 `create_time`(编辑场景仍用创建时间,非本次保存时间) |
| 任务类型 | 细胞存储 → `细胞存储`;科研捐赠 → `科研捐赠`(`phrase`,纯汉字短词) |
| 预计到达 | 该样本运输明细 `eta_time`(仅月日写入标题) |
| 幂等 | 同一租户下,同一 `sample_code` 对同一用户**终身只成功推一次** |
| 开关 | `WX_MP_NOTIFY_ENABLED=true` 且本模板 ID 非空才发送 |
---
## 3. 模板字段映射
模板 ID:`iOnQ9k2mavAOwFbp_6yH4lD-RCNmM_OiwyTgrJfU-_A`
| 模板字段 | 业务含义 | 赋值规则 |
|----------|----------|----------|
| `thing2` | 工单标题 | 见 §2;总长超过 20 字符则截断 |
| `thing8` | 发起人 | 创建人姓名,`thing` 截断 |
| `time3` | 发起时间 | `create_time`,格式如 `2022年11月22日 16:17` |
| `phrase10` | 任务类型 | 仅 `细胞存储` 或 `科研捐赠` |
标题拼装伪代码:
```text
if eta_time:
title = f"样本采集(预计到达 {eta.month}月{eta.day}日)"
else:
title = "样本采集(到达待定)"
title = truncate_thing(title, 20)
```
---
## 4. 架构与数据流
```
Web / 小程序
→ API:细胞存储 create/update 或 科研捐赠 create/update(含样本)
→ CRUD 保存协议 + 样本,记录「保存前 sample_code 集合」
→ commit 成功
→ 异步:sample_collect_wx_notify
├─ new_codes = submitted_codes - before_codes
├─ new_codes 为空 → return
├─ 查角色「样本采集通知人员」→ 用户列表(tenant + del_flag=0 + 角色启用)
├─ 过滤 wx_mp_openid
└─ for code in new_codes:
for user in recipients:
幂等已成功 → skip
组装 data(标题用该样本 eta)→ _send_template
写 biz_wx_notify_log
```
### 4.1 新增判定
| 场景 | before_codes | 行为 |
|------|--------------|------|
| 新建协议 | 空集 | 本次所有非空 `sample_code` 均通知 |
| 编辑:追加样本 | 旧码集合 | 仅差集通知 |
| 编辑:只改金额/ETA 等 | 码不变 | 不通知 |
| 编辑:删除后再用**同一** code 加回 | 若保存前已不存在该 code,则算新增并可能再次通知;若幂等已成功过则仍 skip | 以幂等为准,避免对同一人同码刷屏 |
空 `sample_code` 的行不参与对比与发送。
### 4.2 接收人解析
1. 有效拥有角色名「样本采集通知人员」的用户(与登录权限一致):
- 直接:`sys_user_role` → `sys_role`
- **或经职位继承**:`sys_user_post` → 启用 `sys_post` → `sys_post_role` → `sys_role`
2. 复用 `list_user_ids_with_effective_role_name`;同 `tenant_no`,角色/职位启用且未删除。
3. `wx_mp_openid` 非空才进入发送队列;否则跳过。
运营侧常见做法:角色挂到职位,用户绑职位(不必直接绑角色)。可选 SQL 仅种子角色,不自动绑职位/用户。
### 4.3 幂等(复用 `biz_wx_notify_log`)
| 字段 | 取值 |
|------|------|
| `biz_type` | `sample_collect_notify` |
| `biz_id` | `{sample_code}:{user_id}` |
| `notify_date` | 哨兵 `1970-01-01`(表示「该样本对该人只推一次」,与按日提醒区分) |
| `user_id` | 接收用户 id |
| `status` | `success` / `fail` |
发送前查是否已存在同租户 + 上述键且 `status=success`;有则 skip。
唯一约束沿用表上 `(tenant_no, biz_type, biz_id, notify_date)`:因 `biz_id` 已含 user,冲突即表示已记过一条;失败记录允许重试覆盖或再插(实现时与基础实验提醒对齐:成功才挡重发)。
### 4.4 配置(`Settings` / env)
| 配置项 | 说明 | 默认 |
|--------|------|------|
| `WX_MP_TEMPLATE_SAMPLE_COLLECT` | 模板 ID | 部署填入给定 ID |
| `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_SAMPLE_COLLECT_ROLE_NAME` | 通知角色名 | `样本采集通知人员` |
### 4.5 代码落点
| 模块 | 职责 |
|------|------|
| `app/services/wechat_mp_message.py` | `send_sample_collect_notify(...)` |
| `app/services/sample_collect_wx_notify.py` | 差集、查角色、幂等、组文案、调用发送 |
| `app/api/collection_protocol.py`(及小程序共用 service/API) | commit 后调度异步通知 |
| `app/api` / crud 科研捐赠保存路径 | 同上 |
| `app/core/config.py`、`.env.example` | 新配置项 |
| `docs/sql/migrations/` | 可选:种子角色「样本采集通知人员」 |
| `backend/tests/` | 差集、文案、幂等、角色过滤、phrase/thing 截断 |
钩子须在**样本已落库且能读到 eta**之后触发;异步用与 OA 相同的线程/`SessionLocal` 模式,避免拖慢 HTTP。
### 4.6 点击跳转(首期)
- 默认:不传 `miniprogram`,或传协议详情 path(细胞存储 / 科研捐赠各一,带协议 id)。
- 若微信返回关联/页面错误:去掉 jump 重试一次(复用 `_send_template` 已有降级)。
---
## 5. 错误处理与可观测性
- 单条发送失败:记 `fail` 日志,继续下一条;不抛回协议 API。
- 总开关关闭 / 缺模板 ID / 无接收人:安静跳过(info 级日志)。
- 微信 `47003`(字段非法):说明文案超限或 phrase 含非法字符;单测锁死拼装规则。
---
## 6. 验收要点
1. 新建细胞存储协议含 2 个样本 → 每个通知人收到 2 条;标题/类型符合 §3。
2. 再编辑只改金额 → 不再推。
3. 编辑再追加 1 个新码 → 仅该码 1 条/人。
4. 科研捐赠路径行为与细胞存储一致,`phrase10=科研捐赠`。
5. 未绑服务号用户不收消息;协议保存仍成功。
6. 无 ETA 时标题为 `样本采集(到达待定)`。
7. 同一 sample_code 对同一人第二次保存不重复成功推送。
---
## 7. 合规与运营说明
- 接收人为系统内授权角色成员,须已关注并绑定服务号;避免对纯路人粉丝群发。
- 模板为工单类文案,发送频率随业务新增样本而定;若微信侧后续限流/投诉,再评估改为企业微信或订阅通知(长期类)。
- 运营需:配置模板 ID、打开 `WX_MP_NOTIFY_ENABLED`、创建角色并给人、确保相关人完成服务号绑定。
@@ -0,0 +1,51 @@
# 协议编辑锁定已落库样本编码 — 设计
> 日期:2026-10-26
> 状态:已确认(方案 1,实现默认)
> 关联:样本编码全链路纠错(`2026-10-22-sample-code-correction-design.md`)
---
## 1. 背景
客户服务 / 样本信息管理已提供「更正编码」,会级联更新下游快照。协议单(采集协议、科研捐赠)编辑里换码不会回写下游,易造成不一致。因此编辑已落库样本时禁止改编码。
## 2. 范围决策
| 项 | 决定 |
|----|------|
| 业务 | 采集协议 + 科研捐赠 |
| 端 | Web 管理端 + 小程序市场端(`mobile-oa-h5`) |
| 新增样本 | 仍可选 / 可自动生成编码 |
| 已落库判定 | 样本行有持久化 `id` |
| 本会话未保存新行 | 仍可改码 |
| 后端 | 更新时若 payload 带已存在 `id` 且编码变更 → 拒绝 |
| 真要改码 | 仅走「更正编码」 |
## 3. 非目标
- 不禁止删除已有样本后再新增另一条(删 + 增)
- 不在协议编辑内提供纠错入口
- 不改「更正编码」级联逻辑
## 4. UI
- **Web**:`SampleTransportFormSection` 编辑弹窗中,若 `sampleDraft.id` 非空,样本编码只读展示;新增仍用选择器 / 自动生成。
- **小程序**:`ProtocolSampleTransportPanel` 同等规则(科研捐赠本就自动生成只读,编辑已落库时保持只读并提示来源)。
- 可选提示:已落库编码请到客户服务使用「更正编码」。
## 5. 后端
在采集协议 `save_samples`、科研捐赠样本更新路径中,于写库前:
1. 加载本单现有未删样本,按 `id` 建索引。
2. 遍历 payload:若 `id` 命中现有行,则 `sample_code`、`sample_code_id`(trim 后)必须与库中一致。
3. 否则 `ValueError`,文案明确引导使用「更正编码」。
无 `id` 或 `id` 不在本单现有行中的条目视为新增,不套本校验。
## 6. 测试
- 已落库样本改 `sampleCode` / `sampleCodeId` → 拒绝。
- 已落库样本保持原码 → 通过。
- 无 `id` 新增样本 → 不受本规则影响。
+50 -12
View File
@@ -45,6 +45,7 @@
v-for="(row, idx) in list"
:key="row.id"
class="dd-card"
:class="{ 'is-unread': !row.isRead }"
@click="openView(row)"
@longpress="onCardLongPress(row)"
>
@@ -52,7 +53,10 @@
<text class="dd-card__corner-num">{{ cornerIndex(idx) }}</text>
</view>
<view class="dd-card__head">
<text class="dd-card__title">{{ cardTitle(row) }}</text>
<view class="dd-card__title-wrap">
<view v-if="!row.isRead" class="dd-card__unread" />
<text class="dd-card__title">{{ cardTitle(row) }}</text>
</view>
<text class="dd-card__time">{{
formatRelativeTime(row.applyTime || row.createTime)
}}</text>
@@ -89,7 +93,10 @@
<script>
import VanEmpty from '../../../components/van-empty/van-empty.vue'
import { fetchOaTasks } from '../../../utils/oa-api.js'
import {
fetchMarkOaMentionRead,
fetchOaTasks
} from '../../../utils/oa-api.js'
import { forceRelogin, getToken } from '../../../utils/auth.js'
import { stashOaDetailTitle, stashOaStartTitle } from '../../../utils/oa-h5.js'
import { formatCornerIndex } from '../../../utils/oa-corner-index.js'
@@ -131,6 +138,7 @@
loading: false,
loadingMore: false,
hasMore: true,
openingId: '',
_searchTimer: null
}
},
@@ -275,8 +283,8 @@
}
})
},
openView(row, opts) {
if (!row) return
async openView(row, opts) {
if (!row || this.openingId) return
// mine 行是实例:Web 用 row.id;兼容 instanceId
const instanceId = String(row.instanceId || row.id || '').trim()
if (!instanceId) {
@@ -284,14 +292,27 @@
return
}
const autoPrint = !!(opts && opts.autoPrint)
stashOaDetailTitle(row.appName || row.title)
uni.navigateTo({
url:
'/pages/oa/webview/index?instanceId=' +
encodeURIComponent(instanceId) +
'&mode=view' +
(autoPrint ? '&autoPrint=1' : '')
})
this.openingId = instanceId
try {
if (!row.isRead) {
try {
await fetchMarkOaMentionRead(instanceId)
row.isRead = true
} catch (e) {
/* 仍允许进入详情 */
}
}
stashOaDetailTitle(row.appName || row.title)
uni.navigateTo({
url:
'/pages/oa/webview/index?instanceId=' +
encodeURIComponent(instanceId) +
'&mode=view' +
(autoPrint ? '&autoPrint=1' : '')
})
} finally {
this.openingId = ''
}
},
openCopyRestart(row) {
if (!row) return
@@ -470,6 +491,23 @@
gap: 16rpx;
}
.dd-card__title-wrap {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-start;
gap: 12rpx;
}
.dd-card__unread {
flex-shrink: 0;
width: 14rpx;
height: 14rpx;
margin-top: 12rpx;
border-radius: 50%;
background: #ef4444;
}
.dd-card__title {
flex: 1;
min-width: 0;
+1 -1
View File
@@ -6,7 +6,7 @@
*
* 微信公众平台须为对应环境配置 request / 业务域名(HTTPS)
*/
export const APP_ENV = 'prod' // 'local' | 'test' | 'prod'
export const APP_ENV = 'local' // 'local' | 'test' | 'prod'
const ENV_PRESETS = {
local: {
+14
View File
@@ -113,6 +113,20 @@ export function fetchOaCcUnreadCount() {
return get('/web/admin/oa-approval/tasks/cc/unread-count', authHeader())
}
export function fetchOaMentionUnreadCount() {
return get('/web/admin/oa-approval/mentions/unread-count', authHeader())
}
export function fetchMarkOaMentionRead(instanceId) {
return post(
'/web/admin/oa-approval/mentions/' +
encodeURIComponent(instanceId) +
'/read',
{},
authHeader()
)
}
export function fetchMarkOaCcTaskRead(taskId) {
return post(
'/web/admin/oa-approval/tasks/' + encodeURIComponent(taskId) + '/read',
+8 -4
View File
@@ -1,12 +1,14 @@
import {
fetchOaCcUnreadCount,
fetchOaInstanceDraftCount,
fetchOaMentionUnreadCount,
fetchOaPendingTaskCount
} from './oa-api.js'
const PATH_DRAFTS = '/pages/oa/drafts/index'
const PATH_PENDING = '/pages/oa/pending/index'
const PATH_CC = '/pages/oa/cc/index'
const PATH_MENTIONS = '/pages/oa/mentions/index'
function readCount(data) {
if (data == null) return 0
@@ -16,19 +18,21 @@ function readCount(data) {
}
/**
* 并行拉取草稿 / 待办 / 抄送未读数量。
* 并行拉取草稿 / 待办 / 抄送未读 / 提到我的未读数量。
* @returns {Promise<Record<string, number>>} path → 原始 count(>99 仍保留真实数字)
*/
export async function refreshOaMenuBadges() {
const [draftRes, pendingRes, ccRes] = await Promise.all([
const [draftRes, pendingRes, ccRes, mentionRes] = await Promise.all([
fetchOaInstanceDraftCount().catch(() => null),
fetchOaPendingTaskCount().catch(() => null),
fetchOaCcUnreadCount().catch(() => null)
fetchOaCcUnreadCount().catch(() => null),
fetchOaMentionUnreadCount().catch(() => null)
])
return {
[PATH_DRAFTS]: readCount(draftRes),
[PATH_PENDING]: readCount(pendingRes),
[PATH_CC]: readCount(ccRes)
[PATH_CC]: readCount(ccRes),
[PATH_MENTIONS]: readCount(mentionRes)
}
}
@@ -126,10 +126,16 @@
/>
</div>
</div>
<div v-if="autoGenerateCode" class="market-form-field">
<label>样本编码</label>
<div v-if="autoGenerateCode || isPersistedSampleCodeLocked" class="market-form-field">
<label
>样本编码<span v-if="isPersistedSampleCodeLocked" class="market-form-field__tip"
>已落库不可改,请用客户服务「更正编码」</span
></label
>
<van-field
:model-value="draft.sampleCode || '保存后自动生成'"
:model-value="
draft.sampleCode || (autoGenerateCode && !isPersistedSampleCodeLocked ? '保存后自动生成' : '—')
"
readonly
:border="false"
/>
@@ -419,6 +425,10 @@ const emit = defineEmits(['update:modelValue', 'refresh-sample-codes'])
const samples = computed(() => props.modelValue || [])
const showPrice = computed(() => props.source === 'cell_storage')
const autoGenerateCode = computed(() => props.source === 'research_donation')
/** 已落库样本(有持久化 id)编辑时锁定编码 */
const isPersistedSampleCodeLocked = computed(
() => editorIndex.value >= 0 && Boolean(String(draft.id || '').trim())
)
const freightContext = computed(() => ({
module: 'freight',
marketRole: props.marketRole || 'specialist'
@@ -569,6 +579,7 @@ function transportModeLabel(value) {
function openAdd() {
editorIndex.value = -1
Object.assign(draft, emptySampleRow({ source: props.source }))
draft.id = ''
editorVisible.value = true
resetPopupScroll(editorPopupRef)
emitSampleCodeRefresh('')
@@ -589,7 +600,7 @@ function openEdit(index) {
function onTypeIdChange(value) {
if (!value) {
draft.sampleTypeId = ''
if (!autoGenerateCode.value) {
if (!autoGenerateCode.value && !isPersistedSampleCodeLocked.value) {
draft.sampleCodeId = ''
draft.sampleCode = ''
}
@@ -601,6 +612,9 @@ function onTypeIdChange(value) {
return
}
const type = typeSelectOptions.value.find((opt) => String(opt.value) === String(value))
const lockedCodes = isPersistedSampleCodeLocked.value
? { sampleCodeId: draft.sampleCodeId, sampleCode: draft.sampleCode }
: null
Object.assign(
draft,
applySampleTypeDefaults(
@@ -612,7 +626,12 @@ function onTypeIdChange(value) {
{ showPrice: showPrice.value }
)
)
if (autoGenerateCode.value) draft.sampleCode = ''
if (lockedCodes) {
draft.sampleCodeId = lockedCodes.sampleCodeId
draft.sampleCode = lockedCodes.sampleCode
} else if (autoGenerateCode.value) {
draft.sampleCode = ''
}
emitSampleCodeRefresh(value)
}
+7
View File
@@ -1240,6 +1240,13 @@ body.van-toast--unclickable .presign-order-page * {
color: #d14343;
}
.market-form-field > label .market-form-field__tip {
margin-left: 6px;
color: #64748b;
font-size: 11px;
font-weight: 500;
}
.market-form-field > .van-field,
.market-form-field__control {
box-sizing: border-box;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more