fix: 协议子资源单事务保存,并补齐样本编码/OA/质检等近期能力
锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
19bc66ba45
commit
5dad1bb0e3
142 files changed
+95584
-65717
No files matched your search
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -151,6 +151,8 @@ class ScheduleProductNestedBody(BaseModel):
|
||||
project_name: Optional[str] = Field(None, alias="projectName")
|
||||
cell_type_name: Optional[str] = Field(None, alias="cellTypeName")
|
||||
spec_text: Optional[str] = Field(None, alias="specText")
|
||||
package_id: Optional[str] = Field(None, alias="packageId")
|
||||
package_text: Optional[str] = Field(None, alias="packageText")
|
||||
agreement_price: Optional[float] = Field(None, alias="agreementPrice")
|
||||
actual_price: Optional[float] = Field(None, alias="actualPrice")
|
||||
quantity: Optional[int] = 1
|
||||
@@ -167,6 +169,8 @@ class InfusionScheduleNestedBody(BaseModel):
|
||||
infusion_location: Optional[str] = Field(None, alias="infusionLocation")
|
||||
infusion_method: Optional[str] = Field(None, alias="infusionMethod")
|
||||
amount: Optional[float] = None
|
||||
package_id: Optional[str] = Field(None, alias="packageId")
|
||||
package_text: Optional[str] = Field(None, alias="packageText")
|
||||
cell_products: Optional[list[ScheduleProductNestedBody]] = Field(None, alias="cellProducts")
|
||||
|
||||
|
||||
@@ -500,6 +504,8 @@ class SaleScheduleProductLineBody(BaseModel):
|
||||
project_name: Optional[str] = Field(None, alias="projectName")
|
||||
cell_type_name: Optional[str] = Field(None, alias="cellTypeName")
|
||||
spec_text: Optional[str] = Field(None, alias="specText")
|
||||
package_id: Optional[str] = Field(None, alias="packageId")
|
||||
package_text: Optional[str] = Field(None, alias="packageText")
|
||||
agreement_price: Optional[float] = Field(None, alias="agreementPrice")
|
||||
actual_price: Optional[float] = Field(None, alias="actualPrice")
|
||||
quantity: Optional[int] = 1
|
||||
|
||||
@@ -228,6 +228,108 @@ async def delete_cell_product_spec(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2b. 细胞产品包装
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CellProductPackageCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
package_code: str = Field(..., alias="packageCode")
|
||||
package_name: str = Field(..., alias="packageName")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class CellProductPackageUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
package_code: Optional[str] = Field(None, alias="packageCode")
|
||||
package_name: Optional[str] = Field(None, alias="packageName")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/cell-product-packages")
|
||||
async def list_cell_product_packages(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = ps_svc.list_cell_product_packages(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no, user_info=user
|
||||
)
|
||||
tp = ps_svc.total_pages(total, pageSize)
|
||||
return success_response(
|
||||
{"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cell-product-packages")
|
||||
async def create_cell_product_package(
|
||||
body: CellProductPackageCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = ps_svc.create_cell_product_package(
|
||||
db,
|
||||
package_code=body.package_code,
|
||||
package_name=body.package_name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(ps_svc._row_to_dict_cell_product_package(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/cell-product-packages/{row_id}")
|
||||
async def update_cell_product_package(
|
||||
row_id: str,
|
||||
body: CellProductPackageUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = ps_svc.update_cell_product_package(
|
||||
db,
|
||||
row_id,
|
||||
package_code=payload.get("package_code"),
|
||||
package_name=payload.get("package_name"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(ps_svc._row_to_dict_cell_product_package(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/cell-product-packages/{row_id}")
|
||||
async def delete_cell_product_package(
|
||||
row_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
ps_svc.soft_delete_cell_product_package(db, row_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 细胞产品价格
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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="更新成功",
|
||||
|
||||
@@ -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}")
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "生效中",
|
||||
|
||||
@@ -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}%"))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -272,6 +272,8 @@ def _row_to_dict_schedule_product(row) -> Dict[str, Any]:
|
||||
"projectName": row.project_name or "",
|
||||
"cellTypeName": row.cell_type_name or "",
|
||||
"specText": row.spec_text or "",
|
||||
"packageId": getattr(row, "package_id", None) or "",
|
||||
"packageText": getattr(row, "package_text", None) or "",
|
||||
"agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None,
|
||||
"actualPrice": float(row.actual_price) if row.actual_price is not None else None,
|
||||
"quantity": row.quantity if row.quantity is not None else 1,
|
||||
@@ -289,6 +291,8 @@ def _row_to_dict_infusion_schedule(row, cell_products: Optional[List[Dict[str, A
|
||||
"infusionPlanId": row.infusion_plan_id or "",
|
||||
"cellType": row.cell_type or "",
|
||||
"specText": row.spec_text or "",
|
||||
"packageId": getattr(row, "package_id", None) or "",
|
||||
"packageText": getattr(row, "package_text", None) or "",
|
||||
"infusionTime": infusion_time,
|
||||
"infusionLocation": row.infusion_location or "",
|
||||
"infusionMethod": row.infusion_method or "",
|
||||
@@ -1218,6 +1222,8 @@ def _create_schedule_products(
|
||||
project_name=item.get("projectName") or "",
|
||||
cell_type_name=item.get("cellTypeName") or "",
|
||||
spec_text=item.get("specText") or "",
|
||||
package_id=item.get("packageId") or "",
|
||||
package_text=item.get("packageText") or "",
|
||||
agreement_price=item.get("agreementPrice"),
|
||||
actual_price=item.get("actualPrice"),
|
||||
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
|
||||
@@ -1273,6 +1279,8 @@ def create_schedule(
|
||||
infusion_plan_id=plan_id,
|
||||
cell_type=data.get("cellType") or first_product.get("cellTypeName") or "",
|
||||
spec_text=data.get("specText") or first_product.get("specText") or "",
|
||||
package_id=data.get("packageId") or first_product.get("packageId") or "",
|
||||
package_text=data.get("packageText") or first_product.get("packageText") or "",
|
||||
infusion_time=_parse_infusion_datetime(data.get("infusionTime")),
|
||||
infusion_location=data.get("infusionLocation") or "",
|
||||
infusion_method=data.get("infusionMethod") or "",
|
||||
@@ -1364,6 +1372,10 @@ def _update_schedule_editable(
|
||||
if cell_products:
|
||||
row.cell_type = data.get("cellType") or first_product.get("cellTypeName") or row.cell_type
|
||||
row.spec_text = data.get("specText") or first_product.get("specText") or row.spec_text
|
||||
row.package_id = data.get("packageId") or first_product.get("packageId") or row.package_id
|
||||
row.package_text = (
|
||||
data.get("packageText") or first_product.get("packageText") or row.package_text
|
||||
)
|
||||
if data.get("remark") is not None:
|
||||
row.remark = data.get("remark") or ""
|
||||
row.update_by = user_id
|
||||
@@ -1407,6 +1419,8 @@ def _upsert_schedule_products(
|
||||
prow.project_name = item.get("projectName") or ""
|
||||
prow.cell_type_name = item.get("cellTypeName") or ""
|
||||
prow.spec_text = item.get("specText") or ""
|
||||
prow.package_id = item.get("packageId") or ""
|
||||
prow.package_text = item.get("packageText") or ""
|
||||
if item.get("agreementPrice") is not None:
|
||||
prow.agreement_price = item.get("agreementPrice")
|
||||
if item.get("actualPrice") is not None:
|
||||
@@ -1429,6 +1443,8 @@ def _upsert_schedule_products(
|
||||
project_name=item.get("projectName") or "",
|
||||
cell_type_name=item.get("cellTypeName") or "",
|
||||
spec_text=item.get("specText") or "",
|
||||
package_id=item.get("packageId") or "",
|
||||
package_text=item.get("packageText") or "",
|
||||
agreement_price=item.get("agreementPrice"),
|
||||
actual_price=item.get("actualPrice"),
|
||||
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
|
||||
@@ -1645,6 +1661,8 @@ def create_basic_experiment_sale_schedules(
|
||||
schedule_data = {
|
||||
"cellType": line.get("cellTypeName") or "",
|
||||
"specText": line.get("specText") or "",
|
||||
"packageId": line.get("packageId") or "",
|
||||
"packageText": line.get("packageText") or "",
|
||||
"infusionTime": line.get("pickupTime"),
|
||||
"amount": line.get("amount"),
|
||||
"sortNo": idx + 1,
|
||||
@@ -1655,6 +1673,8 @@ def create_basic_experiment_sale_schedules(
|
||||
"projectName": line.get("projectName"),
|
||||
"cellTypeName": line.get("cellTypeName"),
|
||||
"specText": line.get("specText"),
|
||||
"packageId": line.get("packageId"),
|
||||
"packageText": line.get("packageText"),
|
||||
"agreementPrice": line.get("agreementPrice"),
|
||||
"actualPrice": line.get("actualPrice"),
|
||||
"quantity": line.get("quantity") or 1,
|
||||
|
||||
@@ -69,6 +69,8 @@ def _row_to_dict_schedule(
|
||||
"totalCourseCount": course.get("totalCourseCount"),
|
||||
"cellType": row.cell_type or "",
|
||||
"specText": row.spec_text or "",
|
||||
"packageId": getattr(row, "package_id", None) or "",
|
||||
"packageText": getattr(row, "package_text", None) or "",
|
||||
"infusionTime": row.infusion_time.strftime("%Y-%m-%d %H:%M:%S") if row.infusion_time else "",
|
||||
"infusionLocation": row.infusion_location or "",
|
||||
"infusionMethod": row.infusion_method or "",
|
||||
@@ -142,6 +144,8 @@ def _row_to_dict_product(row) -> Dict[str, Any]:
|
||||
"projectName": row.project_name or "",
|
||||
"cellTypeName": row.cell_type_name or "",
|
||||
"specText": row.spec_text or "",
|
||||
"packageId": getattr(row, "package_id", None) or "",
|
||||
"packageText": getattr(row, "package_text", None) or "",
|
||||
"agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None,
|
||||
"actualPrice": float(row.actual_price) if row.actual_price is not None else None,
|
||||
"quantity": row.quantity if row.quantity is not None else 1,
|
||||
@@ -516,6 +520,8 @@ def get_infusion_schedule_detail(
|
||||
sched["totalCourseCount"] = flat.get("totalCourseCount")
|
||||
sched["cellType"] = flat.get("cellType") or sched.get("cellType") or ""
|
||||
sched["specText"] = flat.get("specText") or sched.get("specText") or ""
|
||||
sched["packageId"] = flat.get("packageId") or sched.get("packageId") or ""
|
||||
sched["packageText"] = flat.get("packageText") or sched.get("packageText") or ""
|
||||
sched["infusionTime"] = flat.get("infusionTime") or sched.get("infusionTime") or ""
|
||||
sched["infusionLocation"] = flat.get("infusionLocation") or sched.get("infusionLocation") or ""
|
||||
sched["infusionMethod"] = flat.get("infusionMethod") or sched.get("infusionMethod") or ""
|
||||
@@ -1114,6 +1120,8 @@ def update_infusion_schedule_mgmt(
|
||||
fields = {
|
||||
"cellType": "cell_type",
|
||||
"specText": "spec_text",
|
||||
"packageId": "package_id",
|
||||
"packageText": "package_text",
|
||||
"infusionLocation": "infusion_location",
|
||||
"infusionMethod": "infusion_method",
|
||||
}
|
||||
@@ -1153,6 +1161,8 @@ def update_infusion_schedule_mgmt(
|
||||
project_name=item.get("projectName") or item.get("project_name") or "",
|
||||
cell_type_name=item.get("cellTypeName") or item.get("cell_type_name") or "",
|
||||
spec_text=item.get("specText") or item.get("spec_text") or "",
|
||||
package_id=item.get("packageId") or item.get("package_id") or "",
|
||||
package_text=item.get("packageText") or item.get("package_text") or "",
|
||||
agreement_price=item.get("agreementPrice") if item.get("agreementPrice") is not None else (
|
||||
item.get("agreement_price") if item.get("agreement_price") is not None else None
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
BizCellProductType,
|
||||
BizCellProductSpec,
|
||||
BizCellProductPackage,
|
||||
BizCellProductPrice,
|
||||
BizCellProductProjectSystem,
|
||||
BizCellProductProjectSystemItem,
|
||||
@@ -281,6 +282,141 @@ def soft_delete_cell_product_spec(db, spec_id, tenant_no=None):
|
||||
db.commit()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────
|
||||
# Entity 2b: BizCellProductPackage 细胞产品包装
|
||||
# ────────────────────────────────────────────
|
||||
|
||||
def _row_to_dict_cell_product_package(row) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"packageCode": row.package_code or "",
|
||||
"packageName": row.package_name or "",
|
||||
"tenantNo": row.tenant_no or "",
|
||||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||||
"remark": row.remark or "",
|
||||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
||||
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_cell_product_packages(db, *, page=1, page_size=10, keyword=None, tenant_no=None, user_info=None):
|
||||
def _filtered():
|
||||
q0 = db.query(BizCellProductPackage).filter(BizCellProductPackage.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizCellProductPackage.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizCellProductPackage.package_code.like(like),
|
||||
BizCellProductPackage.package_name.like(like),
|
||||
)
|
||||
)
|
||||
if user_info:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
q0 = apply_data_scope(q0, BizCellProductPackage, user_info, db, tenant_no)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizCellProductPackage.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizCellProductPackage.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict_cell_product_package(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_cell_product_package(db, package_id, tenant_no=None):
|
||||
q = db.query(BizCellProductPackage).filter(
|
||||
and_(BizCellProductPackage.id == package_id, BizCellProductPackage.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def cell_product_package_code_exists(db, code, tenant_no, exclude_id=None):
|
||||
q = db.query(BizCellProductPackage).filter(
|
||||
and_(BizCellProductPackage.package_code == code, BizCellProductPackage.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
|
||||
if exclude_id:
|
||||
q = q.filter(BizCellProductPackage.id != exclude_id)
|
||||
return db.query(q.exists()).scalar()
|
||||
|
||||
|
||||
def create_cell_product_package(
|
||||
db, *, package_code, package_name, tenant_no="zkxw", order_num=None, remark=None, user_id=None
|
||||
):
|
||||
if cell_product_package_code_exists(db, package_code, tenant_no):
|
||||
raise ValueError(f"包装编码 {package_code} 已存在")
|
||||
obj = BizCellProductPackage(
|
||||
id=generate_uuid(),
|
||||
package_code=package_code,
|
||||
package_name=package_name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num or 0,
|
||||
remark=remark or "",
|
||||
create_by=user_id,
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_cell_product_package(
|
||||
db,
|
||||
package_id,
|
||||
*,
|
||||
package_code=None,
|
||||
package_name=None,
|
||||
order_num=None,
|
||||
remark=None,
|
||||
user_id=None,
|
||||
tenant_no=None,
|
||||
):
|
||||
obj = get_cell_product_package(db, package_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
if package_code is not None and package_code != obj.package_code:
|
||||
if cell_product_package_code_exists(db, package_code, tenant_no, exclude_id=package_id):
|
||||
raise ValueError(f"包装编码 {package_code} 已存在")
|
||||
obj.package_code = package_code
|
||||
if package_name is not None:
|
||||
obj.package_name = package_name
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = remark
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_cell_product_package(db, package_id, tenant_no=None):
|
||||
obj = get_cell_product_package(db, package_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────
|
||||
# Entity 3: BizCellProductPrice 细胞产品价格
|
||||
# ────────────────────────────────────────────
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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 "",
|
||||
)
|
||||
@@ -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
+31129
-23178
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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"], "李四")
|
||||
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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(若触及模块依赖)。
|
||||
+90
@@ -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` 新增样本 → 不受本规则影响。
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -1,11 +1,11 @@
|
||||
# Graph Report - src (2026-10-22)
|
||||
# Graph Report - src (2026-10-26)
|
||||
|
||||
## Corpus Check
|
||||
- Large corpus: 1044 files · ~691,802 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
- Large corpus: 1053 files · ~695,013 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
|
||||
## Summary
|
||||
- 3856 nodes · 5622 edges · 63 communities detected
|
||||
- Extraction: 76% EXTRACTED · 24% INFERRED · 0% AMBIGUOUS · INFERRED: 1344 edges (avg confidence: 0.8)
|
||||
- 3926 nodes · 5753 edges · 65 communities detected
|
||||
- Extraction: 76% EXTRACTED · 24% INFERRED · 0% AMBIGUOUS · INFERRED: 1376 edges (avg confidence: 0.8)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
@@ -54,35 +54,37 @@
|
||||
- [[_COMMUNITY_Community 42|Community 42]]
|
||||
- [[_COMMUNITY_Community 43|Community 43]]
|
||||
- [[_COMMUNITY_Community 45|Community 45]]
|
||||
- [[_COMMUNITY_Community 46|Community 46]]
|
||||
- [[_COMMUNITY_Community 47|Community 47]]
|
||||
- [[_COMMUNITY_Community 48|Community 48]]
|
||||
- [[_COMMUNITY_Community 50|Community 50]]
|
||||
- [[_COMMUNITY_Community 51|Community 51]]
|
||||
- [[_COMMUNITY_Community 49|Community 49]]
|
||||
- [[_COMMUNITY_Community 53|Community 53]]
|
||||
- [[_COMMUNITY_Community 54|Community 54]]
|
||||
- [[_COMMUNITY_Community 55|Community 55]]
|
||||
- [[_COMMUNITY_Community 56|Community 56]]
|
||||
- [[_COMMUNITY_Community 57|Community 57]]
|
||||
- [[_COMMUNITY_Community 58|Community 58]]
|
||||
- [[_COMMUNITY_Community 59|Community 59]]
|
||||
- [[_COMMUNITY_Community 60|Community 60]]
|
||||
- [[_COMMUNITY_Community 63|Community 63]]
|
||||
- [[_COMMUNITY_Community 64|Community 64]]
|
||||
- [[_COMMUNITY_Community 70|Community 70]]
|
||||
- [[_COMMUNITY_Community 74|Community 74]]
|
||||
- [[_COMMUNITY_Community 78|Community 78]]
|
||||
- [[_COMMUNITY_Community 91|Community 91]]
|
||||
- [[_COMMUNITY_Community 62|Community 62]]
|
||||
- [[_COMMUNITY_Community 65|Community 65]]
|
||||
- [[_COMMUNITY_Community 66|Community 66]]
|
||||
- [[_COMMUNITY_Community 72|Community 72]]
|
||||
- [[_COMMUNITY_Community 77|Community 77]]
|
||||
- [[_COMMUNITY_Community 80|Community 80]]
|
||||
- [[_COMMUNITY_Community 94|Community 94]]
|
||||
- [[_COMMUNITY_Community 95|Community 95]]
|
||||
- [[_COMMUNITY_Community 97|Community 97]]
|
||||
- [[_COMMUNITY_Community 98|Community 98]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `get()` - 347 edges
|
||||
2. `push()` - 128 edges
|
||||
3. `Number()` - 106 edges
|
||||
4. `emit()` - 35 edges
|
||||
5. `WebSocketClient` - 26 edges
|
||||
6. `set()` - 23 edges
|
||||
7. `withFullscreenLoading()` - 20 edges
|
||||
8. `handleDynamicRoutes()` - 20 edges
|
||||
9. `onPick()` - 18 edges
|
||||
1. `get()` - 350 edges
|
||||
2. `push()` - 127 edges
|
||||
3. `Number()` - 105 edges
|
||||
4. `emit()` - 36 edges
|
||||
5. `withFullscreenLoading()` - 28 edges
|
||||
6. `WebSocketClient` - 26 edges
|
||||
7. `set()` - 24 edges
|
||||
8. `onPick()` - 18 edges
|
||||
9. `handleDynamicRoutes()` - 18 edges
|
||||
10. `walkRules()` - 17 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
@@ -100,96 +102,96 @@
|
||||
## Communities
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.01
|
||||
Nodes (195): fetchAccessLogList(), fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchChannelAccount() (+187 more)
|
||||
Cohesion: 0.0
|
||||
Nodes (205): fetchAccessLogList(), fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchChannelAccount(), fetchChannelAgreementPrices(), fetchChannelAgreements(), fetchChannelInvoices() (+197 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.02
|
||||
Nodes (145): formatTodayDate(), if(), Number(), addPaymentDetail(), applyPreferentialContractForV1Mode(), createEmptyTransport(), datetimeToYmd(), defaultInstallmentStartDateYmd() (+137 more)
|
||||
Cohesion: 0.01
|
||||
Nodes (190): fetchUploadAttachment(), handleReportUpload(), handleReportUpload(), walk(), if(), Number(), async(), for() (+182 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.02
|
||||
Nodes (109): fetchCheckupInstitutionInvoices(), fetchCheckupInstitutionProjects(), fetchCollectionRegionList(), fetchCreateCheckupInstitution(), fetchCreateCheckupInstitutionInvoice(), fetchCreateCheckupInstitutionProject(), fetchCreateCollectionRegion(), fetchCreateHospital() (+101 more)
|
||||
Nodes (129): fetchCellProductPackageList(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchCheckupInstitutionInvoices(), fetchCheckupInstitutionProjects(), fetchCreateCellProductPackage(), fetchCreateCellProductPrice() (+121 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.02
|
||||
Nodes (96): fetchUpdatePostBoundRoles(), fetchUploadAttachment(), FireworkSystem, handleReportUpload(), handleReportUpload(), walk(), branchConditionLines(), collectConditionReferencedFields() (+88 more)
|
||||
Nodes (113): fetchMyProductionTasks(), fetchMyQcTaskList(), fetchQcTaskDetail(), fetchQcTaskList(), buildTemporaryViewSections(), buildInsertedSiblingCard(), fetchMineTasksForWorkOrders(), resolveShowProtocolPaymentAtGroup() (+105 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.02
|
||||
Nodes (89): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchPilotCheckupDetail(), fetchPilotCheckupList(), markPilotCheckupChecked() (+81 more)
|
||||
Nodes (72): fetchCreateMenu(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateMenu(), collectIds(), buildInboundBasicSection(), buildInboundCellStorageViewSections(), buildInboundResearchDonationViewSections() (+64 more)
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.02
|
||||
Nodes (73): fetchBatchDeleteOaReceiveAccounts(), fetchBatchMarkOaCcTasksRead(), fetchCreateOaReceiveAccount(), fetchDeleteOaReceiveAccount(), fetchMarkOaCcTaskRead(), fetchOaAppDraft(), fetchOaBankReceipts(), fetchOaCcUnreadCount() (+65 more)
|
||||
Cohesion: 0.03
|
||||
Nodes (92): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchPilotCheckupActualItems(), fetchPilotCheckupDetail(), fetchPilotCheckupList() (+84 more)
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.03
|
||||
Nodes (64): fetchAddTaskStep(), fetchCopyTaskStep(), fetchDeleteTaskStep(), fetchDispatchProductionTaskSteps(), fetchForwardProductionTask(), fetchProductionTaskDetail(), fetchProductionTaskEditorInit(), fetchReassignProductionTask() (+56 more)
|
||||
Cohesion: 0.02
|
||||
Nodes (67): fetchBatchDeleteOaReceiveAccounts(), fetchBatchMarkOaCcTasksRead(), fetchCreateOaReceiveAccount(), fetchDeleteOaReceiveAccount(), fetchMarkOaCcTaskRead(), fetchOaAppDraft(), fetchOaBankReceipts(), fetchOaCcUnreadCount() (+59 more)
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.03
|
||||
Nodes (39): fetchGetMenuList(), ComponentLoader, IframeRouteManager, MenuProcessor, RoutePermissionValidator, RouteRegistry, RouteTransformer, useAppMode() (+31 more)
|
||||
Nodes (41): fetchGetMenuList(), useSettingsHandlers(), useSettingsPanel(), useSettingsState(), ComponentLoader, IframeRouteManager, MenuProcessor, RouteRegistry (+33 more)
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.03
|
||||
Nodes (69): fetchCellSampleTypeList(), fetchCreateCellSampleType(), fetchCreateItemUnit(), fetchCreateMenu(), fetchCreateTenant(), fetchDeleteCellSampleType(), fetchDeleteItemUnit(), fetchDeleteMenu() (+61 more)
|
||||
Nodes (59): RoutePermissionValidator, if(), loadAllAppointmentsForExport(), handleSiblingLinkClick(), resolveInboundWorkOrderCardTitle(), companyProgressTagType(), companyProgressText(), ensureCategoryPathDataLoaded() (+51 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.03
|
||||
Nodes (63): fetchOaRelatedSummaries(), useSettingsHandlers(), useSettingsPanel(), useSettingsState(), useCeremony(), useTheme(), if(), chipTitle() (+55 more)
|
||||
Nodes (80): fetchDeptTree(), fetchUserList(), fetchOaFlowPreview(), fetchOaTemplateDetail(), openForDraft(), remoteSearchHandoverUsers(), applySchema(), flowNeedsInitiatorSelect() (+72 more)
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 0.03
|
||||
Nodes (58): fetchDictDataOptions(), fetchAttachmentDownloadUrl(), formatProtocolAmount(), handleAttachmentPreview(), resolveFileUrl(), showStepContent(), visibleFields(), qcAttachmentFullUrl() (+50 more)
|
||||
Nodes (42): fetchSampleInsurance(), fetchSampleStorageCert(), fetchUpdateSampleStorageCert(), dash(), fmtMoney(), auditorDisplay(), dash(), ensureUnitsInOptions() (+34 more)
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.03
|
||||
Nodes (59): buildInboundBasicSection(), buildInboundCellStorageViewSections(), buildInboundResearchDonationViewSections(), buildReceptionSection(), buildTemporaryViewSections(), buildTransportSection(), resolveInboundProtocolStatus(), buildInsertedSiblingCard() (+51 more)
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.02
|
||||
Nodes (29): fetchAllConsumablePacks(), fetchAllConsumables(), fetchAllConsumableStockLogs(), fetchAllEquipments(), fetchAllOutputTypes(), fetchAllProcessCategories(), fetchAllProcesses(), fetchAllReagents() (+21 more)
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.03
|
||||
Nodes (54): handleBlur(), handleFocus(), handleInput(), fetchCreatePaymentTransfer(), fetchDeletePaymentTransfer(), fetchPaymentChaseList(), fetchPaymentTransferList(), fetchPaymentTransferReceiptConfirmedList() (+46 more)
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 0.03
|
||||
Nodes (58): fetchMyProductionTasks(), fetchMyQcTaskList(), fetchQcTaskDetail(), fetchQcTaskList(), fetchMineTasksForWorkOrders(), useInboundSiblingNavigation(), shouldApplyTriggerSourceFilter(), useQcWorkOrderTabPage() (+50 more)
|
||||
Nodes (57): fetchUpdatePostBoundRoles(), fetchMineProductionWorkOrderTabStepCounts(), fetchMineProductionWorkOrderTabStepCountsDeduped(), fetchProductionWorkOrderCellProductOptions(), fetchProductionWorkOrderDetail(), fetchProductionWorkOrderList(), fetchProductionWorkOrderQcDispatchOptions(), fetchProductionWorkOrderQcInfo() (+49 more)
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.04
|
||||
Nodes (70): fetchDeptTree(), fetchUserList(), fetchOaFlowPreview(), fetchOaTemplateDetail(), openForDraft(), remoteSearchHandoverUsers(), applySchema(), flowNeedsInitiatorSelect() (+62 more)
|
||||
Cohesion: 0.03
|
||||
Nodes (52): fetchDictDataOptions(), fetchAttachmentDownloadUrl(), formatProtocolAmount(), handleAttachmentPreview(), resolveFileUrl(), showStepContent(), visibleFields(), qcAttachmentFullUrl() (+44 more)
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.04
|
||||
Nodes (55): handleBlur(), handleFocus(), handleInput(), fetchCreatePaymentTransfer(), fetchDeletePaymentTransfer(), fetchPaymentChaseList(), fetchPaymentTransferList(), fetchPaymentTransferReceiptConfirmedList() (+47 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (62): fetchOaRelatedSummaries(), buildFormRows(), buildOaPrintFilename(), buildOaPrintModel(), canvasToPdf(), ensurePlainObject(), exportOaApprovalPdf(), extractUrls() (+54 more)
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.04
|
||||
Nodes (45): buildBaseFields(), buildInboundBasicSection(), buildInboundCellStorageProductionViewSections(), buildInboundResearchDonationProductionViewSections(), buildOutboundBasicExperimentProductionViewSections(), buildOutboundClinicalProductionViewSections(), buildOutboundDetail(), buildOutboundPilotProductionViewSections() (+37 more)
|
||||
Nodes (58): fetchCollectionRegionList(), fetchCreateCollectionRegion(), fetchCreateHospital(), fetchCreateRegionStaff(), fetchDeleteCollectionRegion(), fetchDeleteHospital(), fetchDeleteRegionStaff(), fetchHospitalList() (+50 more)
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.04
|
||||
Nodes (18): fetchProductionWorkOrderQcInfo(), fetchSampleInsurance(), fetchSampleStorageCert(), fetchUpdateSampleStorageCert(), dash(), fmtMoney(), auditorDisplay(), dash() (+10 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (54): fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchRefreshToken(), fetchUpdateProfile(), fetchUploadProfileAvatar(), fetchWxQrStatus(), toLogin() (+46 more)
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 0.05
|
||||
Nodes (34): fetchClinicalPaymentDetail(), fetchClinicalPayments(), fetchClinicalReceiptConfirmed(), fetchClinicalReceiptPending(), fetchClinicalRefundConfirmed(), fetchClinicalRefundPending(), fetchOrderAdjusts(), fetchOrderDetail() (+26 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (36): fetchDeleteMaterialBatch(), fetchMaterialBatchList(), fetchMaterialBatchOptions(), fetchMaterialList(), fetchMaterialOptions(), fetchUpdateMaterialBatch(), fetchMaterialUsageList(), fetchMyMaterialUsageList() (+28 more)
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 0.05
|
||||
Nodes (38): fetchProductionTaskList(), fetchProductionReceptionBySampleCode(), fetchProductionReceptionSubmit(), fetchProductionReceptionUpdate(), buildPayload(), ensureDefaultHandler(), initDialog(), loadAssignees() (+30 more)
|
||||
Nodes (39): fetchCreateQcInspectionItemTemplate(), fetchDeleteQcInspectionItemTemplate(), fetchQcInspectionItemTemplateDetail(), fetchQcInspectionItemTemplateList(), fetchQcInspectionItemTemplatesActive(), fetchUpdateQcInspectionItemTemplate(), fetchQcLabTestItemList(), fetchQcLabTestItemsActive() (+31 more)
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.06
|
||||
Nodes (47): buildFormRows(), buildOaPrintFilename(), buildOaPrintModel(), canvasToPdf(), ensurePlainObject(), exportOaApprovalPdf(), extractUrls(), buildFlowItems() (+39 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (36): fetchAddTaskStep(), fetchCopyTaskStep(), fetchDeleteTaskStep(), fetchDispatchProductionTaskSteps(), fetchForwardProductionTask(), fetchProductionTaskDetail(), fetchProductionTaskEditorInit(), fetchReassignProductionTask() (+28 more)
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.05
|
||||
Nodes (46): completePilotApplication(), deletePilotApplication(), fetchCreateExhibitionAppointment(), fetchDeleteExhibitionAppointment(), fetchExhibitionAppointmentList(), fetchPilotApplicationList(), fetchUpdateExhibitionAppointment(), startPilotApplication() (+38 more)
|
||||
Nodes (28): fetchProductionTaskList(), fetchProductionReceptionBySampleCode(), fetchProductionReceptionSubmit(), fetchProductionReceptionUpdate(), handleSubmit(), onUserSelectVisible(), remoteSearchUsers(), addDispatchItem() (+20 more)
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 0.05
|
||||
Nodes (39): fetchCreateQcInspectionItemTemplate(), fetchDeleteQcInspectionItemTemplate(), fetchQcInspectionItemTemplateDetail(), fetchQcInspectionItemTemplateList(), fetchQcInspectionItemTemplatesActive(), fetchUpdateQcInspectionItemTemplate(), fetchQcLabTestItemList(), fetchQcLabTestItemsActive() (+31 more)
|
||||
Cohesion: 0.06
|
||||
Nodes (38): completePilotApplication(), deletePilotApplication(), fetchPilotApplicationList(), startPilotApplication(), voidPilotApplication(), fetchClinicalRefundConfirmed(), buildConfirmedParams(), buildPendingParams() (+30 more)
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 0.06
|
||||
@@ -200,69 +202,69 @@ Cohesion: 0.1
|
||||
Nodes (2): WebSocketClient, TableCache
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 0.08
|
||||
Nodes (33): fetchRefreshToken(), toLogin(), base64ToBytes(), bytesToBase64(), decryptJson(), deriveKey(), encryptJson(), fromBase64Url() (+25 more)
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.09
|
||||
Nodes (35): formatDueDate(), formatDueDate(), buildPaymentFlowSteps(), dash(), formatAmount(), hasFinanceReceipt(), hasFinanceRefund(), hasReceiptSubmit() (+27 more)
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 0.06
|
||||
Nodes (21): fetchEthnicList(), fetchSampleFreightDetail(), fetchSampleFreightList(), fetchSampleFreightSampleOptions(), fetchUpdateSampleFreight(), applyDonationBundleToForm(), createEmptyTransport(), ensureEthnicOptionInList() (+13 more)
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 0.09
|
||||
Nodes (8): getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager, validateStorageData()
|
||||
|
||||
### Community 29 - "Community 29"
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.08
|
||||
Nodes (22): fetchMineProductionWorkOrderTabStepCounts(), fetchMineProductionWorkOrderTabStepCountsDeduped(), fetchProductionWorkOrderCellProductOptions(), fetchProductionWorkOrderDetail(), fetchProductionWorkOrderList(), fetchProductionWorkOrderQcDispatchOptions(), fetchProductionWorkOrderTabStepCounts(), fetchProductionWorkOrderTabStepCountsDeduped() (+14 more)
|
||||
Nodes (20): createEmptySnapshots(), normalizeConfiguredReagents(), normalizeConsumables(), normalizeDataCollections(), normalizeReagents(), normalizeStepOutputs(), serializeAttachments(), serializeEquipmentIds() (+12 more)
|
||||
|
||||
### Community 30 - "Community 30"
|
||||
Cohesion: 0.07
|
||||
Nodes (19): fetchDeleteMaterialBatch(), fetchMaterialBatchList(), fetchMaterialBatchOptions(), fetchMaterialList(), fetchMaterialOptions(), fetchUpdateMaterialBatch(), fetchMaterialUsageList(), fetchMyMaterialUsageList() (+11 more)
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 0.09
|
||||
Nodes (22): fetchOaAppList(), fetchOaGroupList(), fetchPublishOaApp(), loadData(), loadGroups(), resetForm(), onSuccess(), goBack() (+14 more)
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
Cohesion: 0.11
|
||||
Nodes (21): fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchUpdateProfile(), fetchUploadProfileAvatar(), fetchWxQrStatus(), fetchUserInfo(), buildWebEncryptedLoginBody() (+13 more)
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 0.08
|
||||
Nodes (17): fetchEthnicList(), fetchSampleFreightDetail(), fetchSampleFreightList(), fetchSampleFreightSampleOptions(), fetchUpdateSampleFreight(), applyDonationBundleToForm(), createEmptyTransport(), ensureEthnicOptionInList() (+9 more)
|
||||
|
||||
### Community 33 - "Community 33"
|
||||
Cohesion: 0.09
|
||||
Nodes (12): fetchMineQcWorkOrderTabUnfinishedCounts(), fetchOutboundQcDetail(), fetchQcWorkOrderDetail(), fetchQcWorkOrderList(), fetchQcWorkOrdersBySample(), fetchQcWorkOrderTabUnfinishedCounts(), reload(), reloadTabCounts() (+4 more)
|
||||
|
||||
### Community 34 - "Community 34"
|
||||
### Community 29 - "Community 29"
|
||||
Cohesion: 0.11
|
||||
Nodes (12): fetchCollectionProtocolList(), handleBatchDelete(), handleDelete(), handleDropdownCommand(), handleEdit(), handleReset(), handleSearch(), loadData() (+4 more)
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
### Community 30 - "Community 30"
|
||||
Cohesion: 0.1
|
||||
Nodes (12): fetchOutboundReleaseMaterial(), fetchOutboundReleaseReviewDetail(), fetchOutboundReleaseReviewList(), fetchReleaseReviewList(), handleReset(), handleSearch(), handleTabChange(), handleToggleSelect() (+4 more)
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 0.13
|
||||
Nodes (19): fetchCellSampleTypeList(), fetchCreateCellSampleType(), fetchDeleteCellSampleType(), fetchUpdateCellSampleType(), handleAdd(), handleBatchDelete(), handleDelete(), handleDropdownCommand() (+11 more)
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
Cohesion: 0.12
|
||||
Nodes (15): addCopyButton(), highlightCode(), insertLineNumbers(), isBlockProcessed(), markBlockAsProcessed(), processBlock(), setupHighlightDirective(), setupRippleDirective() (+7 more)
|
||||
|
||||
### Community 37 - "Community 37"
|
||||
### Community 33 - "Community 33"
|
||||
Cohesion: 0.13
|
||||
Nodes (8): chipTitle(), confirmPicker(), memberOptionLabel(), removeUser(), resolveUser(), signBadge(), signBadgeText(), updateSelect()
|
||||
|
||||
### Community 34 - "Community 34"
|
||||
Cohesion: 0.15
|
||||
Nodes (17): fetchCreateTenant(), fetchDeleteTenant(), fetchTenantList(), fetchUpdateTenant(), handleAdd(), handleBatchDelete(), handleDelete(), handleDropdownCommand() (+9 more)
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 0.2
|
||||
Nodes (14): applyFlagToRule(), applySchema(), countRules(), getActiveRule(), getDesigner(), getSetupState(), onMessage(), readFlag() (+6 more)
|
||||
|
||||
### Community 38 - "Community 38"
|
||||
### Community 36 - "Community 36"
|
||||
Cohesion: 0.16
|
||||
Nodes (14): fetchCreateEnvDetectItem(), fetchDeleteEnvDetectItem(), fetchEnvDetectItemDetail(), fetchEnvDetectItemList(), fetchEnvDetectItemsActive(), fetchUpdateEnvDetectItem(), handleAdd(), handleBatchDelete() (+6 more)
|
||||
|
||||
### Community 39 - "Community 39"
|
||||
### Community 37 - "Community 37"
|
||||
Cohesion: 0.12
|
||||
Nodes (6): setupOaConfirmationKit(), ensureOaKitMenu(), setupOaLeaveKit(), setupOaOvertimeKit(), setupOaResignationKit(), setupOaTripKit()
|
||||
|
||||
### Community 40 - "Community 40"
|
||||
### Community 38 - "Community 38"
|
||||
Cohesion: 0.16
|
||||
Nodes (10): useTable(), useTableImpl(), getColumnChecks(), useTableColumns(), createErrorHandler(), createSmartDebounce(), defaultResponseAdapter(), extractPagination() (+2 more)
|
||||
|
||||
### Community 39 - "Community 39"
|
||||
Cohesion: 0.17
|
||||
Nodes (14): fetchCreateExhibitionAppointment(), fetchDeleteExhibitionAppointment(), fetchExhibitionAppointmentList(), fetchUpdateExhibitionAppointment(), handleAdd(), handleBatchDelete(), handleDelete(), handleDropdownCommand() (+6 more)
|
||||
|
||||
### Community 40 - "Community 40"
|
||||
Cohesion: 0.18
|
||||
Nodes (10): fetchClinicalReceiptConfirmed(), buildConfirmedParams(), buildPendingParams(), handleConfirmedReset(), handleConfirmedSearch(), handlePendingReset(), handlePendingSearch(), loadAll() (+2 more)
|
||||
|
||||
### Community 41 - "Community 41"
|
||||
Cohesion: 0.27
|
||||
Nodes (14): useChart(), useChartComponent(), useChartOps(), colourBlend(), getCssVar(), getDarkColor(), getLightColor(), handleElementThemeColor() (+6 more)
|
||||
@@ -276,131 +278,139 @@ Cohesion: 0.24
|
||||
Nodes (10): buildPrintDocumentHtml(), cleanupPrintIframe(), embedSopImagesAsDataUrls(), getSopA4ContentHeightPx(), getSopA4EditorHeightPx(), normalizeSopCheckboxes(), prepareSopHtmlForPrint(), printSopHtml() (+2 more)
|
||||
|
||||
### Community 45 - "Community 45"
|
||||
Cohesion: 0.25
|
||||
Nodes (2): handleTouchEnd(), setTransition()
|
||||
Cohesion: 0.27
|
||||
Nodes (10): nodeSummary(), createDefaultFlow(), createEndNode(), createNode(), ensureFlowRoot(), newId(), normalizeCcAssigneeTypes(), removeChildNode() (+2 more)
|
||||
|
||||
### Community 46 - "Community 46"
|
||||
Cohesion: 0.26
|
||||
Nodes (8): fetchSampleStorageCertQc(), saveSampleStorageCertQc(), applyDetail(), handleSave(), loadDetail(), resetFormKeys(), resolveStorageCertQcTypeKey(), supportsStorageCertQc()
|
||||
|
||||
### Community 47 - "Community 47"
|
||||
Cohesion: 0.33
|
||||
Nodes (7): buildSheetColumns(), downloadOaHistoryExcel(), exportTimestamp(), pad(), sanitizeFilename(), styleHeaderCell(), writeHeaderRows()
|
||||
|
||||
### Community 48 - "Community 48"
|
||||
Cohesion: 0.25
|
||||
Nodes (3): companyProgressTagType(), companyProgressText(), companyGroupProgress()
|
||||
Nodes (2): handleTouchEnd(), setTransition()
|
||||
|
||||
### Community 50 - "Community 50"
|
||||
### Community 49 - "Community 49"
|
||||
Cohesion: 0.28
|
||||
Nodes (1): FireworkSystem
|
||||
|
||||
### Community 53 - "Community 53"
|
||||
Cohesion: 0.39
|
||||
Nodes (6): isIgnorableScriptError(), normalizeErrorMessage(), registerPromiseErrorHandler(), registerResourceErrorHandler(), scriptErrorHandler(), setupErrorHandle()
|
||||
|
||||
### Community 51 - "Community 51"
|
||||
### Community 54 - "Community 54"
|
||||
Cohesion: 0.36
|
||||
Nodes (1): TableHeightCalculator
|
||||
|
||||
### Community 53 - "Community 53"
|
||||
### Community 56 - "Community 56"
|
||||
Cohesion: 0.33
|
||||
Nodes (2): logApiCryptoBootInfo(), maskSecret()
|
||||
|
||||
### Community 54 - "Community 54"
|
||||
### Community 57 - "Community 57"
|
||||
Cohesion: 0.38
|
||||
Nodes (4): handleFocus(), handleInput(), parseDecimalToNumber(), sanitizeDecimalInput()
|
||||
|
||||
### Community 55 - "Community 55"
|
||||
### Community 58 - "Community 58"
|
||||
Cohesion: 0.29
|
||||
Nodes (1): fetchQcMonthTemplateList()
|
||||
|
||||
### Community 56 - "Community 56"
|
||||
### Community 59 - "Community 59"
|
||||
Cohesion: 0.4
|
||||
Nodes (2): isUnfinishedQcWorkOrder(), resolveQcWorkOrderUnfinishedBadgeCount()
|
||||
|
||||
### Community 57 - "Community 57"
|
||||
### Community 60 - "Community 60"
|
||||
Cohesion: 0.5
|
||||
Nodes (2): parseEquipmentIds(), syncEquipmentNamesFromOptions()
|
||||
|
||||
### Community 59 - "Community 59"
|
||||
### Community 62 - "Community 62"
|
||||
Cohesion: 0.5
|
||||
Nodes (2): focusInput(), handleKeydown()
|
||||
|
||||
### Community 60 - "Community 60"
|
||||
Cohesion: 0.4
|
||||
Nodes (1): fetchQcMaterialAmountAuthUsers()
|
||||
|
||||
### Community 63 - "Community 63"
|
||||
### Community 65 - "Community 65"
|
||||
Cohesion: 0.67
|
||||
Nodes (2): isCurrentPending(), isNotReached()
|
||||
|
||||
### Community 64 - "Community 64"
|
||||
### Community 66 - "Community 66"
|
||||
Cohesion: 0.67
|
||||
Nodes (2): if(), logSidebarMenu()
|
||||
|
||||
### Community 70 - "Community 70"
|
||||
### Community 72 - "Community 72"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): getSettingDefaults(), resetToDefaults()
|
||||
|
||||
### Community 74 - "Community 74"
|
||||
### Community 77 - "Community 77"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): copyTextToClipboard(), copyTextWithFallback()
|
||||
|
||||
### Community 78 - "Community 78"
|
||||
### Community 80 - "Community 80"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): getSourceProtocolAttachment(), normalizeAttachmentFiles()
|
||||
|
||||
### Community 91 - "Community 91"
|
||||
### Community 94 - "Community 94"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): emptyTabCounts(), useProductionWorkOrderTabStepCounts()
|
||||
|
||||
### Community 94 - "Community 94"
|
||||
### Community 97 - "Community 97"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): initialProgress(), useQcWorkOrderAttachmentExport()
|
||||
|
||||
### Community 95 - "Community 95"
|
||||
### Community 98 - "Community 98"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): emptyTabCounts(), useQcWorkOrderTabUnfinishedCounts()
|
||||
|
||||
## Knowledge Gaps
|
||||
- **Thin community `Community 24`** (42 nodes): `WebSocketClient`, `.calculateReconnectDelay()`, `.clearAllTimers()`, `.clearConnectionTimers()`, `.clearTimer()`, `.close()`, `.closeCurrentSocketForReconnect()`, `.connect()`, `.connectionStatusText()`, `.constructor()`, `.destroyInstance()`, `.flushMessageQueue()`, `.getInstance()`, `.getReadyStateText()`, `.handleClose()`, `.handleConnectionTimeout()`, `.handleError()`, `.handleMessage()`, `.handleOpen()`, `.init()`, `.isWebSocketConnected()`, `.reconnect()`, `.resetHeartbeat()`, `.send()`, `.startHeartbeat()`, `.startPing()`, `TableCache`, `.cleanupExpired()`, `.clear()`, `.clearByTags()`, `.clearCurrentSearch()`, `.clearPagination()`, `.constructor()`, `.evictLRU()`, `.generateKey()`, `.generateTags()`, `.get()`, `.getStats()`, `.log()`, `.set()`, `index.ts`, `tableCache.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 45`** (9 nodes): `cleanupEventListeners()`, `handleTouchEnd()`, `handleTouchMove()`, `handleTouchStart()`, `handleWheelScroll()`, `if()`, `setTransition()`, `setupEventListeners()`, `index.vue`
|
||||
- **Thin community `Community 48`** (9 nodes): `cleanupEventListeners()`, `handleTouchEnd()`, `handleTouchMove()`, `handleTouchStart()`, `handleWheelScroll()`, `if()`, `setTransition()`, `setupEventListeners()`, `index.vue`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 51`** (8 nodes): `TableHeightCalculator`, `.calculate()`, `.calculateOffset()`, `.calculatePaginationOffset()`, `.constructor()`, `.getHeaderHeight()`, `useTableHeight()`, `useTableHeight.ts`
|
||||
- **Thin community `Community 49`** (9 nodes): `FireworkSystem`, `.constructor()`, `.createFirework()`, `.createParticle()`, `.getAvailableParticle()`, `.initializePool()`, `.preloadAllImages()`, `.preloadImage()`, `index.vue`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 53`** (7 nodes): `App.vue`, `main.ts`, `refreshButtonAutoIcons()`, `refreshPanelTitleIcons()`, `logApiCryptoBootInfo()`, `maskSecret()`, `console.ts`
|
||||
- **Thin community `Community 54`** (8 nodes): `TableHeightCalculator`, `.calculate()`, `.calculateOffset()`, `.calculatePaginationOffset()`, `.constructor()`, `.getHeaderHeight()`, `useTableHeight()`, `useTableHeight.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 55`** (7 nodes): `fetchConfirmQcMonthImport()`, `fetchCreateQcMonthTemplate()`, `fetchDeleteQcMonthTemplate()`, `fetchPreviewQcMonthImport()`, `fetchQcMonthTemplateList()`, `fetchUpdateQcMonthTemplate()`, `qc-material-month-template.ts`
|
||||
- **Thin community `Community 56`** (7 nodes): `App.vue`, `main.ts`, `refreshButtonAutoIcons()`, `refreshPanelTitleIcons()`, `logApiCryptoBootInfo()`, `maskSecret()`, `console.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 56`** (6 nodes): `formatQcWorkOrderUnfinishedBadgeCount()`, `isUnfinishedQcWorkOrder()`, `qcWorkOrderUnfinishedBadgeTitle()`, `resolveQcWorkOrderUnfinishedBadgeCount()`, `sumQcWorkOrderGroupUnfinishedBadgeCount()`, `qc-work-order-unfinished-badge.ts`
|
||||
- **Thin community `Community 58`** (7 nodes): `fetchConfirmQcMonthImport()`, `fetchCreateQcMonthTemplate()`, `fetchDeleteQcMonthTemplate()`, `fetchPreviewQcMonthImport()`, `fetchQcMonthTemplateList()`, `fetchUpdateQcMonthTemplate()`, `qc-material-month-template.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 57`** (5 nodes): `equipmentOptionLabel()`, `joinEquipmentIds()`, `parseEquipmentIds()`, `syncEquipmentNamesFromOptions()`, `equipment-select.ts`
|
||||
- **Thin community `Community 59`** (6 nodes): `formatQcWorkOrderUnfinishedBadgeCount()`, `isUnfinishedQcWorkOrder()`, `qcWorkOrderUnfinishedBadgeTitle()`, `resolveQcWorkOrderUnfinishedBadgeCount()`, `sumQcWorkOrderGroupUnfinishedBadgeCount()`, `qc-work-order-unfinished-badge.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 59`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
|
||||
- **Thin community `Community 60`** (5 nodes): `equipmentOptionLabel()`, `joinEquipmentIds()`, `parseEquipmentIds()`, `syncEquipmentNamesFromOptions()`, `equipment-select.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 60`** (5 nodes): `fetchBatchRevokeQcMaterialAmountAuth()`, `fetchGrantQcMaterialAmountAuth()`, `fetchQcMaterialAmountAuthUsers()`, `fetchRevokeQcMaterialAmountAuth()`, `qc-material-amount-auth.ts`
|
||||
- **Thin community `Community 62`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 63`** (4 nodes): `isCurrentPending()`, `isNotReached()`, `mergeProgressWithComments()`, `oa-progress-merge-comments.ts`
|
||||
- **Thin community `Community 65`** (4 nodes): `isCurrentPending()`, `isNotReached()`, `mergeProgressWithComments()`, `oa-progress-merge-comments.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 64`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
|
||||
- **Thin community `Community 66`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 70`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
|
||||
- **Thin community `Community 72`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 74`** (3 nodes): `copyTextToClipboard()`, `copyTextWithFallback()`, `copy-text.ts`
|
||||
- **Thin community `Community 77`** (3 nodes): `copyTextToClipboard()`, `copyTextWithFallback()`, `copy-text.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 78`** (3 nodes): `getSourceProtocolAttachment()`, `normalizeAttachmentFiles()`, `protocol-source-attachment.ts`
|
||||
- **Thin community `Community 80`** (3 nodes): `getSourceProtocolAttachment()`, `normalizeAttachmentFiles()`, `protocol-source-attachment.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 91`** (3 nodes): `emptyTabCounts()`, `useProductionWorkOrderTabStepCounts()`, `useProductionWorkOrderTabStepCounts.ts`
|
||||
- **Thin community `Community 94`** (3 nodes): `emptyTabCounts()`, `useProductionWorkOrderTabStepCounts()`, `useProductionWorkOrderTabStepCounts.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 94`** (3 nodes): `initialProgress()`, `useQcWorkOrderAttachmentExport()`, `useQcWorkOrderAttachmentExport.ts`
|
||||
- **Thin community `Community 97`** (3 nodes): `initialProgress()`, `useQcWorkOrderAttachmentExport()`, `useQcWorkOrderAttachmentExport.ts`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 95`** (3 nodes): `emptyTabCounts()`, `useQcWorkOrderTabUnfinishedCounts()`, `useQcWorkOrderTabUnfinishedCounts.ts`
|
||||
- **Thin community `Community 98`** (3 nodes): `emptyTabCounts()`, `useQcWorkOrderTabUnfinishedCounts()`, `useQcWorkOrderTabUnfinishedCounts.ts`
|
||||
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 `get()` connect `Community 0` to `Community 1`, `Community 2`, `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 15`, `Community 17`, `Community 18`, `Community 19`, `Community 21`, `Community 22`, `Community 23`, `Community 27`, `Community 29`, `Community 30`, `Community 31`, `Community 32`, `Community 33`, `Community 34`, `Community 35`, `Community 38`, `Community 42`, `Community 55`, `Community 60`?**
|
||||
_High betweenness centrality (0.282) - this node is a cross-community bridge._
|
||||
- **Why does `push()` connect `Community 3` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 17`, `Community 19`, `Community 20`, `Community 21`, `Community 22`, `Community 24`, `Community 25`, `Community 26`, `Community 29`, `Community 31`, `Community 33`?**
|
||||
_High betweenness centrality (0.235) - this node is a cross-community bridge._
|
||||
- **Why does `Number()` connect `Community 1` to `Community 0`, `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 9`, `Community 11`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 17`, `Community 19`, `Community 20`, `Community 23`, `Community 25`, `Community 26`, `Community 27`, `Community 30`, `Community 41`, `Community 54`?**
|
||||
_High betweenness centrality (0.133) - this node is a cross-community bridge._
|
||||
- **Are the 346 inferred relationships involving `get()` (e.g. with `resolveStorageSlotRangeFill()` and `buildNodeCodePathFromFlat()`) actually correct?**
|
||||
_`get()` has 346 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 127 inferred relationships involving `push()` (e.g. with `refreshQcMenuBadges()` and `collectLeafFacilityNodesInTreeOrder()`) actually correct?**
|
||||
_`push()` has 127 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 101 inferred relationships involving `Number()` (e.g. with `slotOrderNum()` and `isSlotAvailable()`) actually correct?**
|
||||
_`Number()` has 101 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 34 inferred relationships involving `emit()` (e.g. with `updateSamples()` and `notifyChanged()`) actually correct?**
|
||||
_`emit()` has 34 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Why does `get()` connect `Community 0` to `Community 1`, `Community 2`, `Community 3`, `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 17`, `Community 18`, `Community 19`, `Community 20`, `Community 21`, `Community 22`, `Community 23`, `Community 27`, `Community 28`, `Community 29`, `Community 30`, `Community 31`, `Community 34`, `Community 36`, `Community 39`, `Community 40`, `Community 42`, `Community 46`, `Community 58`?**
|
||||
_High betweenness centrality (0.298) - this node is a cross-community bridge._
|
||||
- **Why does `push()` connect `Community 1` to `Community 0`, `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 13`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 19`, `Community 20`, `Community 21`, `Community 24`, `Community 26`, `Community 27`, `Community 33`, `Community 45`, `Community 47`, `Community 49`?**
|
||||
_High betweenness centrality (0.234) - this node is a cross-community bridge._
|
||||
- **Why does `Number()` connect `Community 1` to `Community 0`, `Community 2`, `Community 3`, `Community 4`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 41`, `Community 12`, `Community 15`, `Community 17`, `Community 18`, `Community 23`, `Community 57`, `Community 26`, `Community 28`?**
|
||||
_High betweenness centrality (0.113) - this node is a cross-community bridge._
|
||||
- **Are the 349 inferred relationships involving `get()` (e.g. with `resolveStorageSlotRangeFill()` and `buildNodeCodePathFromFlat()`) actually correct?**
|
||||
_`get()` has 349 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 126 inferred relationships involving `push()` (e.g. with `refreshQcMenuBadges()` and `collectLeafFacilityNodesInTreeOrder()`) actually correct?**
|
||||
_`push()` has 126 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 100 inferred relationships involving `Number()` (e.g. with `slotOrderNum()` and `isSlotAvailable()`) actually correct?**
|
||||
_`Number()` has 100 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 35 inferred relationships involving `emit()` (e.g. with `notifyChanged()` and `handleSubmit()`) actually correct?**
|
||||
_`emit()` has 35 INFERRED edges - model-reasoned connections that need verification._
|
||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user