Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8020e667f1 | ||
|
|
50d56ce0bb | ||
|
|
4efd423d2d | ||
|
|
5e563a6669 | ||
|
|
d66e55af65 | ||
|
|
045ee8d0e3 | ||
|
|
4583c54568 | ||
|
|
5dad1bb0e3 | ||
|
|
19bc66ba45 | ||
|
|
076e892df9 | ||
|
|
07508e677b | ||
|
|
bab304abd3 |
No files matched your search
+15
-20
@@ -2,6 +2,12 @@
|
||||
# 镜像与包名固定为 v1.1.0(与现网 .env / 部署习惯一致),便于生产升级时不必改镜像标签
|
||||
# 产物:/root/zkxw-releases/zkxw-docker-deploy-v1.1.0.tar.gz
|
||||
# 脚本:./scripts/package-docker-deploy.sh
|
||||
#
|
||||
# docker.sock 与 /releases 由 runner 环境变量 DRONE_RUNNER_VOLUMES 注入,
|
||||
# 勿在本文件再声明同路径 host volume,否则会 Duplicate mount point。
|
||||
#
|
||||
# 注意:Drone 会对 commands 里的 ${VAR} 做预处理替换;自定义环境变量请用 $${VAR}
|
||||
# 或直接写死版本号,避免变成空字符串。
|
||||
|
||||
kind: pipeline
|
||||
type: docker
|
||||
@@ -17,35 +23,24 @@ trigger:
|
||||
clone:
|
||||
depth: 1
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
host:
|
||||
path: /var/run/docker.sock
|
||||
- name: releases
|
||||
host:
|
||||
path: /root/zkxw-releases
|
||||
|
||||
steps:
|
||||
- name: package-docker
|
||||
image: docker:27-cli
|
||||
privileged: true
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
- name: releases
|
||||
path: /releases
|
||||
environment:
|
||||
DOCKER_BUILDKIT: '1'
|
||||
NODE_MAX_OLD_SPACE_SIZE: '3072'
|
||||
NPM_REGISTRY: https://registry.npmmirror.com
|
||||
PIP_INDEX_URL: https://mirrors.aliyun.com/pypi/simple/
|
||||
PIP_TRUSTED_HOST: mirrors.aliyun.com
|
||||
# 固定交付版本(勿改为 DRONE_TAG,生产 .env 使用 v1.1.0)
|
||||
ZKXW_DEPLOY_VERSION: v1.1.0
|
||||
commands:
|
||||
- set -eu
|
||||
# 国内机访问 dl-cdn.alpinelinux.org 偶发 Permission denied,改用阿里云镜像
|
||||
- sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||
- apk add --no-cache bash git python3
|
||||
- echo "==> git tag=${DRONE_TAG:-none} deploy_version=${ZKXW_DEPLOY_VERSION} commit=${DRONE_COMMIT}"
|
||||
# 固定 v1.1.0;$${ } 防止 Drone 预处理把自定义变量掏空
|
||||
- export ZKXW_DEPLOY_VERSION="$${ZKXW_DEPLOY_VERSION:-v1.1.0}"
|
||||
- echo "==> git tag=${DRONE_TAG:-none} deploy_version=$${ZKXW_DEPLOY_VERSION} commit=${DRONE_COMMIT}"
|
||||
- chmod +x scripts/package-docker-deploy.sh scripts/build-docker-images.sh
|
||||
- |
|
||||
if [ -f /releases/.deploy-docker.env ]; then
|
||||
@@ -53,8 +48,8 @@ steps:
|
||||
cp /releases/.deploy-docker.env deploy/docker/.env
|
||||
echo "using /releases/.deploy-docker.env for secret inject"
|
||||
fi
|
||||
- ./scripts/package-docker-deploy.sh "${ZKXW_DEPLOY_VERSION}"
|
||||
- ./scripts/package-docker-deploy.sh "$${ZKXW_DEPLOY_VERSION}"
|
||||
- mkdir -p /releases
|
||||
- cp -v dist/zkxw-docker-deploy-"${ZKXW_DEPLOY_VERSION}".tar.gz /releases/
|
||||
- ls -lh /releases/zkxw-docker-deploy-"${ZKXW_DEPLOY_VERSION}".tar.gz
|
||||
- sha256sum /releases/zkxw-docker-deploy-"${ZKXW_DEPLOY_VERSION}".tar.gz
|
||||
- cp -v dist/zkxw-docker-deploy-"$${ZKXW_DEPLOY_VERSION}".tar.gz /releases/
|
||||
- ls -lh /releases/zkxw-docker-deploy-"$${ZKXW_DEPLOY_VERSION}".tar.gz
|
||||
- sha256sum /releases/zkxw-docker-deploy-"$${ZKXW_DEPLOY_VERSION}".tar.gz
|
||||
@@ -70,6 +70,7 @@ Full specs: `openspec/changes/zkxw-engineering-standards/specs/*/spec.md`. Below
|
||||
- **DB changes**: SHALL maintain SQL in `docs/sql/`. Schema changes SHALL align with `database-conventions` public field rules.
|
||||
- **Commit messages**: SHALL be complete sentences describing "what changed and why". Follow Conventional Commits (`feat|fix|docs|…`).
|
||||
- **Field consistency (CRUD ↔ Schema ↔ Frontend)**: When implementing CRUD for any entity, the `_row_to_dict` function is the **single source of truth** for all readable fields. The CreateBody/UpdateBody Pydantic schemas and the frontend `formData` initialization SHALL include every writable field present in `_row_to_dict` (excluding computed/read-only fields like `id`, `createTime`, `updateTime`, `stockQty`). Run `python scripts/check_field_consistency.py` after implementation to verify. See `docs/dev-field-consistency.md` for details.
|
||||
- **Child-row save(禁止整表删除再新增)**: 保存子表时 SHALL 按行处理:已有行就地更新,只插入新增行,只软删除用户去掉的行。客户端可以提交稳定 `id`,字段变化时该行必须保留原 `id`。请求未带 `id` 时,先用稳定业务键(样本编码、物料编码、条码、组分 id 等)匹配一条尚未占用的有效行,匹配不到再插入新 UUID。属于其他父记录或其他租户的 `id` SHALL 拒绝。SHALL NOT 在保存时把子表全部软删除再整批插入新行——其他表会拿这些 id 做外键(`production_step_output_id`、`protocol_sample_id`、`donation_sample_id`),整批换 id 会把关联打断。删除父记录时可以级联软删除其子行。复制或克隆父记录时 SHALL 为子行分配新 id。实现入口:`backend/app/crud/child_row_sync.py` 的 `sync_child_rows`。
|
||||
|
||||
### Permission Conventions
|
||||
|
||||
@@ -220,4 +221,5 @@ pnpm clean:dev # strip demo data for fresh start
|
||||
- **Table column widths**: All `el-table-column` SHALL use `min-width` (not `width`) for proportional scaling when viewport resizes.
|
||||
- **Toolbar**: "已选 N 项" first, then primary action button (only one `type="primary"` per toolbar row), no `space-between` push.
|
||||
- **Database changes**: always pair with schema SQL in `docs/sql/` and evaluate API/TypeScript type impact.
|
||||
- **Child-row save**: SHALL NOT soft-delete every child row and insert new rows on save. Update in place, insert new rows, soft-delete only removed rows. See Coding Conventions.
|
||||
- **进程重启**:修改 bug 时,如果前端或后端需要重启,Agent 只告知用户,由用户自行重启。Agent 不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。
|
||||
@@ -86,3 +86,18 @@ SECRET_KEY=
|
||||
# WX_MP_TPL_PROGRESS_FIELD_APPROVER=thing6
|
||||
# WX_MP_TPL_PROGRESS_FIELD_RESULT=const4
|
||||
# WX_MP_TPL_PROGRESS_FIELD_TIME=time11
|
||||
# 基础实验交付日前一天提醒(出库提醒模板)
|
||||
# WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY=oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER=thing6
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT=thing10
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_QTY=character_string8
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT=thing7
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_TIME=time14
|
||||
# 协议新增样本采集通知(工单类模板)
|
||||
# WX_MP_TEMPLATE_SAMPLE_COLLECT=iOnQ9k2mavAOwFbp_6yH4t1SHqpEkaiwNWKueY0kpwE
|
||||
# WX_MP_SAMPLE_COLLECT_ROLE_NAME=样本采集通知人员
|
||||
# WX_MP_TPL_SC_FIELD_TITLE=thing2
|
||||
# WX_MP_TPL_SC_FIELD_APPLICANT=thing8
|
||||
# WX_MP_TPL_SC_FIELD_TIME=time3
|
||||
# WX_MP_TPL_SC_FIELD_TYPE=phrase10
|
||||
# WX_MP_TPL_SC_FIELD_SAMPLE_TYPE=thing17
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
"""运营端管理接口(租户、角色下拉等)"""
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, List, Optional
|
||||
@@ -1616,7 +1617,10 @@ async def upload_attachment(
|
||||
return error_response(400, "请选择文件")
|
||||
try:
|
||||
file_content = await file.read()
|
||||
full_url = storage.upload_file(file_content, file.filename, folder or "")
|
||||
# MinIO/本地写入是同步的。多张大图并行时若占住事件循环,其它上传会一直等不到响应,前端表现为超时后文件消失。
|
||||
full_url = await asyncio.to_thread(
|
||||
storage.upload_file, file_content, file.filename, folder or ""
|
||||
)
|
||||
base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
|
||||
if base_url and full_url.startswith(base_url + "/"):
|
||||
file_url = full_url[len(base_url) + 1:]
|
||||
|
||||
@@ -22,6 +22,12 @@ from app.models.models import (
|
||||
SysUser,
|
||||
)
|
||||
from app.services.party_customer_sync import sync_party_customers
|
||||
from app.services.sample_collect_wx_notify import (
|
||||
PROTOCOL_KIND_CELL,
|
||||
sample_codes_from_orm_rows,
|
||||
sample_codes_from_payload,
|
||||
schedule_sample_collect_notify,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["细胞存储"])
|
||||
|
||||
@@ -279,26 +285,40 @@ def _persist_protocol_sub_resources(
|
||||
sample_payments: Optional[list] = None,
|
||||
storage_cert: Optional[dict],
|
||||
payment_model_version: int = 2,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
"""持久化协议子资源。
|
||||
|
||||
version=1:缴费(含明细校验)须在样本之前,避免校验失败后已占用样本编码。
|
||||
version=2:样本先落库占码,再按 sample_code 挂样本缴费方案。
|
||||
|
||||
默认整段子资源共用一次事务:内部一律 commit=False,成功后再统一 commit,
|
||||
避免样本已提交、缴费校验失败导致「提示失败但部分已保存」。
|
||||
"""
|
||||
if party_customer:
|
||||
_parse_datetime_fields(party_customer, ["newbornDeliveryTime"])
|
||||
party_customer = sync_party_customers(
|
||||
db, party_customer, tenant_no=tenant_no, user_id=user_id
|
||||
db,
|
||||
party_customer,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
commit=False,
|
||||
)
|
||||
cp_svc.upsert_party_customer(
|
||||
db, protocol_id, tenant_no, user_id, commit=False, **party_customer
|
||||
)
|
||||
cp_svc.upsert_party_customer(db, protocol_id, tenant_no, user_id, **party_customer)
|
||||
if collection_info:
|
||||
_parse_datetime_fields(collection_info, ["sampleCollectTime", "maternalBloodCollectTime"])
|
||||
cp_svc.upsert_collection_info(db, protocol_id, tenant_no, user_id, **collection_info)
|
||||
cp_svc.upsert_collection_info(
|
||||
db, protocol_id, tenant_no, user_id, commit=False, **collection_info
|
||||
)
|
||||
|
||||
version = int(payment_model_version or 2)
|
||||
if version >= 2:
|
||||
if samples is not None:
|
||||
cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples)
|
||||
cp_svc.save_samples(
|
||||
db, protocol_id, tenant_no, user_id, samples, commit=False
|
||||
)
|
||||
if sample_payments is not None:
|
||||
from app.crud import protocol_sample_payment as sp_pay_svc
|
||||
|
||||
@@ -308,6 +328,7 @@ def _persist_protocol_sub_resources(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
sample_payments=sample_payments,
|
||||
commit=False,
|
||||
)
|
||||
else:
|
||||
# 旧模型:缴费先于样本
|
||||
@@ -319,12 +340,20 @@ def _persist_protocol_sub_resources(
|
||||
user_id,
|
||||
payment=payment,
|
||||
payment_details=payment_details,
|
||||
commit=False,
|
||||
)
|
||||
if samples is not None:
|
||||
cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples)
|
||||
cp_svc.save_samples(
|
||||
db, protocol_id, tenant_no, user_id, samples, commit=False
|
||||
)
|
||||
|
||||
if storage_cert:
|
||||
cp_svc.upsert_storage_cert(db, protocol_id, tenant_no, user_id, **storage_cert)
|
||||
cp_svc.upsert_storage_cert(
|
||||
db, protocol_id, tenant_no, user_id, commit=False, **storage_cert
|
||||
)
|
||||
|
||||
if commit:
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/collection-protocols")
|
||||
@@ -372,6 +401,7 @@ async def create_collection_protocol(
|
||||
remark=body.remark,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
commit=False,
|
||||
)
|
||||
protocol_id = obj.id
|
||||
_persist_protocol_sub_resources(
|
||||
@@ -389,20 +419,23 @@ async def create_collection_protocol(
|
||||
payment_model_version=int(
|
||||
getattr(obj, "payment_model_version", None) or 2
|
||||
),
|
||||
commit=True,
|
||||
)
|
||||
db.refresh(obj)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=protocol_id,
|
||||
protocol_kind=PROTOCOL_KIND_CELL,
|
||||
before_codes=[],
|
||||
submitted_codes=(
|
||||
sample_codes_from_payload(body.samples)
|
||||
if body.samples is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return success_response(cp_svc._protocol_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
# 子表分段 commit 后校验失败时,补偿软删主单及样本,释放样本编码占用
|
||||
if protocol_id:
|
||||
try:
|
||||
cp_svc.soft_delete_collection_protocol(
|
||||
db,
|
||||
protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@@ -447,11 +480,15 @@ async def update_collection_protocol(
|
||||
)
|
||||
if resolved is not None:
|
||||
main_payload["health_survey_form_urls"] = resolved
|
||||
before_codes = sample_codes_from_orm_rows(
|
||||
cp_svc.list_samples(db, protocol_id, tenant_no)
|
||||
)
|
||||
obj = cp_svc.update_collection_protocol(
|
||||
db,
|
||||
protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
commit=False,
|
||||
**{
|
||||
k: v
|
||||
for k, v in main_payload.items()
|
||||
@@ -473,9 +510,23 @@ async def update_collection_protocol(
|
||||
payment_model_version=int(
|
||||
getattr(obj, "payment_model_version", None) or 1
|
||||
),
|
||||
commit=True,
|
||||
)
|
||||
db.refresh(obj)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=protocol_id,
|
||||
protocol_kind=PROTOCOL_KIND_CELL,
|
||||
before_codes=before_codes,
|
||||
submitted_codes=(
|
||||
sample_codes_from_payload(body.samples)
|
||||
if body.samples is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return success_response(cp_svc._protocol_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@@ -1177,7 +1228,7 @@ def _attach_handler_name(db: Session, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
uid = (payload.get("handlerUserId") or "").strip()
|
||||
name = ""
|
||||
if uid:
|
||||
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
|
||||
u = db.query(SysUser).filter(SysUser.id == uid).first()
|
||||
name = (u.nick_name or "") if u else ""
|
||||
payload["handlerUserName"] = name
|
||||
return payload
|
||||
|
||||
@@ -171,6 +171,7 @@ class StockCheckCreateBody(BaseModel):
|
||||
|
||||
class StockCheckUnusedLineItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
usage_id: Optional[str] = Field(None, alias="usageId")
|
||||
batch_id: Optional[str] = Field(None, alias="batchId")
|
||||
material_code: Optional[str] = Field(None, alias="materialCode")
|
||||
|
||||
@@ -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. 细胞产品价格
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,15 +11,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.data_scope import get_user_dept_id
|
||||
from app.core.menu_guard import require_admin_or_super_admin, require_strict_menu_code
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import presign_order as po_svc
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["细胞存储-预签约订单"])
|
||||
|
||||
PAYMENT_CONFIRM_MENU = "biz:cellStoragePaymentConfirm:list"
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
@@ -400,7 +398,7 @@ async def list_presign_receipt_confirmed(
|
||||
async def confirm_presign_transfer(
|
||||
transfer_id: str,
|
||||
body: Optional[FinanceConfirmBody] = None,
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
@@ -429,7 +427,7 @@ async def confirm_presign_transfer(
|
||||
@router.post("/presign-transfers/{transfer_id}/receipt-reject")
|
||||
async def reject_presign_transfer(
|
||||
transfer_id: str,
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
|
||||
@@ -1277,6 +1277,7 @@ async def delete_consumable_pack(
|
||||
|
||||
class ProcessReagentInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
reagent_code: Optional[str] = Field(None, alias="reagentCode")
|
||||
reagent_name: Optional[str] = Field(None, alias="reagentName")
|
||||
spec: Optional[str] = None
|
||||
@@ -1288,6 +1289,7 @@ class ProcessReagentInput(BaseModel):
|
||||
|
||||
class ProcessConsumableInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
consumable_code: Optional[str] = Field(None, alias="consumableCode")
|
||||
consumable_name: Optional[str] = Field(None, alias="consumableName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
@@ -1299,6 +1301,7 @@ class ProcessConsumableInput(BaseModel):
|
||||
|
||||
class ProcessOutputInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
output_type_id: Optional[str] = Field(None, alias="outputTypeId")
|
||||
output_name: Optional[str] = Field(None, alias="outputName")
|
||||
output_barcode: Optional[str] = Field(None, alias="outputBarcode")
|
||||
@@ -1307,6 +1310,7 @@ class ProcessOutputInput(BaseModel):
|
||||
|
||||
class ProcessDataCollectionInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
collection_name: str = Field(..., alias="collectionName")
|
||||
unit: Optional[str] = None
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
@@ -2734,6 +2738,7 @@ async def replace_task_step_reagents(
|
||||
for it in body:
|
||||
d = it.model_dump(by_alias=False)
|
||||
items_data.append({
|
||||
"id": d.get("id"),
|
||||
"reagent_code": d.get("material_code"),
|
||||
"reagent_name": d.get("material_name"),
|
||||
"spec": d.get("spec"),
|
||||
@@ -2767,6 +2772,7 @@ async def replace_task_step_consumables(
|
||||
for it in body:
|
||||
d = it.model_dump(by_alias=False)
|
||||
items_data.append({
|
||||
"id": d.get("id"),
|
||||
"consumable_code": d.get("material_code"),
|
||||
"consumable_name": d.get("material_name"),
|
||||
"spec_model": d.get("spec_model"),
|
||||
@@ -2800,6 +2806,7 @@ async def replace_task_step_configured_reagents(
|
||||
for it in body:
|
||||
d = it.model_dump(by_alias=False)
|
||||
items_data.append({
|
||||
"id": d.get("id"),
|
||||
"configured_reagent_code": d.get("material_code"),
|
||||
"configured_reagent_name": d.get("material_name"),
|
||||
"spec": d.get("spec"),
|
||||
@@ -2851,8 +2858,10 @@ async def replace_task_step_outputs(
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items_data = [it.model_dump(by_alias=False) for it in body]
|
||||
production_task_svc.replace_step_outputs(db, step_id, tenant_no, user.get("user_id"), items_data)
|
||||
return success_response(None, message="更新成功")
|
||||
saved = production_task_svc.replace_step_outputs(
|
||||
db, step_id, tenant_no, user.get("user_id"), items_data
|
||||
)
|
||||
return success_response(saved, message="更新成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
|
||||
@@ -739,6 +739,7 @@ class StockCheckCreateBody(BaseModel):
|
||||
|
||||
class StockCheckUnusedLineItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
usage_id: Optional[str] = Field(None, alias="usageId")
|
||||
batch_id: Optional[str] = Field(None, alias="batchId")
|
||||
material_code: Optional[str] = Field(None, alias="materialCode")
|
||||
@@ -926,6 +927,7 @@ async def cancel_production_material_stock_check(
|
||||
|
||||
class FormulaLineBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
component_material_id: str = Field(..., alias="componentMaterialId")
|
||||
component_material_type: str = Field(..., alias="componentMaterialType")
|
||||
standard_qty: float = Field(..., gt=0, alias="standardQty")
|
||||
|
||||
@@ -9,15 +9,13 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin, require_strict_menu_code
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import protocol_sample_payment as psp_svc
|
||||
from app.models.models import BizCollectionProtocol, SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["细胞存储-按样本缴费"])
|
||||
|
||||
PAYMENT_CONFIRM_MENU = "biz:cellStoragePaymentConfirm:list"
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
@@ -587,7 +585,7 @@ async def list_receipt_confirmed(
|
||||
async def confirm_payment_transfer(
|
||||
transfer_id: str,
|
||||
body: Optional[FinanceConfirmBody] = None,
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
@@ -620,7 +618,7 @@ async def confirm_payment_transfer(
|
||||
@router.post("/payment-transfers/{transfer_id}/receipt-reject")
|
||||
async def reject_payment_transfer(
|
||||
transfer_id: str,
|
||||
user: dict = Depends(require_strict_menu_code(PAYMENT_CONFIRM_MENU)),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
|
||||
@@ -826,6 +826,9 @@ async def list_qc_work_orders(
|
||||
infusionTimeEnd: Optional[str] = Query(None, alias="infusionTimeEnd"),
|
||||
triggerSource: Optional[str] = Query(None, alias="triggerSource"),
|
||||
sampleTypeId: Optional[str] = Query(None, alias="sampleTypeId"),
|
||||
protocolStatus: Optional[str] = Query(
|
||||
None, alias="protocolStatus", description="协议/捐赠状态,多个用英文逗号分隔"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcWorkOrder:list")),
|
||||
):
|
||||
@@ -842,6 +845,7 @@ async def list_qc_work_orders(
|
||||
reception_source=receptionSource,
|
||||
trigger_source=triggerSource,
|
||||
sample_type_id=sampleTypeId,
|
||||
protocol_status=protocolStatus,
|
||||
create_time_start=createTimeStart,
|
||||
create_time_end=createTimeEnd,
|
||||
infusion_time_start=infusionTimeStart,
|
||||
@@ -2448,6 +2452,7 @@ class StockCheckCreateBody(BaseModel):
|
||||
|
||||
class StockCheckUnusedLineItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
usage_id: Optional[str] = Field(None, alias="usageId")
|
||||
batch_id: Optional[str] = Field(None, alias="batchId")
|
||||
material_code: Optional[str] = Field(None, alias="materialCode")
|
||||
|
||||
@@ -6,21 +6,20 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import (
|
||||
assert_strict_menu_code,
|
||||
require_admin_or_super_admin,
|
||||
require_strict_menu_code,
|
||||
)
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import research_donation as rd_svc
|
||||
from app.models.models import SysUser
|
||||
from app.models.research_donation import BizResearchDonation
|
||||
from app.services.sample_collect_wx_notify import (
|
||||
PROTOCOL_KIND_DONATION,
|
||||
sample_codes_from_orm_rows,
|
||||
sample_codes_from_payload,
|
||||
schedule_sample_collect_notify,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["科研捐赠管理"])
|
||||
|
||||
PROTOCOL_LIST_MENU = "biz:cellStorageProtocol:list"
|
||||
PROTOCOL_AUDIT_MENU = "biz:cellStorageProtocolAudit:list"
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
@@ -133,24 +132,8 @@ def assert_donation_sensitive_fields(
|
||||
*,
|
||||
creating: bool,
|
||||
) -> None:
|
||||
"""防止通过普通保存接口注入状态或协议互转来源。"""
|
||||
submitted = set(body.model_fields_set)
|
||||
if creating:
|
||||
status_value = str(getattr(body, "donation_status", None) or "dsx").strip()
|
||||
source_value = str(getattr(body, "source_type", None) or "direct").strip()
|
||||
requires_audit = (
|
||||
status_value != "dsx"
|
||||
or source_value != "direct"
|
||||
or bool(getattr(body, "source_protocol_id", None))
|
||||
)
|
||||
else:
|
||||
requires_audit = bool(
|
||||
submitted.intersection(
|
||||
{"donation_status", "source_type", "source_protocol_id"}
|
||||
)
|
||||
)
|
||||
if requires_audit:
|
||||
assert_strict_menu_code(db, user, PROTOCOL_AUDIT_MENU)
|
||||
"""保留钩子;不再校验菜单权限(与采集协议敏感字段策略对齐)。"""
|
||||
_ = (db, user, body, creating)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -260,7 +243,7 @@ async def get_research_donation_detail(
|
||||
@router.post("/research-donations")
|
||||
async def create_research_donation(
|
||||
body: DonationCreateBody,
|
||||
user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
assert_donation_sensitive_fields(db, user, body, creating=True)
|
||||
@@ -277,6 +260,17 @@ async def create_research_donation(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=obj.id,
|
||||
protocol_kind=PROTOCOL_KIND_DONATION,
|
||||
before_codes=[],
|
||||
submitted_codes=(
|
||||
sample_codes_from_payload(body.samples)
|
||||
if body.samples is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return success_response(
|
||||
rd_svc._donation_to_dict(obj, db, tenant_no),
|
||||
message="创建成功",
|
||||
@@ -294,7 +288,7 @@ async def create_research_donation(
|
||||
async def update_research_donation(
|
||||
donation_id: str,
|
||||
body: DonationUpdateBody,
|
||||
user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
assert_donation_sensitive_fields(db, user, body, creating=False)
|
||||
@@ -302,6 +296,10 @@ async def update_research_donation(
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
data = body.model_dump(exclude_unset=True, by_alias=True)
|
||||
before_codes = sample_codes_from_orm_rows(
|
||||
rd_svc.list_samples(db, donation_id, tenant_no)
|
||||
)
|
||||
unset_fields = body.model_dump(exclude_unset=True)
|
||||
obj = rd_svc.update_donation(
|
||||
db,
|
||||
donation_id,
|
||||
@@ -309,6 +307,17 @@ async def update_research_donation(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=obj.id,
|
||||
protocol_kind=PROTOCOL_KIND_DONATION,
|
||||
before_codes=before_codes,
|
||||
submitted_codes=(
|
||||
sample_codes_from_payload(body.samples)
|
||||
if "samples" in unset_fields
|
||||
else None
|
||||
),
|
||||
)
|
||||
return success_response(
|
||||
rd_svc._donation_to_dict(obj, db, tenant_no),
|
||||
message="更新成功",
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_any_menu_code, require_strict_menu_code
|
||||
from app.core.menu_guard import require_admin_or_super_admin, require_any_menu_code
|
||||
from app.core.response import error_response, success_response
|
||||
from app.core.utils import parse_datetime
|
||||
from app.crud import sample_insurance as insurance_crud
|
||||
@@ -16,7 +16,7 @@ from app.models.models import BizProtocolSample, SysUser
|
||||
router = APIRouter(prefix="/web/admin", tags=["样本储存证保险"])
|
||||
|
||||
_MENU = require_any_menu_code("cell-storage:sample-info:list")
|
||||
_WRITE_MENU = require_strict_menu_code("cell-storage:storage-cert:list")
|
||||
_WRITE_MENU = require_admin_or_super_admin
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
|
||||
@@ -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,23 @@ class Settings(BaseSettings):
|
||||
WX_TEMPLATE_DELIVERY_WARNING: str = "" # 配送时效预警模板ID
|
||||
|
||||
# 微信服务号(OA 审批/抄送通知)
|
||||
# 总开关:false 时不发送审批/抄送/申请人进度模板消息(模板未就绪或微信能力不可用时关闭)
|
||||
# 总开关:false 时不发送审批/抄送/申请人进度/评论@提到/基础实验交付提醒/样本采集通知模板消息
|
||||
WX_MP_NOTIFY_ENABLED: bool = False
|
||||
WX_MP_APPID: str = ""
|
||||
WX_MP_SECRET: str = ""
|
||||
WX_MP_TEMPLATE_APPROVAL: str = ""
|
||||
WX_MP_TEMPLATE_CC: str = ""
|
||||
WX_MP_TEMPLATE_APPLICANT_PROGRESS: str = ""
|
||||
# 基础实验交付日前一天提醒(出库提醒模板)
|
||||
WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY: str = (
|
||||
"oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM"
|
||||
)
|
||||
# 协议新增样本采集通知(工单类模板)
|
||||
# 工单标题 thing2 / 发起人 thing8 / 发起时间 time3 / 任务类型 phrase10 / 工单类型 thing17(样本类型名称)
|
||||
WX_MP_TEMPLATE_SAMPLE_COLLECT: str = (
|
||||
"iOnQ9k2mavAOwFbp_6yH4t1SHqpEkaiwNWKueY0kpwE"
|
||||
)
|
||||
WX_MP_SAMPLE_COLLECT_ROLE_NAME: str = "样本采集通知人员"
|
||||
# 审批模板:申请事项/操作人(申请人)/当前环节/操作时间/申请类型(流程名)
|
||||
# 生产字段:thing12 / thing18 / thing7 / time17 / thing8
|
||||
WX_MP_TPL_FIELD_TITLE: str = "thing12"
|
||||
@@ -158,6 +168,18 @@ class Settings(BaseSettings):
|
||||
WX_MP_TPL_PROGRESS_FIELD_APPROVER: str = "thing6"
|
||||
WX_MP_TPL_PROGRESS_FIELD_RESULT: str = "const4"
|
||||
WX_MP_TPL_PROGRESS_FIELD_TIME: str = "time11"
|
||||
# 基础实验交付提醒:客户名称/产品名称/件数/收货联系人/出库时间
|
||||
WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER: str = "thing6"
|
||||
WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT: str = "thing10"
|
||||
WX_MP_TPL_BE_DELIVERY_FIELD_QTY: str = "character_string8"
|
||||
WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT: str = "thing7"
|
||||
WX_MP_TPL_BE_DELIVERY_FIELD_TIME: str = "time14"
|
||||
# 样本采集通知:工单标题/发起人/发起时间/任务类型/工单类型(样本类型名称)
|
||||
WX_MP_TPL_SC_FIELD_TITLE: str = "thing2"
|
||||
WX_MP_TPL_SC_FIELD_APPLICANT: str = "thing8"
|
||||
WX_MP_TPL_SC_FIELD_TIME: str = "time3"
|
||||
WX_MP_TPL_SC_FIELD_TYPE: str = "phrase10"
|
||||
WX_MP_TPL_SC_FIELD_SAMPLE_TYPE: str = "thing17"
|
||||
|
||||
# 与 docs/sql/zkxw.sql 基线一致:登录按租户 + sys_user.user_no
|
||||
DEFAULT_TENANT_NO: str = "zkxw"
|
||||
|
||||
@@ -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}%"))
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""按行同步子表。
|
||||
|
||||
带 ID 的行原地修改,没有 ID 时按业务键对上尚未占用的原行,其余才新增。
|
||||
本次提交里没有的活动行才软删除。不要整表软删后再插入,否则外键会指向已删除的行。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
|
||||
|
||||
def _item_id(item: Dict[str, Any]) -> str:
|
||||
return str(item.get("id") or "").strip()
|
||||
|
||||
|
||||
def _sort_key(row: Any):
|
||||
order_num = getattr(row, "order_num", None)
|
||||
create_time = getattr(row, "create_time", None)
|
||||
return (
|
||||
order_num if order_num is not None else 0,
|
||||
create_time or datetime.min,
|
||||
str(getattr(row, "id", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
def sync_child_rows(
|
||||
db: Session,
|
||||
model,
|
||||
*,
|
||||
parent_attr: str,
|
||||
parent_id: str,
|
||||
items: List[Dict[str, Any]],
|
||||
build_row: Callable[[str], Any],
|
||||
apply_fields: Callable[[Any, Dict[str, Any], int, datetime], None],
|
||||
tenant_no: Optional[str] = None,
|
||||
tenant_attr: str = "tenant_no",
|
||||
user_id: Optional[str] = None,
|
||||
now: Optional[datetime] = None,
|
||||
natural_key: Optional[Callable[[Dict[str, Any]], str]] = None,
|
||||
row_natural_key: Optional[Callable[[Any], str]] = None,
|
||||
duplicate_message: str = "编号重复",
|
||||
foreign_message: str = "记录不属于当前数据",
|
||||
extra_filters: Optional[Dict[str, Any]] = None,
|
||||
reuse_deleted: bool = False,
|
||||
) -> List[Any]:
|
||||
now = now or datetime.now()
|
||||
payload = list(items or [])
|
||||
seen_ids = set()
|
||||
for item in payload:
|
||||
row_id = _item_id(item)
|
||||
if not row_id:
|
||||
continue
|
||||
if row_id in seen_ids:
|
||||
raise ValueError(duplicate_message)
|
||||
seen_ids.add(row_id)
|
||||
|
||||
def _scoped(query):
|
||||
query = query.filter(getattr(model, parent_attr) == parent_id)
|
||||
if tenant_no is not None and hasattr(model, tenant_attr):
|
||||
query = query.filter(getattr(model, tenant_attr) == tenant_no)
|
||||
for key, value in (extra_filters or {}).items():
|
||||
query = query.filter(getattr(model, key) == value)
|
||||
return query
|
||||
|
||||
active_rows = _scoped(db.query(model).filter(model.del_flag == 0)).all()
|
||||
active_by_id = {row.id: row for row in active_rows}
|
||||
|
||||
for row_id in seen_ids:
|
||||
if row_id in active_by_id:
|
||||
continue
|
||||
other = db.query(model).filter(model.id == row_id).first()
|
||||
if other is None:
|
||||
continue
|
||||
other_parent = getattr(other, parent_attr)
|
||||
other_tenant = getattr(other, tenant_attr, None) if tenant_no is not None else tenant_no
|
||||
if other_parent != parent_id or (
|
||||
tenant_no is not None and (other_tenant or "") != tenant_no
|
||||
):
|
||||
raise ValueError(foreign_message)
|
||||
|
||||
queues: Dict[str, List[Any]] = {}
|
||||
deleted_queues: Dict[str, List[Any]] = {}
|
||||
if natural_key is not None and row_natural_key is not None:
|
||||
for row in sorted(active_rows, key=_sort_key):
|
||||
key = (row_natural_key(row) or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
queues.setdefault(key, []).append(row)
|
||||
if reuse_deleted:
|
||||
deleted_rows = _scoped(db.query(model).filter(model.del_flag == 1)).all()
|
||||
for row in sorted(deleted_rows, key=_sort_key):
|
||||
key = (row_natural_key(row) or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
deleted_queues.setdefault(key, []).append(row)
|
||||
|
||||
claimed = set()
|
||||
assigned: List[Optional[Any]] = [None] * len(payload)
|
||||
|
||||
def _take(row: Any) -> Any:
|
||||
if row.id in claimed:
|
||||
raise ValueError(duplicate_message)
|
||||
claimed.add(row.id)
|
||||
return row
|
||||
|
||||
for idx, item in enumerate(payload):
|
||||
row_id = _item_id(item)
|
||||
if not row_id:
|
||||
continue
|
||||
if row_id in active_by_id:
|
||||
assigned[idx] = _take(active_by_id[row_id])
|
||||
continue
|
||||
row = db.query(model).filter(model.id == row_id).first()
|
||||
if row is None:
|
||||
row = build_row(row_id)
|
||||
db.add(row)
|
||||
assigned[idx] = _take(row)
|
||||
|
||||
for bucket in queues.values():
|
||||
bucket[:] = [row for row in bucket if row.id not in claimed]
|
||||
|
||||
for idx, item in enumerate(payload):
|
||||
if assigned[idx] is not None:
|
||||
continue
|
||||
row = None
|
||||
if natural_key is not None:
|
||||
key = (natural_key(item) or "").strip()
|
||||
bucket = queues.get(key) if key else None
|
||||
while bucket:
|
||||
candidate = bucket.pop(0)
|
||||
if candidate.id not in claimed:
|
||||
row = candidate
|
||||
break
|
||||
if row is None and reuse_deleted and natural_key is not None:
|
||||
key = (natural_key(item) or "").strip()
|
||||
bucket = deleted_queues.get(key) if key else None
|
||||
while bucket:
|
||||
candidate = bucket.pop(0)
|
||||
if candidate.id not in claimed:
|
||||
row = candidate
|
||||
break
|
||||
if row is None:
|
||||
row = build_row(generate_uuid())
|
||||
db.add(row)
|
||||
assigned[idx] = _take(row)
|
||||
|
||||
result: List[Any] = []
|
||||
for idx, item in enumerate(payload):
|
||||
row = assigned[idx]
|
||||
setattr(row, parent_attr, parent_id)
|
||||
if tenant_no is not None and hasattr(row, tenant_attr):
|
||||
setattr(row, tenant_attr, tenant_no)
|
||||
apply_fields(row, item, idx, now)
|
||||
row.del_flag = 0
|
||||
if hasattr(row, "update_by"):
|
||||
row.update_by = user_id
|
||||
if hasattr(row, "update_time"):
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
result.append(row)
|
||||
|
||||
if reuse_deleted and row_natural_key is not None:
|
||||
keys_to_clear = set()
|
||||
for row in result:
|
||||
keys_to_clear.add((row_natural_key(row) or "").strip())
|
||||
for row in active_rows:
|
||||
if row.id not in claimed:
|
||||
keys_to_clear.add((row_natural_key(row) or "").strip())
|
||||
removed_tombstone = False
|
||||
for key in keys_to_clear:
|
||||
if not key:
|
||||
continue
|
||||
for tomb in deleted_queues.get(key, []):
|
||||
if tomb.id in claimed:
|
||||
continue
|
||||
db.delete(tomb)
|
||||
removed_tombstone = True
|
||||
if removed_tombstone:
|
||||
db.flush()
|
||||
|
||||
for row in active_rows:
|
||||
if row.id in claimed:
|
||||
continue
|
||||
row.del_flag = 1
|
||||
if hasattr(row, "update_by"):
|
||||
row.update_by = user_id
|
||||
if hasattr(row, "update_time"):
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
return result
|
||||
|
||||
|
||||
def sync_link_rows(
|
||||
db: Session,
|
||||
model,
|
||||
*,
|
||||
parent_attr: str,
|
||||
parent_id: str,
|
||||
link_attr: str,
|
||||
link_ids: List[str],
|
||||
tenant_no: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
duplicate_message: str = "关联重复",
|
||||
foreign_message: str = "关联不属于当前数据",
|
||||
) -> List[Any]:
|
||||
"""按关联 id 更新关系行。重新加回已删除的关联时恢复原行,避免唯一索引冲突。"""
|
||||
now = datetime.now()
|
||||
ordered: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
for raw in link_ids or []:
|
||||
link_id = str(raw or "").strip()
|
||||
if not link_id or link_id in seen:
|
||||
continue
|
||||
seen.add(link_id)
|
||||
ordered.append({link_attr: link_id})
|
||||
|
||||
def build_row(row_id: str):
|
||||
data = {
|
||||
"id": row_id,
|
||||
parent_attr: parent_id,
|
||||
link_attr: "",
|
||||
"create_by": user_id,
|
||||
"create_dept_id": "",
|
||||
"create_time": now,
|
||||
}
|
||||
if tenant_no is not None and hasattr(model, "tenant_no"):
|
||||
data["tenant_no"] = tenant_no
|
||||
return model(**data)
|
||||
|
||||
def apply_row(row: Any, item: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
setattr(row, link_attr, item[link_attr])
|
||||
if tenant_no is not None and hasattr(row, "tenant_no"):
|
||||
row.tenant_no = tenant_no
|
||||
if hasattr(row, "order_num"):
|
||||
row.order_num = idx
|
||||
|
||||
return sync_child_rows(
|
||||
db,
|
||||
model,
|
||||
parent_attr=parent_attr,
|
||||
parent_id=parent_id,
|
||||
tenant_no=tenant_no,
|
||||
items=ordered,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: item[link_attr],
|
||||
row_natural_key=lambda row: str(getattr(row, link_attr) or "").strip(),
|
||||
duplicate_message=duplicate_message,
|
||||
foreign_message=foreign_message,
|
||||
reuse_deleted=True,
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.base import camel_to_snake
|
||||
from app.crud.biz_no_generator import allocate_next_biz_no
|
||||
from app.crud.protocol_donation_convert import (
|
||||
@@ -45,8 +46,11 @@ from app.models.models import (
|
||||
BizProtocolPayment,
|
||||
BizProtocolPaymentDetail,
|
||||
BizProtocolSample,
|
||||
BizProtocolSamplePayment,
|
||||
BizProtocolSampleTransport,
|
||||
BizProtocolStorageCert,
|
||||
BizProductionWorkOrder,
|
||||
BizQcSmsNotification,
|
||||
BizQcWorkOrder,
|
||||
BizSampleReception,
|
||||
BizStorageReleaseReview,
|
||||
@@ -169,6 +173,35 @@ def validate_sample_codes_not_used(
|
||||
raise ValueError(f"样本编码「{first}」已被占用")
|
||||
|
||||
|
||||
def validate_persisted_sample_codes_immutable(
|
||||
existing_samples: list,
|
||||
samples: list,
|
||||
) -> None:
|
||||
"""已落库样本(payload 带本单现有 id)的编码不可在协议编辑中修改。"""
|
||||
by_id = {
|
||||
str(getattr(s, "id", "") or "").strip(): s
|
||||
for s in (existing_samples or [])
|
||||
if str(getattr(s, "id", "") or "").strip()
|
||||
}
|
||||
if not by_id:
|
||||
return
|
||||
for raw_item in samples or []:
|
||||
raw = _normalize_fields(raw_item if isinstance(raw_item, dict) else {})
|
||||
sid = (raw.get("id") or "").strip()
|
||||
if not sid or sid not in by_id:
|
||||
continue
|
||||
old = by_id[sid]
|
||||
old_code = (getattr(old, "sample_code", None) or "").strip()
|
||||
old_code_id = (getattr(old, "sample_code_id", None) or "").strip()
|
||||
new_code = (raw.get("sample_code") or "").strip()
|
||||
new_code_id = (raw.get("sample_code_id") or "").strip()
|
||||
if new_code != old_code or new_code_id != old_code_id:
|
||||
label = old_code or sid
|
||||
raise ValueError(
|
||||
f"已落库样本「{label}」的编码不可在协议编辑中修改,请使用客户服务「更正编码」"
|
||||
)
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
@@ -2551,7 +2584,7 @@ def create_collection_protocol(
|
||||
protocol_attachments=(protocol_attachments or "").strip() or None,
|
||||
consent_form_urls=(consent_form_urls or "").strip() or None,
|
||||
health_survey_form_urls=(health_survey_form_urls or "").strip() or None,
|
||||
register_date=(register_date or "").strip() or None,
|
||||
register_date=_normalize_yyyymmdd_date(register_date),
|
||||
collector_user_id=(collector_user_id or "").strip() or None,
|
||||
protocol_total_amount=total_amt,
|
||||
source_presign_order_id=(source_presign_order_id or "").strip() or None,
|
||||
@@ -2655,7 +2688,7 @@ def update_collection_protocol(
|
||||
if health_survey_form_urls is not None:
|
||||
obj.health_survey_form_urls = health_survey_form_urls.strip() or None
|
||||
if register_date is not None:
|
||||
obj.register_date = register_date.strip() or None
|
||||
obj.register_date = _normalize_yyyymmdd_date(register_date)
|
||||
if effective_time is not None:
|
||||
obj.effective_time = effective_time
|
||||
if effective_desc is not None:
|
||||
@@ -2944,6 +2977,39 @@ def list_samples(db: Session, collection_protocol_id: str, tenant_no: str) -> Li
|
||||
)
|
||||
|
||||
|
||||
def _retarget_protocol_sample_id(
|
||||
db: Session,
|
||||
model,
|
||||
*,
|
||||
old_ids: List[str],
|
||||
old_id_to_code: Dict[str, str],
|
||||
new_code_to_id: Dict[str, str],
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
now: datetime,
|
||||
del_flag_alive=0,
|
||||
) -> None:
|
||||
rows = (
|
||||
db.query(model)
|
||||
.filter(
|
||||
model.protocol_sample_id.in_(old_ids),
|
||||
model.tenant_no == tenant_no,
|
||||
model.del_flag == del_flag_alive,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
old_sid = (row.protocol_sample_id or "").strip()
|
||||
code = old_id_to_code.get(old_sid) or (getattr(row, "sample_code", None) or "").strip()
|
||||
new_sid = new_code_to_id.get(code) if code else None
|
||||
if not new_sid or new_sid == old_sid:
|
||||
continue
|
||||
row.protocol_sample_id = new_sid
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
|
||||
|
||||
def _migrate_sample_child_refs_on_replace(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -2952,31 +3018,92 @@ def _migrate_sample_child_refs_on_replace(
|
||||
new_code_to_id: Dict[str, str],
|
||||
user_id: Optional[str],
|
||||
) -> None:
|
||||
"""协议样本行重建后,将接收/质检工单的 protocol_sample_id 迁移到新样本 ID。"""
|
||||
"""样本行 ID 变化时,把仍指向旧 ID 的下游改到同一编码的新样本。"""
|
||||
if not old_id_to_code or not new_code_to_id:
|
||||
return
|
||||
old_ids = list(old_id_to_code.keys())
|
||||
now = datetime.now()
|
||||
for model in (BizSampleReception, BizQcWorkOrder):
|
||||
rows = (
|
||||
db.query(model)
|
||||
.filter(
|
||||
model.protocol_sample_id.in_(old_ids),
|
||||
model.tenant_no == tenant_no,
|
||||
model.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
for model in (
|
||||
BizSampleReception,
|
||||
BizQcWorkOrder,
|
||||
BizProductionWorkOrder,
|
||||
BizProtocolSamplePayment,
|
||||
):
|
||||
_retarget_protocol_sample_id(
|
||||
db,
|
||||
model,
|
||||
old_ids=old_ids,
|
||||
old_id_to_code=old_id_to_code,
|
||||
new_code_to_id=new_code_to_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
)
|
||||
for row in rows:
|
||||
old_sid = (row.protocol_sample_id or "").strip()
|
||||
code = old_id_to_code.get(old_sid) or (row.sample_code or "").strip()
|
||||
new_sid = new_code_to_id.get(code) if code else None
|
||||
if not new_sid:
|
||||
continue
|
||||
row.protocol_sample_id = new_sid
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
_retarget_protocol_sample_id(
|
||||
db,
|
||||
BizQcSmsNotification,
|
||||
old_ids=old_ids,
|
||||
old_id_to_code=old_id_to_code,
|
||||
new_code_to_id=new_code_to_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
del_flag_alive="0",
|
||||
)
|
||||
|
||||
|
||||
def _owned_child_row(
|
||||
db: Session,
|
||||
model,
|
||||
row_id: str,
|
||||
*,
|
||||
parent_attr: str,
|
||||
parent_id: str,
|
||||
tenant_no: str,
|
||||
message: str,
|
||||
):
|
||||
row = db.query(model).filter(model.id == row_id).first()
|
||||
if row is None:
|
||||
return None
|
||||
if getattr(row, parent_attr) != parent_id or (row.tenant_no or "") != tenant_no:
|
||||
raise ValueError(message)
|
||||
return row
|
||||
|
||||
|
||||
def _apply_protocol_sample_fields(
|
||||
obj: BizProtocolSample,
|
||||
raw: dict,
|
||||
*,
|
||||
order_num: int,
|
||||
user_id: Optional[str],
|
||||
now: datetime,
|
||||
) -> None:
|
||||
str_fields = [
|
||||
"sample_type_id", "sample_code_id", "sample_code", "unit_id",
|
||||
"sample_status", "storage_in_date", "detect_start_date",
|
||||
"void_date", "storage_expire_date",
|
||||
]
|
||||
obj.order_num = order_num
|
||||
obj.remark = (raw.get("remark") or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = now
|
||||
obj.del_flag = 0
|
||||
for key in str_fields:
|
||||
if key == "sample_status":
|
||||
continue
|
||||
if key in raw:
|
||||
setattr(obj, key, (raw[key] or "").strip() or None)
|
||||
if "sample_status" in raw:
|
||||
status_val = raw.get("sample_status")
|
||||
obj.sample_status = (
|
||||
str(status_val).strip() if status_val is not None and str(status_val).strip() else None
|
||||
)
|
||||
for key in ("collect_amount", "price", "original_price"):
|
||||
if key in raw and raw[key] is not None:
|
||||
setattr(obj, key, Decimal(str(raw[key])))
|
||||
if "storage_years" in raw:
|
||||
obj.storage_years = raw["storage_years"]
|
||||
apply_sample_special_fields(obj, raw)
|
||||
|
||||
|
||||
def save_samples(
|
||||
@@ -2989,77 +3116,103 @@ def save_samples(
|
||||
commit: bool = True,
|
||||
) -> List[BizProtocolSample]:
|
||||
existing = list_samples(db, collection_protocol_id, tenant_no)
|
||||
old_id_to_code = {
|
||||
s.id: (s.sample_code or "").strip()
|
||||
for s in existing
|
||||
if (s.sample_code or "").strip()
|
||||
validate_persisted_sample_codes_immutable(existing, samples)
|
||||
by_id = {s.id: s for s in existing}
|
||||
by_code: Dict[str, List[BizProtocolSample]] = {}
|
||||
for sample in existing:
|
||||
code = (sample.sample_code or "").strip()
|
||||
if code:
|
||||
by_code.setdefault(code, []).append(sample)
|
||||
claimed = set()
|
||||
planned: List[tuple] = []
|
||||
for idx, raw_item in enumerate(samples or []):
|
||||
raw = _normalize_fields(raw_item)
|
||||
validate_sample_special_raw(raw, index=idx)
|
||||
sid = (raw.get("id") or "").strip()
|
||||
code = (raw.get("sample_code") or "").strip()
|
||||
row = None
|
||||
if sid and sid in by_id:
|
||||
row = by_id[sid]
|
||||
elif sid:
|
||||
row = _owned_child_row(
|
||||
db,
|
||||
BizProtocolSample,
|
||||
sid,
|
||||
parent_attr="collection_protocol_id",
|
||||
parent_id=collection_protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
message="样本不属于当前协议",
|
||||
)
|
||||
if row is None and code:
|
||||
for candidate in by_code.get(code, []):
|
||||
if candidate.id not in claimed:
|
||||
row = candidate
|
||||
break
|
||||
if row is not None:
|
||||
if row.id in claimed:
|
||||
raise ValueError("样本编号重复")
|
||||
claimed.add(row.id)
|
||||
planned.append((idx, raw, row))
|
||||
kept_codes = {
|
||||
(sample.sample_code or "").strip()
|
||||
for sample in existing
|
||||
if sample.id in claimed and (sample.sample_code or "").strip()
|
||||
}
|
||||
for s in existing:
|
||||
s.del_flag = 1
|
||||
s.update_by = user_id
|
||||
s.update_time = datetime.now()
|
||||
db.add(s)
|
||||
if existing:
|
||||
db.flush()
|
||||
validate_sample_codes_not_used(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
samples=samples,
|
||||
samples=[
|
||||
raw
|
||||
for _idx, raw, row in planned
|
||||
if row is None or (raw.get("sample_code") or "").strip() not in kept_codes
|
||||
],
|
||||
exclude_protocol_id=collection_protocol_id,
|
||||
)
|
||||
_STR_FIELDS = [
|
||||
"sample_type_id", "sample_code_id", "sample_code", "unit_id",
|
||||
"sample_status", "storage_in_date", "detect_start_date",
|
||||
"void_date", "storage_expire_date",
|
||||
]
|
||||
_DEC_FIELDS = ["collect_amount", "price", "original_price"]
|
||||
_INT_FIELDS = ["storage_years"]
|
||||
result = []
|
||||
for idx, raw in enumerate(samples):
|
||||
raw = _normalize_fields(raw)
|
||||
validate_sample_special_raw(raw, index=idx)
|
||||
obj = BizProtocolSample(
|
||||
id=generate_uuid(),
|
||||
collection_protocol_id=collection_protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
remark=(raw.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
for k in _STR_FIELDS:
|
||||
if k == "sample_status":
|
||||
continue
|
||||
if k in raw:
|
||||
setattr(obj, k, (raw[k] or "").strip() or None)
|
||||
# 样本状态:显式支持 null/空字符串,避免 ORM/库默认值 ycj 回填
|
||||
if "sample_status" in raw:
|
||||
status_val = raw.get("sample_status")
|
||||
obj.sample_status = (
|
||||
str(status_val).strip() if status_val is not None and str(status_val).strip() else None
|
||||
now = datetime.now()
|
||||
result: List[BizProtocolSample] = []
|
||||
for idx, raw, row in planned:
|
||||
is_new = row is None
|
||||
if is_new:
|
||||
row = BizProtocolSample(
|
||||
id=(raw.get("id") or "").strip() or generate_uuid(),
|
||||
collection_protocol_id=collection_protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
)
|
||||
for k in _DEC_FIELDS:
|
||||
if k in raw and raw[k] is not None:
|
||||
setattr(obj, k, Decimal(str(raw[k])))
|
||||
for k in _INT_FIELDS:
|
||||
if k in raw:
|
||||
setattr(obj, k, raw[k])
|
||||
apply_sample_special_fields(obj, raw)
|
||||
db.add(obj)
|
||||
result.append(obj)
|
||||
_apply_protocol_sample_fields(
|
||||
row,
|
||||
raw,
|
||||
order_num=idx,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
)
|
||||
row.collection_protocol_id = collection_protocol_id
|
||||
row.tenant_no = tenant_no
|
||||
db.add(row)
|
||||
result.append(row)
|
||||
for sample in existing:
|
||||
if sample.id in claimed:
|
||||
continue
|
||||
sample.del_flag = 1
|
||||
sample.update_by = user_id
|
||||
sample.update_time = now
|
||||
db.add(sample)
|
||||
removed_id_to_code = {
|
||||
sample.id: (sample.sample_code or "").strip()
|
||||
for sample in existing
|
||||
if sample.id not in {row.id for row in result} and (sample.sample_code or "").strip()
|
||||
}
|
||||
new_code_to_id = {
|
||||
(o.sample_code or "").strip(): o.id
|
||||
for o in result
|
||||
if (o.sample_code or "").strip()
|
||||
(row.sample_code or "").strip(): row.id
|
||||
for row in result
|
||||
if (row.sample_code or "").strip()
|
||||
}
|
||||
_migrate_sample_child_refs_on_replace(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
old_id_to_code=old_id_to_code,
|
||||
old_id_to_code=removed_id_to_code,
|
||||
new_code_to_id=new_code_to_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
@@ -3142,7 +3295,8 @@ def save_sample_transports(
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
"""写回样本运输明细。运费批次改造后仍保留预计到达等字段(可仅有 eta_time)。"""
|
||||
_soft_delete_transports_for_protocol(db, collection_protocol_id, tenant_no, user_id)
|
||||
now = datetime.now()
|
||||
payload = []
|
||||
for idx, sample_obj in enumerate(saved_samples):
|
||||
if idx >= len(input_samples):
|
||||
break
|
||||
@@ -3151,7 +3305,6 @@ def save_sample_transports(
|
||||
if not isinstance(transport_raw, dict):
|
||||
transport_raw = {}
|
||||
tnorm = _normalize_fields(transport_raw)
|
||||
# 移动端可能把 etaTime 放在样本顶层
|
||||
if not tnorm.get("eta_time") and raw.get("eta_time"):
|
||||
tnorm["eta_time"] = raw.get("eta_time")
|
||||
|
||||
@@ -3161,37 +3314,71 @@ def save_sample_transports(
|
||||
eta_time = parse_datetime(eta_time)
|
||||
if not transport_type and not eta_time:
|
||||
continue
|
||||
|
||||
express_fee = tnorm.get("express_fee")
|
||||
if express_fee is not None and express_fee != "":
|
||||
express_fee = Decimal(str(express_fee))
|
||||
else:
|
||||
express_fee = None
|
||||
payload.append(
|
||||
{
|
||||
"id": (tnorm.get("id") or "").strip(),
|
||||
"sample_id": sample_obj.id,
|
||||
"sample_code": sample_obj.sample_code or None,
|
||||
"transport_type": transport_type,
|
||||
"express_company_id": (tnorm.get("express_company_id") or "").strip() or None,
|
||||
"waybill_no": (tnorm.get("waybill_no") or "").strip() or None,
|
||||
"dest_site_id": (tnorm.get("dest_site_id") or "").strip() or None,
|
||||
"express_fee": express_fee,
|
||||
"eta_time": eta_time,
|
||||
"carrier_name": (tnorm.get("carrier_name") or "").strip() or None,
|
||||
"carrier_phone": (tnorm.get("carrier_phone") or "").strip() or None,
|
||||
"remark": (tnorm.get("remark") or "").strip() or None,
|
||||
"order_num": idx,
|
||||
}
|
||||
)
|
||||
|
||||
obj = BizProtocolSampleTransport(
|
||||
id=generate_uuid(),
|
||||
def build_row(row_id):
|
||||
return BizProtocolSampleTransport(
|
||||
id=row_id,
|
||||
collection_protocol_id=collection_protocol_id,
|
||||
sample_id=sample_obj.id,
|
||||
sample_code=sample_obj.sample_code or None,
|
||||
transport_type=transport_type,
|
||||
express_company_id=(tnorm.get("express_company_id") or "").strip() or None,
|
||||
waybill_no=(tnorm.get("waybill_no") or "").strip() or None,
|
||||
dest_site_id=(tnorm.get("dest_site_id") or "").strip() or None,
|
||||
express_fee=express_fee,
|
||||
eta_time=eta_time,
|
||||
carrier_name=(tnorm.get("carrier_name") or "").strip() or None,
|
||||
carrier_phone=(tnorm.get("carrier_phone") or "").strip() or None,
|
||||
sample_id="",
|
||||
transport_type="",
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
remark=(tnorm.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
create_time=now,
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
def apply_row(row, item, _idx, _now):
|
||||
row.sample_id = item["sample_id"]
|
||||
row.sample_code = item["sample_code"]
|
||||
row.transport_type = item["transport_type"]
|
||||
row.express_company_id = item["express_company_id"]
|
||||
row.waybill_no = item["waybill_no"]
|
||||
row.dest_site_id = item["dest_site_id"]
|
||||
row.express_fee = item["express_fee"]
|
||||
row.eta_time = item["eta_time"]
|
||||
row.carrier_name = item["carrier_name"]
|
||||
row.carrier_phone = item["carrier_phone"]
|
||||
row.remark = item["remark"]
|
||||
row.order_num = item["order_num"]
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProtocolSampleTransport,
|
||||
parent_attr="collection_protocol_id",
|
||||
parent_id=collection_protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
items=payload,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: item["sample_id"],
|
||||
row_natural_key=lambda row: row.sample_id or "",
|
||||
duplicate_message="样本运输重复",
|
||||
foreign_message="运输记录不属于当前协议",
|
||||
)
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
@@ -4036,6 +4223,7 @@ def upsert_storage_cert(
|
||||
collection_protocol_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
commit: bool = True,
|
||||
**fields,
|
||||
) -> BizProtocolStorageCert:
|
||||
fields = _normalize_fields(fields)
|
||||
@@ -4052,8 +4240,11 @@ def upsert_storage_cert(
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
else:
|
||||
db.flush()
|
||||
return obj
|
||||
obj = BizProtocolStorageCert(
|
||||
id=generate_uuid(),
|
||||
@@ -4075,8 +4266,11 @@ def upsert_storage_cert(
|
||||
if "handle_time" in fields:
|
||||
obj.handle_time = fields.get("handle_time")
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
else:
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ def _resolve_issued_user_display_name(
|
||||
uid = (user_id or "").strip()
|
||||
if not uid:
|
||||
return None
|
||||
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
|
||||
u = db.query(SysUser).filter(SysUser.id == uid).first()
|
||||
if not u:
|
||||
return None
|
||||
return (u.nick_name or u.user_no or "").strip() or None
|
||||
@@ -41,7 +41,7 @@ def enrich_issued_user_names(db: Session, rows: List[Dict[str, Any]]) -> None:
|
||||
return
|
||||
users = (
|
||||
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
|
||||
.filter(SysUser.id.in_(missing_uids), SysUser.del_flag == 0)
|
||||
.filter(SysUser.id.in_(missing_uids))
|
||||
.all()
|
||||
)
|
||||
name_map = {(u.id or ""): ((u.nick_name or u.user_no or "").strip()) for u in users}
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.models.models import BizConsumablePack, BizConsumablePackItem
|
||||
|
||||
|
||||
@@ -230,37 +231,45 @@ def update_consumable_pack(
|
||||
db.add(obj)
|
||||
|
||||
if items is not None:
|
||||
existing = (
|
||||
db.query(BizConsumablePackItem)
|
||||
.filter(and_(BizConsumablePackItem.pack_id == pack_id, BizConsumablePackItem.del_flag == 0))
|
||||
.all()
|
||||
)
|
||||
now = datetime.now()
|
||||
for ei in existing:
|
||||
ei.del_flag = 1
|
||||
ei.update_time = now
|
||||
db.add(ei)
|
||||
|
||||
for it in items:
|
||||
item_obj = BizConsumablePackItem(
|
||||
id=generate_uuid(),
|
||||
def build_item(row_id):
|
||||
return BizConsumablePackItem(
|
||||
id=row_id,
|
||||
pack_id=pack_id,
|
||||
consumable_code=(it.get("consumable_code") or "").strip(),
|
||||
consumable_name=(it.get("consumable_name") or "").strip(),
|
||||
spec_model=(it.get("spec_model") or "").strip() or None,
|
||||
standard_usage=(it.get("standard_usage") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
consumable_code="",
|
||||
consumable_name="",
|
||||
tenant_no=obj.tenant_no,
|
||||
order_num=0,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(item_obj)
|
||||
|
||||
def apply_item(row, it, _idx, _now):
|
||||
row.consumable_code = (it.get("consumable_code") or "").strip()
|
||||
row.consumable_name = (it.get("consumable_name") or "").strip()
|
||||
row.spec_model = (it.get("spec_model") or "").strip() or None
|
||||
row.standard_usage = (it.get("standard_usage") or "").strip() or None
|
||||
row.unit = (it.get("unit") or "").strip() or None
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = 0
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizConsumablePackItem,
|
||||
parent_attr="pack_id",
|
||||
parent_id=pack_id,
|
||||
tenant_no=obj.tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_item,
|
||||
apply_fields=apply_item,
|
||||
natural_key=lambda it: (it.get("consumable_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.consumable_code or "").strip(),
|
||||
duplicate_message="耗材包明细重复",
|
||||
foreign_message="耗材包明细不属于当前耗材包",
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
|
||||
from app.models.models import SysUser
|
||||
from app.models.material import (
|
||||
@@ -442,6 +443,44 @@ def _resolve_unused_item_fields(
|
||||
return out
|
||||
|
||||
|
||||
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
|
||||
usage = (usage_id or "").strip()
|
||||
if usage:
|
||||
return f"usage:{usage}"
|
||||
batch = (batch_id or "").strip()
|
||||
if batch:
|
||||
return f"batch:{batch}"
|
||||
return ""
|
||||
|
||||
|
||||
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
|
||||
it = _resolve_unused_item_fields(db, it, tenant_no)
|
||||
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
|
||||
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
|
||||
if qty is None or Decimal(str(qty)) <= 0:
|
||||
raise ValueError("未用完数量必须大于0")
|
||||
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
|
||||
return {
|
||||
"id": str(it.get("id") or "").strip(),
|
||||
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
|
||||
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
|
||||
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
|
||||
"material_name": name,
|
||||
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
|
||||
"unit": (it.get("unit") or "").strip() or None,
|
||||
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
|
||||
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
|
||||
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
|
||||
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
|
||||
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
|
||||
"manufacturer": (it.get("manufacturer") or "").strip() or None,
|
||||
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
|
||||
"holder": (it.get("holder") or "").strip() or None,
|
||||
"unused_qty": Decimal(str(qty)),
|
||||
"remark": (it.get("remark") or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def save_unused_lines(
|
||||
db: Session,
|
||||
check_id: str,
|
||||
@@ -458,72 +497,61 @@ def save_unused_lines(
|
||||
raise ValueError("仅进行中的盘点单可登记未用完物料")
|
||||
|
||||
now = datetime.now()
|
||||
old_rows = (
|
||||
db.query(BizMaterialStockCheckUnusedLine)
|
||||
.filter(
|
||||
BizMaterialStockCheckUnusedLine.check_id == check_id,
|
||||
BizMaterialStockCheckUnusedLine.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for r in old_rows:
|
||||
r.del_flag = 1
|
||||
r.update_by = user_id
|
||||
r.update_time = now
|
||||
db.add(r)
|
||||
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
|
||||
|
||||
count = 0
|
||||
for it in items or []:
|
||||
it = _resolve_unused_item_fields(db, it, tenant_no)
|
||||
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
|
||||
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
|
||||
if qty is None or Decimal(str(qty)) <= 0:
|
||||
raise ValueError("未用完数量必须大于0")
|
||||
qty_dec = Decimal(str(qty))
|
||||
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
|
||||
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
|
||||
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
|
||||
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
|
||||
|
||||
row = BizMaterialStockCheckUnusedLine(
|
||||
id=generate_uuid(),
|
||||
def build_row(row_id: str):
|
||||
return BizMaterialStockCheckUnusedLine(
|
||||
id=row_id,
|
||||
check_id=check_id,
|
||||
usage_id=usage_id,
|
||||
batch_id=batch_id,
|
||||
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
|
||||
material_name=name,
|
||||
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
|
||||
stock_in_date=stock_in_date,
|
||||
expire_date=expire_date,
|
||||
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
|
||||
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
|
||||
manufacturer=(it.get("manufacturer") or "").strip() or None,
|
||||
unit_price=(
|
||||
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
|
||||
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
|
||||
else None
|
||||
),
|
||||
holder=(it.get("holder") or "").strip() or None,
|
||||
unused_qty=qty_dec,
|
||||
material_name="",
|
||||
unused_qty=0,
|
||||
tenant_no=tenant_no,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
count += 1
|
||||
|
||||
def apply_row(row, it, idx, _now):
|
||||
row.usage_id = it["usage_id"]
|
||||
row.batch_id = it["batch_id"]
|
||||
row.material_code = it["material_code"]
|
||||
row.material_name = it["material_name"]
|
||||
row.spec_model = it["spec_model"]
|
||||
row.unit = it["unit"]
|
||||
row.stock_in_no = it["stock_in_no"]
|
||||
row.stock_in_date = it["stock_in_date"]
|
||||
row.expire_date = it["expire_date"]
|
||||
row.catalog_no = it["catalog_no"]
|
||||
row.batch_no = it["batch_no"]
|
||||
row.manufacturer = it["manufacturer"]
|
||||
row.unit_price = it["unit_price"]
|
||||
row.holder = it["holder"]
|
||||
row.unused_qty = it["unused_qty"]
|
||||
row.remark = it["remark"]
|
||||
row.order_num = idx
|
||||
|
||||
saved = sync_child_rows(
|
||||
db,
|
||||
BizMaterialStockCheckUnusedLine,
|
||||
parent_attr="check_id",
|
||||
parent_id=check_id,
|
||||
tenant_no=tenant_no,
|
||||
items=prepared,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
|
||||
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
|
||||
duplicate_message="未用完明细重复",
|
||||
foreign_message="未用完明细不属于当前盘点单",
|
||||
)
|
||||
|
||||
check.update_by = user_id
|
||||
check.update_time = now
|
||||
db.add(check)
|
||||
db.commit()
|
||||
return count
|
||||
return len(saved)
|
||||
|
||||
|
||||
def complete_check(
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud import cell_product as cell_product_svc
|
||||
from app.models.models import (
|
||||
BizCellProduct,
|
||||
@@ -396,13 +397,6 @@ def save_outbound_material(
|
||||
db, schedule_id, tenant_no, incoming_qc_ids, user_id, now
|
||||
)
|
||||
|
||||
db.query(BizOutboundReleasePaper).filter(
|
||||
BizOutboundReleasePaper.infusion_schedule_id == schedule_id,
|
||||
BizOutboundReleasePaper.tenant_no == tenant_no,
|
||||
BizOutboundReleasePaper.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
|
||||
db.flush()
|
||||
|
||||
for idx, item in enumerate(cells or []):
|
||||
cp_id = (item.get("cellProductId") or item.get("cell_product_id") or "").strip()
|
||||
if not cp_id:
|
||||
@@ -532,7 +526,8 @@ def save_outbound_material(
|
||||
)
|
||||
)
|
||||
|
||||
for idx, item in enumerate(paper_materials or []):
|
||||
paper_payload = []
|
||||
for item in paper_materials or []:
|
||||
paper_name = (item.get("paperName") or item.get("paper_name") or "").strip()
|
||||
attachment_url = (item.get("attachmentUrl") or item.get("attachment_url") or "").strip()
|
||||
copies_raw = item.get("copies")
|
||||
@@ -546,26 +541,53 @@ def save_outbound_material(
|
||||
raise HTTPException(status_code=400, detail="纸质材料份数格式无效")
|
||||
if copies <= 0:
|
||||
raise HTTPException(status_code=400, detail="纸质材料份数必须大于0")
|
||||
|
||||
db.add(
|
||||
BizOutboundReleasePaper(
|
||||
id=generate_uuid(),
|
||||
material_id=header.id,
|
||||
infusion_schedule_id=schedule_id,
|
||||
paper_name=paper_name,
|
||||
copies=copies,
|
||||
attachment_url=attachment_url,
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx + 1,
|
||||
remark=(item.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
paper_payload.append(
|
||||
{
|
||||
"paper_name": paper_name,
|
||||
"copies": copies,
|
||||
"attachment_url": attachment_url,
|
||||
"remark": (item.get("remark") or "").strip() or None,
|
||||
}
|
||||
)
|
||||
|
||||
def build_paper(row_id):
|
||||
return BizOutboundReleasePaper(
|
||||
id=row_id,
|
||||
material_id=header.id,
|
||||
infusion_schedule_id=schedule_id,
|
||||
paper_name="",
|
||||
copies=1,
|
||||
attachment_url="",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_paper(row, item, idx, _now):
|
||||
row.material_id = header.id
|
||||
row.paper_name = item["paper_name"]
|
||||
row.copies = item["copies"]
|
||||
row.attachment_url = item["attachment_url"]
|
||||
row.order_num = idx + 1
|
||||
row.remark = item["remark"]
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizOutboundReleasePaper,
|
||||
parent_attr="infusion_schedule_id",
|
||||
parent_id=schedule_id,
|
||||
tenant_no=tenant_no,
|
||||
items=paper_payload,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_paper,
|
||||
apply_fields=apply_paper,
|
||||
natural_key=lambda item: item["attachment_url"],
|
||||
row_natural_key=lambda row: row.attachment_url or "",
|
||||
duplicate_message="纸质材料重复",
|
||||
foreign_message="纸质材料不属于当前输注计划",
|
||||
)
|
||||
|
||||
db.flush()
|
||||
return get_outbound_material(db, schedule_id, tenant_no)
|
||||
@@ -524,6 +524,8 @@ def list_reviews_paginated(
|
||||
"projectName": product.project_name or "",
|
||||
"cellTypeName": product.cell_type_name or "",
|
||||
"specText": product.spec_text or "",
|
||||
"packageId": getattr(product, "package_id", None) or "",
|
||||
"packageText": getattr(product, "package_text", None) or "",
|
||||
"agreementPrice": _dec(product.agreement_price),
|
||||
"actualPrice": _dec(product.actual_price),
|
||||
"quantity": product.quantity,
|
||||
@@ -604,6 +606,8 @@ def list_reviews_paginated(
|
||||
"patientName": (patient.patient_name or "") if patient else "",
|
||||
"cellType": s.cell_type or (plan.cell_type or "") if plan else (s.cell_type or ""),
|
||||
"specText": s.spec_text or (plan.spec_text or "") if plan else (s.spec_text or ""),
|
||||
"packageId": getattr(s, "package_id", None) or "",
|
||||
"packageText": getattr(s, "package_text", None) or "",
|
||||
"infusionTime": _fmt_dt(s.infusion_time),
|
||||
"scheduleStatus": s.schedule_status or "",
|
||||
"scheduleStatusLabel": _SCHEDULE_STATUS_LABELS.get(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,9 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
|
||||
from app.models.models import (
|
||||
BizCheckupInstitution,
|
||||
BizPilotApplication,
|
||||
@@ -429,6 +432,52 @@ def list_reserve_items(
|
||||
return [_row_to_dict_reserve_item(r) for r in rows]
|
||||
|
||||
|
||||
def _checkup_project_name(item: Dict[str, Any]) -> str:
|
||||
return (item.get("projectName") or item.get("project_name") or "").strip()
|
||||
|
||||
|
||||
def _checkup_project_fee(item: Dict[str, Any]):
|
||||
if item.get("projectFee") is not None:
|
||||
return item.get("projectFee")
|
||||
return item.get("project_fee")
|
||||
|
||||
|
||||
def _sync_checkup_items(db, model, checkup_id, items_json, tenant_no, user_id, now):
|
||||
def build_row(row_id):
|
||||
return model(
|
||||
id=row_id,
|
||||
pilot_checkup_id=checkup_id,
|
||||
project_name="",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row, item, idx, _now):
|
||||
row.project_name = _checkup_project_name(item)
|
||||
row.project_fee = _checkup_project_fee(item)
|
||||
row.order_num = idx + 1
|
||||
row.remark = item.get("remark") or ""
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
model,
|
||||
parent_attr="pilot_checkup_id",
|
||||
parent_id=checkup_id,
|
||||
tenant_no=tenant_no,
|
||||
items=list(items_json or []),
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=_checkup_project_name,
|
||||
row_natural_key=lambda row: (row.project_name or "").strip(),
|
||||
duplicate_message="体检项目重复",
|
||||
foreign_message="体检项目不属于当前体检单",
|
||||
)
|
||||
|
||||
|
||||
def save_reserve_items(
|
||||
db: Session,
|
||||
checkup_id: str,
|
||||
@@ -437,35 +486,15 @@ def save_reserve_items(
|
||||
user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
now = datetime.now()
|
||||
|
||||
# Soft-delete existing items
|
||||
db.query(BizPilotCheckupReserveItem).filter(
|
||||
BizPilotCheckupReserveItem.pilot_checkup_id == checkup_id,
|
||||
BizPilotCheckupReserveItem.tenant_no == tenant_no,
|
||||
BizPilotCheckupReserveItem.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
|
||||
# Insert new items
|
||||
for idx, item in enumerate(items_json):
|
||||
row = BizPilotCheckupReserveItem(
|
||||
id=uuid.uuid4().hex,
|
||||
pilot_checkup_id=checkup_id,
|
||||
project_name=item.get("projectName") or item.get("project_name") or "",
|
||||
project_fee=item.get("projectFee") if item.get("projectFee") is not None else (
|
||||
item.get("project_fee") if item.get("project_fee") is not None else None
|
||||
),
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx + 1,
|
||||
remark=item.get("remark") or "",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
_sync_checkup_items(
|
||||
db,
|
||||
BizPilotCheckupReserveItem,
|
||||
checkup_id,
|
||||
items_json,
|
||||
tenant_no,
|
||||
user_id,
|
||||
now,
|
||||
)
|
||||
db.commit()
|
||||
return list_reserve_items(db, checkup_id, tenant_no)
|
||||
|
||||
@@ -520,35 +549,15 @@ def save_actual_items(
|
||||
user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
now = datetime.now()
|
||||
|
||||
# Soft-delete existing items
|
||||
db.query(BizPilotCheckupActualItem).filter(
|
||||
BizPilotCheckupActualItem.pilot_checkup_id == checkup_id,
|
||||
BizPilotCheckupActualItem.tenant_no == tenant_no,
|
||||
BizPilotCheckupActualItem.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
|
||||
# Insert new items
|
||||
for idx, item in enumerate(items_json):
|
||||
row = BizPilotCheckupActualItem(
|
||||
id=uuid.uuid4().hex,
|
||||
pilot_checkup_id=checkup_id,
|
||||
project_name=item.get("projectName") or item.get("project_name") or "",
|
||||
project_fee=item.get("projectFee") if item.get("projectFee") is not None else (
|
||||
item.get("project_fee") if item.get("project_fee") is not None else None
|
||||
),
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx + 1,
|
||||
remark=item.get("remark") or "",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
_sync_checkup_items(
|
||||
db,
|
||||
BizPilotCheckupActualItem,
|
||||
checkup_id,
|
||||
items_json,
|
||||
tenant_no,
|
||||
user_id,
|
||||
now,
|
||||
)
|
||||
db.commit()
|
||||
return list_actual_items(db, checkup_id, tenant_no)
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
|
||||
from app.models.models import (
|
||||
BizChannelAccount,
|
||||
BizChannelAgreementProductPrice,
|
||||
@@ -539,7 +541,7 @@ def save_delivery_materials(
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Bulk save: soft-delete existing + insert new."""
|
||||
"""按物料名称更新已有行,只插入新物料,只软删去掉的物料。"""
|
||||
now = datetime.now()
|
||||
|
||||
# Validate delivery exists
|
||||
@@ -555,36 +557,40 @@ def save_delivery_materials(
|
||||
if not delivery:
|
||||
raise ValueError("交付信息不存在")
|
||||
|
||||
# Soft-delete existing materials
|
||||
db.query(BizPilotDeliveryMaterial).filter(
|
||||
BizPilotDeliveryMaterial.delivery_id == delivery_id,
|
||||
BizPilotDeliveryMaterial.tenant_no == tenant_no,
|
||||
BizPilotDeliveryMaterial.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
db.flush()
|
||||
|
||||
# Insert new
|
||||
created = []
|
||||
for idx, item in enumerate(items_json):
|
||||
material = BizPilotDeliveryMaterial(
|
||||
id=str(uuid.uuid4()),
|
||||
def build_row(row_id):
|
||||
return BizPilotDeliveryMaterial(
|
||||
id=row_id,
|
||||
delivery_id=delivery_id,
|
||||
material_name=item.get("material_name") or "",
|
||||
qty=item.get("qty"),
|
||||
unit_id=item.get("unit_id") or "",
|
||||
material_name="",
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx + 1,
|
||||
remark=item.get("remark") or "",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(material)
|
||||
created.append(material)
|
||||
|
||||
def apply_row(row, item, idx, _now):
|
||||
row.material_name = item.get("material_name") or ""
|
||||
row.qty = item.get("qty")
|
||||
row.unit_id = item.get("unit_id") or ""
|
||||
row.order_num = idx + 1
|
||||
row.remark = item.get("remark") or ""
|
||||
|
||||
created = sync_child_rows(
|
||||
db,
|
||||
BizPilotDeliveryMaterial,
|
||||
parent_attr="delivery_id",
|
||||
parent_id=delivery_id,
|
||||
tenant_no=tenant_no,
|
||||
items=list(items_json or []),
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("material_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.material_name or "").strip(),
|
||||
duplicate_message="交付物料重复",
|
||||
foreign_message="交付物料不属于当前交付单",
|
||||
)
|
||||
db.commit()
|
||||
for m in created:
|
||||
db.refresh(m)
|
||||
|
||||
@@ -129,7 +129,7 @@ def get_confirm_by_schedule(
|
||||
if uid:
|
||||
user = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == uid, SysUser.del_flag == 0)
|
||||
.filter(SysUser.id == uid)
|
||||
.first()
|
||||
)
|
||||
data["confirmUserName"] = (user.nick_name or user.user_no or "") if user else ""
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -10,6 +9,7 @@ from sqlalchemy import and_, desc, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.models.models import (
|
||||
BizChannelAgreementProductPrice,
|
||||
BizChannelServiceOrder,
|
||||
@@ -69,6 +69,8 @@ def _row_to_dict_schedule(
|
||||
"totalCourseCount": course.get("totalCourseCount"),
|
||||
"cellType": row.cell_type or "",
|
||||
"specText": row.spec_text or "",
|
||||
"packageId": getattr(row, "package_id", None) or "",
|
||||
"packageText": getattr(row, "package_text", None) or "",
|
||||
"infusionTime": row.infusion_time.strftime("%Y-%m-%d %H:%M:%S") if row.infusion_time else "",
|
||||
"infusionLocation": row.infusion_location or "",
|
||||
"infusionMethod": row.infusion_method or "",
|
||||
@@ -142,6 +144,8 @@ def _row_to_dict_product(row) -> Dict[str, Any]:
|
||||
"projectName": row.project_name or "",
|
||||
"cellTypeName": row.cell_type_name or "",
|
||||
"specText": row.spec_text or "",
|
||||
"packageId": getattr(row, "package_id", None) or "",
|
||||
"packageText": getattr(row, "package_text", None) or "",
|
||||
"agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None,
|
||||
"actualPrice": float(row.actual_price) if row.actual_price is not None else None,
|
||||
"quantity": row.quantity if row.quantity is not None else 1,
|
||||
@@ -516,6 +520,8 @@ def get_infusion_schedule_detail(
|
||||
sched["totalCourseCount"] = flat.get("totalCourseCount")
|
||||
sched["cellType"] = flat.get("cellType") or sched.get("cellType") or ""
|
||||
sched["specText"] = flat.get("specText") or sched.get("specText") or ""
|
||||
sched["packageId"] = flat.get("packageId") or sched.get("packageId") or ""
|
||||
sched["packageText"] = flat.get("packageText") or sched.get("packageText") or ""
|
||||
sched["infusionTime"] = flat.get("infusionTime") or sched.get("infusionTime") or ""
|
||||
sched["infusionLocation"] = flat.get("infusionLocation") or sched.get("infusionLocation") or ""
|
||||
sched["infusionMethod"] = flat.get("infusionMethod") or sched.get("infusionMethod") or ""
|
||||
@@ -1114,6 +1120,8 @@ def update_infusion_schedule_mgmt(
|
||||
fields = {
|
||||
"cellType": "cell_type",
|
||||
"specText": "spec_text",
|
||||
"packageId": "package_id",
|
||||
"packageText": "package_text",
|
||||
"infusionLocation": "infusion_location",
|
||||
"infusionMethod": "infusion_method",
|
||||
}
|
||||
@@ -1134,44 +1142,68 @@ def update_infusion_schedule_mgmt(
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
|
||||
# Update products if provided
|
||||
products_data = data.get("products")
|
||||
if products_data is not None:
|
||||
# Soft-delete existing products
|
||||
db.query(BizPilotInfusionScheduleProduct).filter(
|
||||
BizPilotInfusionScheduleProduct.infusion_schedule_id == schedule_id,
|
||||
BizPilotInfusionScheduleProduct.tenant_no == tenant_no,
|
||||
BizPilotInfusionScheduleProduct.del_flag == 0,
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
def _price_id(item):
|
||||
return (item.get("priceId") or item.get("price_id") or "").strip()
|
||||
|
||||
for idx, item in enumerate(products_data):
|
||||
p_row = BizPilotInfusionScheduleProduct(
|
||||
id=str(uuid.uuid4()),
|
||||
def _product_key(item):
|
||||
price_id = _price_id(item)
|
||||
if price_id:
|
||||
return "price:" + price_id
|
||||
project_code = (item.get("projectCode") or item.get("project_code") or "").strip()
|
||||
spec_text = (item.get("specText") or item.get("spec_text") or "").strip()
|
||||
return f"proj:{project_code}|{spec_text}"
|
||||
|
||||
def build_product(row_id):
|
||||
return BizPilotInfusionScheduleProduct(
|
||||
id=row_id,
|
||||
infusion_schedule_id=schedule_id,
|
||||
price_id=item.get("priceId") or item.get("price_id") or "",
|
||||
project_code=item.get("projectCode") or item.get("project_code") or "",
|
||||
project_name=item.get("projectName") or item.get("project_name") or "",
|
||||
cell_type_name=item.get("cellTypeName") or item.get("cell_type_name") or "",
|
||||
spec_text=item.get("specText") or item.get("spec_text") or "",
|
||||
agreement_price=item.get("agreementPrice") if item.get("agreementPrice") is not None else (
|
||||
item.get("agreement_price") if item.get("agreement_price") is not None else None
|
||||
),
|
||||
actual_price=item.get("actualPrice") if item.get("actualPrice") is not None else (
|
||||
item.get("actual_price") if item.get("actual_price") is not None else None
|
||||
),
|
||||
quantity=item.get("quantity") if item.get("quantity") is not None else 1,
|
||||
product_status=status,
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx + 1,
|
||||
remark=item.get("remark") or "",
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(p_row)
|
||||
|
||||
def apply_product(row, item, idx, _now):
|
||||
row.price_id = _price_id(item)
|
||||
row.project_code = item.get("projectCode") or item.get("project_code") or ""
|
||||
row.project_name = item.get("projectName") or item.get("project_name") or ""
|
||||
row.cell_type_name = item.get("cellTypeName") or item.get("cell_type_name") or ""
|
||||
row.spec_text = item.get("specText") or item.get("spec_text") or ""
|
||||
row.package_id = item.get("packageId") or item.get("package_id") or ""
|
||||
row.package_text = item.get("packageText") or item.get("package_text") or ""
|
||||
row.agreement_price = item.get("agreementPrice") if item.get("agreementPrice") is not None else (
|
||||
item.get("agreement_price") if item.get("agreement_price") is not None else None
|
||||
)
|
||||
row.actual_price = item.get("actualPrice") if item.get("actualPrice") is not None else (
|
||||
item.get("actual_price") if item.get("actual_price") is not None else None
|
||||
)
|
||||
row.quantity = item.get("quantity") if item.get("quantity") is not None else 1
|
||||
row.product_status = status
|
||||
row.order_num = idx + 1
|
||||
row.remark = item.get("remark") or ""
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizPilotInfusionScheduleProduct,
|
||||
parent_attr="infusion_schedule_id",
|
||||
parent_id=schedule_id,
|
||||
tenant_no=tenant_no,
|
||||
items=list(products_data or []),
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_product,
|
||||
apply_fields=apply_product,
|
||||
natural_key=_product_key,
|
||||
row_natural_key=lambda row: (
|
||||
"price:" + (row.price_id or "").strip()
|
||||
if (row.price_id or "").strip()
|
||||
else f"proj:{(row.project_code or '').strip()}|{(row.spec_text or '').strip()}"
|
||||
),
|
||||
duplicate_message="输注产品重复",
|
||||
foreign_message="输注产品不属于当前计划",
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
|
||||
@@ -10,10 +10,12 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
BizCellProductType,
|
||||
BizCellProductSpec,
|
||||
BizCellProductPackage,
|
||||
BizCellProductPrice,
|
||||
BizCellProductProjectSystem,
|
||||
BizCellProductProjectSystemItem,
|
||||
@@ -281,6 +283,141 @@ def soft_delete_cell_product_spec(db, spec_id, tenant_no=None):
|
||||
db.commit()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────
|
||||
# Entity 2b: BizCellProductPackage 细胞产品包装
|
||||
# ────────────────────────────────────────────
|
||||
|
||||
def _row_to_dict_cell_product_package(row) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"packageCode": row.package_code or "",
|
||||
"packageName": row.package_name or "",
|
||||
"tenantNo": row.tenant_no or "",
|
||||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||||
"remark": row.remark or "",
|
||||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
||||
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_cell_product_packages(db, *, page=1, page_size=10, keyword=None, tenant_no=None, user_info=None):
|
||||
def _filtered():
|
||||
q0 = db.query(BizCellProductPackage).filter(BizCellProductPackage.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizCellProductPackage.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizCellProductPackage.package_code.like(like),
|
||||
BizCellProductPackage.package_name.like(like),
|
||||
)
|
||||
)
|
||||
if user_info:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
q0 = apply_data_scope(q0, BizCellProductPackage, user_info, db, tenant_no)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizCellProductPackage.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizCellProductPackage.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict_cell_product_package(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_cell_product_package(db, package_id, tenant_no=None):
|
||||
q = db.query(BizCellProductPackage).filter(
|
||||
and_(BizCellProductPackage.id == package_id, BizCellProductPackage.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def cell_product_package_code_exists(db, code, tenant_no, exclude_id=None):
|
||||
q = db.query(BizCellProductPackage).filter(
|
||||
and_(BizCellProductPackage.package_code == code, BizCellProductPackage.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizCellProductPackage.tenant_no == tenant_no)
|
||||
if exclude_id:
|
||||
q = q.filter(BizCellProductPackage.id != exclude_id)
|
||||
return db.query(q.exists()).scalar()
|
||||
|
||||
|
||||
def create_cell_product_package(
|
||||
db, *, package_code, package_name, tenant_no="zkxw", order_num=None, remark=None, user_id=None
|
||||
):
|
||||
if cell_product_package_code_exists(db, package_code, tenant_no):
|
||||
raise ValueError(f"包装编码 {package_code} 已存在")
|
||||
obj = BizCellProductPackage(
|
||||
id=generate_uuid(),
|
||||
package_code=package_code,
|
||||
package_name=package_name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num or 0,
|
||||
remark=remark or "",
|
||||
create_by=user_id,
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_cell_product_package(
|
||||
db,
|
||||
package_id,
|
||||
*,
|
||||
package_code=None,
|
||||
package_name=None,
|
||||
order_num=None,
|
||||
remark=None,
|
||||
user_id=None,
|
||||
tenant_no=None,
|
||||
):
|
||||
obj = get_cell_product_package(db, package_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
if package_code is not None and package_code != obj.package_code:
|
||||
if cell_product_package_code_exists(db, package_code, tenant_no, exclude_id=package_id):
|
||||
raise ValueError(f"包装编码 {package_code} 已存在")
|
||||
obj.package_code = package_code
|
||||
if package_name is not None:
|
||||
obj.package_name = package_name
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = remark
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_cell_product_package(db, package_id, tenant_no=None):
|
||||
obj = get_cell_product_package(db, package_id, tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("记录不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────
|
||||
# Entity 3: BizCellProductPrice 细胞产品价格
|
||||
# ────────────────────────────────────────────
|
||||
@@ -2151,7 +2288,9 @@ def _insert_package_items(
|
||||
tenant_no,
|
||||
user_id=None,
|
||||
):
|
||||
for idx, it in enumerate(items):
|
||||
now = datetime.now()
|
||||
prepared = []
|
||||
for idx, it in enumerate(items or []):
|
||||
pid = it.get("checkup_institution_project_id") or it.get("checkupInstitutionProjectId")
|
||||
proj = _get_checkup_project_for_package(db, pid, institution_id, tenant_no)
|
||||
name = (it.get("project_name") or it.get("projectName") or "").strip()
|
||||
@@ -2160,22 +2299,51 @@ def _insert_package_items(
|
||||
fee = it.get("project_fee")
|
||||
if fee is None:
|
||||
fee = it.get("projectFee")
|
||||
obj = BizCheckupInstitutionPackageItem(
|
||||
id=generate_uuid(),
|
||||
package_id=package_id,
|
||||
checkup_institution_project_id=pid,
|
||||
project_name=name,
|
||||
project_fee=fee,
|
||||
tenant_no=tenant_no,
|
||||
order_num=it.get("order_num") if it.get("order_num") is not None else it.get("orderNum") or (idx + 1),
|
||||
remark=it.get("remark") or "",
|
||||
create_by=user_id,
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
prepared.append(
|
||||
{
|
||||
"project_id": pid,
|
||||
"project_name": name,
|
||||
"project_fee": fee,
|
||||
"order_num": it.get("order_num") if it.get("order_num") is not None else it.get("orderNum") or (idx + 1),
|
||||
"remark": it.get("remark") or "",
|
||||
}
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
def build_row(row_id):
|
||||
return BizCheckupInstitutionPackageItem(
|
||||
id=row_id,
|
||||
package_id=package_id,
|
||||
checkup_institution_project_id="",
|
||||
project_name="",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row, item, _idx, _now):
|
||||
row.checkup_institution_project_id = item["project_id"]
|
||||
row.project_name = item["project_name"]
|
||||
row.project_fee = item["project_fee"]
|
||||
row.order_num = item["order_num"]
|
||||
row.remark = item["remark"]
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizCheckupInstitutionPackageItem,
|
||||
parent_attr="package_id",
|
||||
parent_id=package_id,
|
||||
tenant_no=tenant_no,
|
||||
items=prepared,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: str(item.get("project_id") or "").strip(),
|
||||
row_natural_key=lambda row: (row.checkup_institution_project_id or "").strip(),
|
||||
duplicate_message="套餐项目重复",
|
||||
foreign_message="套餐项目不属于当前套餐",
|
||||
reuse_deleted=True,
|
||||
)
|
||||
|
||||
|
||||
def list_checkup_institution_packages(db, checkup_institution_id, tenant_no=None, user_info=None):
|
||||
@@ -2319,15 +2487,6 @@ def update_checkup_institution_package(
|
||||
tenant_no,
|
||||
existing_item_project_ids=existing_pids,
|
||||
)
|
||||
# 全量替换:物理删除旧明细,避免 uk(package_id, project_id) 与软删行冲突
|
||||
q_items = db.query(BizCheckupInstitutionPackageItem).filter(
|
||||
BizCheckupInstitutionPackageItem.package_id == package_id
|
||||
)
|
||||
if tenant_no:
|
||||
q_items = q_items.filter(BizCheckupInstitutionPackageItem.tenant_no == tenant_no)
|
||||
for row in q_items.all():
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
_insert_package_items(
|
||||
db,
|
||||
package_id=package_id,
|
||||
@@ -3076,50 +3235,60 @@ def _save_gmcp_product_rows(
|
||||
user_id,
|
||||
now=None,
|
||||
):
|
||||
"""仅保存购买订单产品明细,不调整份数库存"""
|
||||
"""仅保存购买订单产品明细,不调整份数库存。已有项目按编码、规格、细胞类型更新。"""
|
||||
now = now or datetime.now()
|
||||
for p in products:
|
||||
project_code = p.get("projectCode") or p.get("project_code") or ""
|
||||
project_name = p.get("projectName") or p.get("project_name") or ""
|
||||
cell_type = p.get("cellType") or p.get("cell_type") or ""
|
||||
spec_text = p.get("specText") or p.get("spec_text") or ""
|
||||
agreement_unit_price = float(p.get("agreementUnitPrice") or p.get("agreement_unit_price") or 0)
|
||||
buy_qty = float(p.get("buyQty") or p.get("buy_qty") or 0)
|
||||
buy_amount = float(p.get("buyAmount") or p.get("buy_amount") or agreement_unit_price * buy_qty)
|
||||
tenant = tenant_no or "zkxw"
|
||||
|
||||
prod_row = BizChannelServiceOrderProduct(
|
||||
id=generate_uuid(),
|
||||
def _product_key(item):
|
||||
project_code = (item.get("projectCode") or item.get("project_code") or "").strip()
|
||||
spec_text = (item.get("specText") or item.get("spec_text") or "").strip()
|
||||
cell_type = (item.get("cellType") or item.get("cell_type") or "").strip()
|
||||
return f"{project_code}|{spec_text}|{cell_type}"
|
||||
|
||||
def build_row(row_id):
|
||||
return BizChannelServiceOrderProduct(
|
||||
id=row_id,
|
||||
service_order_id=order_id,
|
||||
project_code=project_code,
|
||||
project_name=project_name,
|
||||
cell_type=cell_type,
|
||||
spec_text=spec_text,
|
||||
agreement_unit_price=agreement_unit_price,
|
||||
buy_qty=buy_qty,
|
||||
buy_amount=buy_amount,
|
||||
tenant_no=tenant_no or "zkxw",
|
||||
order_num=0,
|
||||
remark="",
|
||||
tenant_no=tenant,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(prod_row)
|
||||
|
||||
def apply_row(row, p, _idx, _now):
|
||||
agreement_unit_price = float(p.get("agreementUnitPrice") or p.get("agreement_unit_price") or 0)
|
||||
buy_qty = float(p.get("buyQty") or p.get("buy_qty") or 0)
|
||||
row.project_code = p.get("projectCode") or p.get("project_code") or ""
|
||||
row.project_name = p.get("projectName") or p.get("project_name") or ""
|
||||
row.cell_type = p.get("cellType") or p.get("cell_type") or ""
|
||||
row.spec_text = p.get("specText") or p.get("spec_text") or ""
|
||||
row.agreement_unit_price = agreement_unit_price
|
||||
row.buy_qty = buy_qty
|
||||
row.buy_amount = float(p.get("buyAmount") or p.get("buy_amount") or agreement_unit_price * buy_qty)
|
||||
row.order_num = 0
|
||||
row.remark = ""
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizChannelServiceOrderProduct,
|
||||
parent_attr="service_order_id",
|
||||
parent_id=order_id,
|
||||
tenant_no=tenant,
|
||||
items=list(products or []),
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=_product_key,
|
||||
row_natural_key=lambda row: f"{(row.project_code or '').strip()}|{(row.spec_text or '').strip()}|{(row.cell_type or '').strip()}",
|
||||
duplicate_message="订单产品重复",
|
||||
foreign_message="订单产品不属于当前订单",
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
def _replace_gmcp_product_rows(db, order_id, products, tenant_no, user_id):
|
||||
now = datetime.now()
|
||||
db.query(BizChannelServiceOrderProduct).filter(
|
||||
and_(
|
||||
BizChannelServiceOrderProduct.service_order_id == order_id,
|
||||
BizChannelServiceOrderProduct.del_flag == 0,
|
||||
)
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
_save_gmcp_product_rows(db, order_id, products, tenant_no, user_id, now=now)
|
||||
_save_gmcp_product_rows(db, order_id, products, tenant_no, user_id)
|
||||
|
||||
|
||||
def _apply_gmcp_order_quotas(db, order, tenant_no, user_id):
|
||||
|
||||
+14
-26
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_link_rows
|
||||
from app.models.models import SysPost, SysPostRole, SysRole, SysUser, SysUserPost
|
||||
|
||||
|
||||
@@ -232,7 +233,7 @@ def sync_post_role_bindings(
|
||||
role_ids: List[str],
|
||||
updater_id: Optional[str],
|
||||
) -> None:
|
||||
"""将本职位绑定的角色集合全量替换为 role_ids。"""
|
||||
"""按角色更新本职位的角色关联。"""
|
||||
ordered = list(dict.fromkeys([rid for rid in (role_ids or []) if rid]))
|
||||
for rid in ordered:
|
||||
role = (
|
||||
@@ -249,31 +250,18 @@ def sync_post_role_bindings(
|
||||
if not role:
|
||||
raise ValueError("存在无效角色或不属于当前租户")
|
||||
|
||||
now = datetime.now()
|
||||
db.query(SysPostRole).filter(
|
||||
and_(
|
||||
SysPostRole.post_id == post_id,
|
||||
SysPostRole.tenant_no == tenant_no,
|
||||
SysPostRole.del_flag == 0,
|
||||
)
|
||||
).update({"del_flag": 1, "update_time": now, "update_by": updater_id}, synchronize_session=False)
|
||||
for rid in ordered:
|
||||
db.add(
|
||||
SysPostRole(
|
||||
id=generate_uuid(),
|
||||
post_id=post_id,
|
||||
role_id=rid,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=updater_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=updater_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
sync_link_rows(
|
||||
db,
|
||||
SysPostRole,
|
||||
parent_attr="post_id",
|
||||
parent_id=post_id,
|
||||
link_attr="role_id",
|
||||
link_ids=ordered,
|
||||
tenant_no=tenant_no,
|
||||
user_id=updater_id,
|
||||
duplicate_message="角色重复",
|
||||
foreign_message="角色关联不属于当前职位",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid, parse_datetime
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.biz_no_generator import allocate_next_biz_no
|
||||
from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
@@ -337,41 +338,45 @@ def replace_sample_types(
|
||||
create_dept_id: str = "",
|
||||
) -> None:
|
||||
now = _now()
|
||||
existing = (
|
||||
db.query(BizPresignOrderSampleType)
|
||||
.filter(
|
||||
BizPresignOrderSampleType.presign_order_id == presign_order_id,
|
||||
BizPresignOrderSampleType.tenant_no == tenant_no,
|
||||
BizPresignOrderSampleType.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for r in existing:
|
||||
r.del_flag = 1
|
||||
r.update_by = user_id
|
||||
r.update_time = now
|
||||
db.add(r)
|
||||
payload: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
for idx, tid in enumerate(sample_type_ids or []):
|
||||
for tid in sample_type_ids or []:
|
||||
sid = (tid or "").strip()
|
||||
if not sid or sid in seen:
|
||||
continue
|
||||
seen.add(sid)
|
||||
db.add(
|
||||
BizPresignOrderSampleType(
|
||||
id=generate_uuid(),
|
||||
presign_order_id=presign_order_id,
|
||||
sample_type_id=sid,
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
payload.append({"sample_type_id": sid})
|
||||
|
||||
def build_row(row_id: str) -> BizPresignOrderSampleType:
|
||||
return BizPresignOrderSampleType(
|
||||
id=row_id,
|
||||
presign_order_id=presign_order_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row: BizPresignOrderSampleType, item: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
row.sample_type_id = item["sample_type_id"]
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizPresignOrderSampleType,
|
||||
parent_attr="presign_order_id",
|
||||
parent_id=presign_order_id,
|
||||
tenant_no=tenant_no,
|
||||
items=payload,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("sample_type_id") or "").strip(),
|
||||
row_natural_key=lambda row: (row.sample_type_id or "").strip(),
|
||||
duplicate_message="样本类型编号重复",
|
||||
foreign_message="样本类型不属于当前预签约订单",
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
|
||||
+132
-12
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.models.models import (
|
||||
BizProcess,
|
||||
BizProcessCategory,
|
||||
@@ -300,6 +301,132 @@ def _create_sub_data_collection(db: Session, process_id: str, tenant_no: str, us
|
||||
db.add(obj)
|
||||
|
||||
|
||||
def _new_process_child(model, row_id: str, process_id: str, tenant_no: str, user_id: Optional[str], now: datetime):
|
||||
return model(
|
||||
id=row_id,
|
||||
process_id=process_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
|
||||
def _sync_process_reagents(db, process_id, tenant_no, user_id, now, items):
|
||||
def apply_row(row, data, idx, _now):
|
||||
item = _sub_item_with_order(data, idx)
|
||||
row.reagent_code = (item.get("reagent_code") or "").strip()
|
||||
row.reagent_name = (item.get("reagent_name") or "").strip()
|
||||
row.spec = (item.get("spec") or "").strip() or None
|
||||
row.standard_usage = (item.get("standard_usage") or "").strip() or None
|
||||
row.unit = (item.get("unit") or "").strip() or None
|
||||
row.is_key_material = item.get("is_key_material", 0)
|
||||
row.remark = (item.get("remark") or "").strip() or None
|
||||
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessReagent,
|
||||
parent_attr="process_id",
|
||||
parent_id=process_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_process_child(BizProcessReagent, row_id, process_id, tenant_no, user_id, now),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("reagent_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.reagent_code or "").strip(),
|
||||
duplicate_message="试剂编号重复",
|
||||
foreign_message="试剂不属于当前工艺",
|
||||
)
|
||||
|
||||
|
||||
def _sync_process_consumables(db, process_id, tenant_no, user_id, now, items):
|
||||
def apply_row(row, data, idx, _now):
|
||||
item = _sub_item_with_order(data, idx)
|
||||
row.consumable_code = (item.get("consumable_code") or "").strip()
|
||||
row.consumable_name = (item.get("consumable_name") or "").strip()
|
||||
row.spec_model = (item.get("spec_model") or "").strip() or None
|
||||
row.standard_usage = (item.get("standard_usage") or "").strip() or None
|
||||
row.unit = (item.get("unit") or "").strip() or None
|
||||
row.is_required = item.get("is_required", 0)
|
||||
row.remark = (item.get("remark") or "").strip() or None
|
||||
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessConsumable,
|
||||
parent_attr="process_id",
|
||||
parent_id=process_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_process_child(BizProcessConsumable, row_id, process_id, tenant_no, user_id, now),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("consumable_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.consumable_code or "").strip(),
|
||||
duplicate_message="耗材编号重复",
|
||||
foreign_message="耗材不属于当前工艺",
|
||||
)
|
||||
|
||||
|
||||
def _sync_process_outputs(db, process_id, tenant_no, user_id, now, items):
|
||||
def apply_row(row, data, idx, _now):
|
||||
item = _sub_item_with_order(data, idx)
|
||||
row.output_type_id = (item.get("output_type_id") or "").strip() or None
|
||||
row.output_name = (item.get("output_name") or "").strip() or None
|
||||
row.output_barcode = (item.get("output_barcode") or "").strip() or None
|
||||
row.remark = (item.get("remark") or "").strip() or None
|
||||
row.order_num = item.get("order_num", 0) if item.get("order_num") is not None else 0
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessOutput,
|
||||
parent_attr="process_id",
|
||||
parent_id=process_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_process_child(BizProcessOutput, row_id, process_id, tenant_no, user_id, now),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("output_barcode") or item.get("output_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.output_barcode or row.output_name or "").strip(),
|
||||
duplicate_message="产出物编号重复",
|
||||
foreign_message="产出物不属于当前工艺",
|
||||
)
|
||||
|
||||
|
||||
def _sync_process_data_collections(db, process_id, tenant_no, user_id, now, items):
|
||||
def apply_row(row, data, idx, _now):
|
||||
row.collection_name = (data.get("collection_name") or "").strip()
|
||||
row.unit = (data.get("unit") or "").strip() or None
|
||||
order_num = data.get("order_num")
|
||||
row.order_num = idx if order_num is None else order_num
|
||||
row.remark = (data.get("remark") or "").strip() or None
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessDataCollection,
|
||||
parent_attr="process_id",
|
||||
parent_id=process_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_process_child(
|
||||
BizProcessDataCollection, row_id, process_id, tenant_no, user_id, now
|
||||
),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("collection_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.collection_name or "").strip(),
|
||||
duplicate_message="数据采集编号重复",
|
||||
foreign_message="数据采集不属于当前工艺",
|
||||
)
|
||||
|
||||
|
||||
def create_process(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -434,22 +561,15 @@ def update_process(
|
||||
sub_updated = reagents is not None or consumables is not None or outputs is not None or data_collections is not None
|
||||
if sub_updated:
|
||||
now = datetime.now()
|
||||
tenant_no = obj.tenant_no
|
||||
if reagents is not None:
|
||||
_soft_delete_sub_records(db, BizProcessReagent, process_id, now)
|
||||
for idx, r in enumerate(reagents):
|
||||
_create_sub_reagent(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(r, idx))
|
||||
_sync_process_reagents(db, process_id, tenant_no, user_id, now, reagents)
|
||||
if consumables is not None:
|
||||
_soft_delete_sub_records(db, BizProcessConsumable, process_id, now)
|
||||
for idx, c in enumerate(consumables):
|
||||
_create_sub_consumable(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(c, idx))
|
||||
_sync_process_consumables(db, process_id, tenant_no, user_id, now, consumables)
|
||||
if outputs is not None:
|
||||
_soft_delete_sub_records(db, BizProcessOutput, process_id, now)
|
||||
for idx, o in enumerate(outputs):
|
||||
_create_sub_output(db, process_id, obj.tenant_no, user_id, now, _sub_item_with_order(o, idx))
|
||||
_sync_process_outputs(db, process_id, tenant_no, user_id, now, outputs)
|
||||
if data_collections is not None:
|
||||
_soft_delete_sub_records(db, BizProcessDataCollection, process_id, now)
|
||||
for d in data_collections:
|
||||
_create_sub_data_collection(db, process_id, obj.tenant_no, user_id, now, d)
|
||||
_sync_process_data_collections(db, process_id, tenant_no, user_id, now, data_collections)
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
|
||||
from app.models.models import (
|
||||
BizProductionProductType,
|
||||
@@ -652,38 +653,147 @@ def soft_delete_route(db: Session, route_id: str) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子表完整替换更新
|
||||
# 子表按行更新:保留原行 ID,去掉的才软删除
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _new_route_step_child(model, row_id, step_id, tenant_no, user_id, now):
|
||||
return model(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
|
||||
def update_step_reagents(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
|
||||
now = datetime.now()
|
||||
_soft_delete_sub_records(db, BizProcessRouteStepReagent, step_id, now)
|
||||
for it in items:
|
||||
_create_step_reagent(db, step_id, tenant_no, user_id, now, it)
|
||||
|
||||
def apply_row(row, data, idx, _now):
|
||||
row.reagent_code = (data.get("reagent_code") or "").strip() or None
|
||||
row.reagent_name = (data.get("reagent_name") or "").strip() or None
|
||||
row.spec = (data.get("spec") or "").strip() or None
|
||||
row.standard_usage = (data.get("standard_usage") or "").strip() or None
|
||||
row.unit = (data.get("unit") or "").strip() or None
|
||||
row.is_key_material = data.get("is_key_material", "0")
|
||||
row.remark = (data.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessRouteStepReagent,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_route_step_child(
|
||||
BizProcessRouteStepReagent, row_id, step_id, tenant_no, user_id, now
|
||||
),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("reagent_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.reagent_code or "").strip(),
|
||||
duplicate_message="试剂编号重复",
|
||||
foreign_message="试剂不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_step_consumables(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
|
||||
now = datetime.now()
|
||||
_soft_delete_sub_records(db, BizProcessRouteStepConsumable, step_id, now)
|
||||
for it in items:
|
||||
_create_step_consumable(db, step_id, tenant_no, user_id, now, it)
|
||||
|
||||
def apply_row(row, data, idx, _now):
|
||||
row.consumable_code = (data.get("consumable_code") or "").strip() or None
|
||||
row.consumable_name = (data.get("consumable_name") or "").strip() or None
|
||||
row.spec_model = (data.get("spec_model") or "").strip() or None
|
||||
row.standard_usage = (data.get("standard_usage") or "").strip() or None
|
||||
row.unit = (data.get("unit") or "").strip() or None
|
||||
row.is_required = data.get("is_required", "1")
|
||||
row.remark = (data.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessRouteStepConsumable,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_route_step_child(
|
||||
BizProcessRouteStepConsumable, row_id, step_id, tenant_no, user_id, now
|
||||
),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("consumable_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.consumable_code or "").strip(),
|
||||
duplicate_message="耗材编号重复",
|
||||
foreign_message="耗材不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_step_outputs(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
|
||||
now = datetime.now()
|
||||
_soft_delete_sub_records(db, BizProcessRouteStepOutput, step_id, now)
|
||||
for it in items:
|
||||
_create_step_output(db, step_id, tenant_no, user_id, now, it)
|
||||
|
||||
def apply_row(row, data, idx, _now):
|
||||
row.output_type_id = (data.get("output_type_id") or "").strip() or None
|
||||
row.output_name = (data.get("output_name") or "").strip() or None
|
||||
row.output_barcode = (data.get("output_barcode") or "").strip() or None
|
||||
row.remark = (data.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessRouteStepOutput,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_route_step_child(
|
||||
BizProcessRouteStepOutput, row_id, step_id, tenant_no, user_id, now
|
||||
),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("output_barcode") or item.get("output_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.output_barcode or row.output_name or "").strip(),
|
||||
duplicate_message="产出物编号重复",
|
||||
foreign_message="产出物不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_step_data_collections(db: Session, step_id: str, items: List[Dict], tenant_no: str, user_id: Optional[str] = None):
|
||||
now = datetime.now()
|
||||
_soft_delete_sub_records(db, BizProcessRouteStepDataCollection, step_id, now)
|
||||
for it in items:
|
||||
_create_step_data_collection(db, step_id, tenant_no, user_id, now, it)
|
||||
|
||||
def apply_row(row, data, idx, _now):
|
||||
row.collection_name = (data.get("collection_name") or "").strip() or None
|
||||
row.unit = (data.get("unit") or "").strip() or None
|
||||
order_num = data.get("order_num")
|
||||
row.order_num = idx if order_num is None else (order_num or 0)
|
||||
row.remark = (data.get("remark") or "").strip() or None
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProcessRouteStepDataCollection,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=lambda row_id: _new_route_step_child(
|
||||
BizProcessRouteStepDataCollection, row_id, step_id, tenant_no, user_id, now
|
||||
),
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("collection_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.collection_name or "").strip(),
|
||||
duplicate_message="数据采集编号重复",
|
||||
foreign_message="数据采集不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.production_material import _dec, _fmt_dt
|
||||
from app.models.production_material import BizProductionMaterial, BizProductionMaterialFormula
|
||||
|
||||
@@ -84,7 +84,7 @@ def replace_formula(
|
||||
raise ValueError("请至少添加一条配方明细")
|
||||
|
||||
seen_components: set = set()
|
||||
new_rows: List[BizProductionMaterialFormula] = []
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
|
||||
for idx, it in enumerate(lines):
|
||||
comp_id = (
|
||||
@@ -125,45 +125,58 @@ def replace_formula(
|
||||
it.get("usageUnit") or it.get("usage_unit") or comp.unit or ""
|
||||
).strip()[:32]
|
||||
|
||||
new_rows.append(
|
||||
BizProductionMaterialFormula(
|
||||
id=generate_uuid(),
|
||||
configured_material_id=configured_material_id,
|
||||
component_material_id=comp_id,
|
||||
component_material_type=comp_type,
|
||||
component_material_code=comp.material_code,
|
||||
component_material_name=comp.material_name,
|
||||
standard_qty=standard_qty,
|
||||
usage_unit=usage_unit or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
remark=(it.get("remark") or "").strip()[:512] or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or configured.create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
normalized.append(
|
||||
{
|
||||
"id": (it.get("id") or "").strip(),
|
||||
"component_material_id": comp_id,
|
||||
"component_material_type": comp_type,
|
||||
"component_material_code": comp.material_code,
|
||||
"component_material_name": comp.material_name,
|
||||
"standard_qty": standard_qty,
|
||||
"usage_unit": usage_unit or None,
|
||||
"order_num": idx,
|
||||
"remark": (it.get("remark") or "").strip()[:512] or None,
|
||||
}
|
||||
)
|
||||
|
||||
existing = (
|
||||
db.query(BizProductionMaterialFormula)
|
||||
.filter(
|
||||
BizProductionMaterialFormula.configured_material_id == configured_material_id,
|
||||
BizProductionMaterialFormula.tenant_no == tenant_no,
|
||||
BizProductionMaterialFormula.del_flag == 0,
|
||||
dept = dept_id or configured.create_dept_id or ""
|
||||
|
||||
def build_row(row_id: str) -> BizProductionMaterialFormula:
|
||||
return BizProductionMaterialFormula(
|
||||
id=row_id,
|
||||
configured_material_id=configured_material_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept,
|
||||
create_time=now,
|
||||
)
|
||||
.all()
|
||||
|
||||
def apply_row(row: BizProductionMaterialFormula, it: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
row.component_material_id = it["component_material_id"]
|
||||
row.component_material_type = it["component_material_type"]
|
||||
row.component_material_code = it["component_material_code"]
|
||||
row.component_material_name = it["component_material_name"]
|
||||
row.standard_qty = it["standard_qty"]
|
||||
row.usage_unit = it["usage_unit"]
|
||||
row.order_num = idx
|
||||
row.remark = it["remark"]
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionMaterialFormula,
|
||||
parent_attr="configured_material_id",
|
||||
parent_id=configured_material_id,
|
||||
tenant_no=tenant_no,
|
||||
items=normalized,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: (it.get("component_material_id") or "").strip(),
|
||||
row_natural_key=lambda row: (row.component_material_id or "").strip(),
|
||||
duplicate_message="配方明细编号重复",
|
||||
foreign_message="配方明细不属于当前配置试剂",
|
||||
)
|
||||
for row in existing:
|
||||
row.del_flag = 1
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
|
||||
for row in new_rows:
|
||||
db.add(row)
|
||||
|
||||
db.commit()
|
||||
return get_formula(db, configured_material_id, tenant_no)
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid, parse_datetime
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.production_material import _dec, _fmt_dt, _is_enabled_status
|
||||
from app.crud.production_material_stock_batch import (
|
||||
_create_batch_and_usage,
|
||||
@@ -1007,44 +1008,65 @@ def update_prep(
|
||||
lines_raw = data.get("lines")
|
||||
if lines_raw is not None:
|
||||
parsed_lines = _parse_prep_lines(db, lines_raw, tenant_no=tenant_no)
|
||||
existing = (
|
||||
db.query(BizProductionMaterialPrepLine)
|
||||
.filter(
|
||||
BizProductionMaterialPrepLine.prep_id == prep_id,
|
||||
BizProductionMaterialPrepLine.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for ln in existing:
|
||||
ln.del_flag = 1
|
||||
ln.update_by = user_id
|
||||
ln.update_time = now
|
||||
db.add(ln)
|
||||
line_items = []
|
||||
for pl in parsed_lines:
|
||||
comp = pl["component"]
|
||||
db.add(
|
||||
BizProductionMaterialPrepLine(
|
||||
id=generate_uuid(),
|
||||
prep_id=prep_id,
|
||||
component_material_id=comp.id,
|
||||
component_material_type=pl["component_type"],
|
||||
component_material_code=comp.material_code,
|
||||
component_material_name=comp.material_name,
|
||||
requisition_usage_id=pl["requisition"].id,
|
||||
actual_qty=pl["actual_qty"],
|
||||
usage_unit=pl["usage_unit"] or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=pl["idx"],
|
||||
remark=pl["remark"],
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or prep.create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
line_items.append(
|
||||
{
|
||||
"id": (pl.get("id") or "").strip(),
|
||||
"component_material_id": comp.id,
|
||||
"component_material_type": pl["component_type"],
|
||||
"component_material_code": comp.material_code,
|
||||
"component_material_name": comp.material_name,
|
||||
"requisition_usage_id": pl["requisition"].id,
|
||||
"actual_qty": pl["actual_qty"],
|
||||
"usage_unit": pl["usage_unit"] or None,
|
||||
"order_num": pl["idx"],
|
||||
"remark": pl["remark"],
|
||||
}
|
||||
)
|
||||
|
||||
def build_line(row_id):
|
||||
return BizProductionMaterialPrepLine(
|
||||
id=row_id,
|
||||
prep_id=prep_id,
|
||||
component_material_id="",
|
||||
component_material_type="",
|
||||
actual_qty=0,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or prep.create_dept_id or "",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_line(row, item, _idx, _now):
|
||||
row.component_material_id = item["component_material_id"]
|
||||
row.component_material_type = item["component_material_type"]
|
||||
row.component_material_code = item["component_material_code"]
|
||||
row.component_material_name = item["component_material_name"]
|
||||
row.requisition_usage_id = item["requisition_usage_id"]
|
||||
row.actual_qty = item["actual_qty"]
|
||||
row.usage_unit = item["usage_unit"]
|
||||
row.order_num = item["order_num"]
|
||||
row.remark = item["remark"]
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionMaterialPrepLine,
|
||||
parent_attr="prep_id",
|
||||
parent_id=prep_id,
|
||||
tenant_no=tenant_no,
|
||||
items=line_items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_line,
|
||||
apply_fields=apply_line,
|
||||
natural_key=lambda item: f"{item['component_material_id']}:{item['requisition_usage_id'] or ''}",
|
||||
row_natural_key=lambda row: f"{row.component_material_id}:{row.requisition_usage_id or ''}",
|
||||
duplicate_message="配制明细重复",
|
||||
foreign_message="配制明细不属于当前配制单",
|
||||
)
|
||||
|
||||
prep.update_by = user_id
|
||||
prep.update_time = now
|
||||
db.add(prep)
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.production_material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
|
||||
from app.models.models import SysUser
|
||||
from app.models.production_material import (
|
||||
@@ -449,6 +450,47 @@ def _resolve_unused_item_fields(
|
||||
return out
|
||||
|
||||
|
||||
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
|
||||
usage = (usage_id or "").strip()
|
||||
if usage:
|
||||
return f"usage:{usage}"
|
||||
batch = (batch_id or "").strip()
|
||||
if batch:
|
||||
return f"batch:{batch}"
|
||||
return ""
|
||||
|
||||
|
||||
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
|
||||
it = _resolve_unused_item_fields(db, it, tenant_no)
|
||||
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
|
||||
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
|
||||
if qty is None or Decimal(str(qty)) <= 0:
|
||||
raise ValueError("未用完数量必须大于0")
|
||||
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
|
||||
material_type = it.get("material_type") if "material_type" in it else it.get("materialType")
|
||||
return {
|
||||
"id": str(it.get("id") or "").strip(),
|
||||
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
|
||||
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
|
||||
"material_type": (material_type or "").strip() or None if material_type is not None else None,
|
||||
"has_material_type": "material_type" in it or "materialType" in it,
|
||||
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
|
||||
"material_name": name,
|
||||
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
|
||||
"unit": (it.get("unit") or "").strip() or None,
|
||||
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
|
||||
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
|
||||
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
|
||||
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
|
||||
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
|
||||
"manufacturer": (it.get("manufacturer") or "").strip() or None,
|
||||
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
|
||||
"holder": (it.get("holder") or "").strip() or None,
|
||||
"unused_qty": Decimal(str(qty)),
|
||||
"remark": (it.get("remark") or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def save_unused_lines(
|
||||
db: Session,
|
||||
check_id: str,
|
||||
@@ -465,73 +507,63 @@ def save_unused_lines(
|
||||
raise ValueError("仅进行中的盘点单可登记未用完物料")
|
||||
|
||||
now = datetime.now()
|
||||
old_rows = (
|
||||
db.query(BizProductionMaterialStockCheckUnusedLine)
|
||||
.filter(
|
||||
BizProductionMaterialStockCheckUnusedLine.check_id == check_id,
|
||||
BizProductionMaterialStockCheckUnusedLine.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for r in old_rows:
|
||||
r.del_flag = 1
|
||||
r.update_by = user_id
|
||||
r.update_time = now
|
||||
db.add(r)
|
||||
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
|
||||
|
||||
count = 0
|
||||
for it in items or []:
|
||||
it = _resolve_unused_item_fields(db, it, tenant_no)
|
||||
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
|
||||
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
|
||||
if qty is None or Decimal(str(qty)) <= 0:
|
||||
raise ValueError("未用完数量必须大于0")
|
||||
qty_dec = Decimal(str(qty))
|
||||
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
|
||||
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
|
||||
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
|
||||
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
|
||||
|
||||
row = BizProductionMaterialStockCheckUnusedLine(
|
||||
id=generate_uuid(),
|
||||
def build_row(row_id: str):
|
||||
return BizProductionMaterialStockCheckUnusedLine(
|
||||
id=row_id,
|
||||
check_id=check_id,
|
||||
usage_id=usage_id,
|
||||
batch_id=batch_id,
|
||||
material_type=(it.get("material_type") or it.get("materialType") or "").strip() or None,
|
||||
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
|
||||
material_name=name,
|
||||
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
|
||||
stock_in_date=stock_in_date,
|
||||
expire_date=expire_date,
|
||||
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
|
||||
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
|
||||
manufacturer=(it.get("manufacturer") or "").strip() or None,
|
||||
unit_price=(
|
||||
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
|
||||
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
|
||||
else None
|
||||
),
|
||||
holder=(it.get("holder") or "").strip() or None,
|
||||
unused_qty=qty_dec,
|
||||
material_name="",
|
||||
unused_qty=0,
|
||||
tenant_no=tenant_no,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
count += 1
|
||||
|
||||
def apply_row(row, it, idx, _now):
|
||||
row.usage_id = it["usage_id"]
|
||||
row.batch_id = it["batch_id"]
|
||||
if it["has_material_type"]:
|
||||
row.material_type = it["material_type"]
|
||||
row.material_code = it["material_code"]
|
||||
row.material_name = it["material_name"]
|
||||
row.spec_model = it["spec_model"]
|
||||
row.unit = it["unit"]
|
||||
row.stock_in_no = it["stock_in_no"]
|
||||
row.stock_in_date = it["stock_in_date"]
|
||||
row.expire_date = it["expire_date"]
|
||||
row.catalog_no = it["catalog_no"]
|
||||
row.batch_no = it["batch_no"]
|
||||
row.manufacturer = it["manufacturer"]
|
||||
row.unit_price = it["unit_price"]
|
||||
row.holder = it["holder"]
|
||||
row.unused_qty = it["unused_qty"]
|
||||
row.remark = it["remark"]
|
||||
row.order_num = idx
|
||||
|
||||
saved = sync_child_rows(
|
||||
db,
|
||||
BizProductionMaterialStockCheckUnusedLine,
|
||||
parent_attr="check_id",
|
||||
parent_id=check_id,
|
||||
tenant_no=tenant_no,
|
||||
items=prepared,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
|
||||
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
|
||||
duplicate_message="未用完明细重复",
|
||||
foreign_message="未用完明细不属于当前盘点单",
|
||||
)
|
||||
|
||||
check.update_by = user_id
|
||||
check.update_time = now
|
||||
db.add(check)
|
||||
db.commit()
|
||||
return count
|
||||
return len(saved)
|
||||
|
||||
|
||||
def complete_check(
|
||||
|
||||
+337
-119
@@ -9,7 +9,9 @@ from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import format_date, generate_uuid, parse_datetime
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
|
||||
from app.crud.user_display import user_display_name_map
|
||||
from app.models.models import (
|
||||
BizCellProduct,
|
||||
BizCollectionProtocol,
|
||||
@@ -727,12 +729,7 @@ def _enrich_assignee_names(db: Session, items: List[Dict[str, Any]]) -> None:
|
||||
user_ids = list({(x.get("currentAssigneeUserId") or "").strip() for x in items if (x.get("currentAssigneeUserId") or "").strip()})
|
||||
if not user_ids:
|
||||
return
|
||||
rows = (
|
||||
db.query(SysUser.id, SysUser.nick_name)
|
||||
.filter(SysUser.id.in_(user_ids), SysUser.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
name_map = {r.id: (r.nick_name or "").strip() for r in rows}
|
||||
name_map = user_display_name_map(db, user_ids)
|
||||
for item in items:
|
||||
uid = (item.get("currentAssigneeUserId") or "").strip()
|
||||
if uid:
|
||||
@@ -2306,10 +2303,17 @@ def replace_step_reagents(
|
||||
task_no = task_row.task_no or ""
|
||||
|
||||
try:
|
||||
_soft_delete_sub(db, BizProductionTaskStepReagent, "step_id", step_id, now)
|
||||
def build_row(row_id: str) -> BizProductionTaskStepReagent:
|
||||
return BizProductionTaskStepReagent(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for idx, it in enumerate(items):
|
||||
def apply_row(row: BizProductionTaskStepReagent, it: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
@@ -2321,20 +2325,39 @@ def replace_step_reagents(
|
||||
it.get("actual_usage"),
|
||||
req_id,
|
||||
)
|
||||
db.add(BizProductionTaskStepReagent(
|
||||
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
|
||||
reagent_code=(it.get("reagent_code") or "").strip() or None,
|
||||
reagent_name=(it.get("reagent_name") or "").strip() or None,
|
||||
spec=(it.get("spec") or "").strip() or None,
|
||||
standard_usage=(it.get("standard_usage") or "").strip() or None,
|
||||
actual_usage=(it.get("actual_usage") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
requisition_usage_id=req_id or None,
|
||||
is_key_material=it.get("is_key_material", "0"),
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
row.reagent_code = (it.get("reagent_code") or "").strip() or None
|
||||
row.reagent_name = (it.get("reagent_name") or "").strip() or None
|
||||
row.spec = (it.get("spec") or "").strip() or None
|
||||
row.standard_usage = (it.get("standard_usage") or "").strip() or None
|
||||
row.actual_usage = (it.get("actual_usage") or "").strip() or None
|
||||
row.unit = (it.get("unit") or "").strip() or None
|
||||
row.requisition_usage_id = req_id or None
|
||||
row.is_key_material = it.get("is_key_material", "0")
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskStepReagent,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: (it.get("reagent_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.reagent_code or "").strip(),
|
||||
duplicate_message="试剂编号重复",
|
||||
foreign_message="试剂不属于当前步骤",
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for it in items:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
if req_id:
|
||||
usage_items.append({
|
||||
"requisitionUsageId": req_id,
|
||||
@@ -2376,10 +2399,17 @@ def replace_step_consumables(
|
||||
task_no = task_row.task_no or ""
|
||||
|
||||
try:
|
||||
_soft_delete_sub(db, BizProductionTaskStepConsumable, "step_id", step_id, now)
|
||||
def build_row(row_id: str) -> BizProductionTaskStepConsumable:
|
||||
return BizProductionTaskStepConsumable(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for idx, it in enumerate(items):
|
||||
def apply_row(row: BizProductionTaskStepConsumable, it: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
@@ -2391,20 +2421,39 @@ def replace_step_consumables(
|
||||
it.get("actual_usage"),
|
||||
req_id,
|
||||
)
|
||||
db.add(BizProductionTaskStepConsumable(
|
||||
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
|
||||
consumable_code=(it.get("consumable_code") or "").strip() or None,
|
||||
consumable_name=(it.get("consumable_name") or "").strip() or None,
|
||||
spec_model=(it.get("spec_model") or "").strip() or None,
|
||||
standard_usage=(it.get("standard_usage") or "").strip() or None,
|
||||
actual_usage=(it.get("actual_usage") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
requisition_usage_id=req_id or None,
|
||||
is_required=it.get("is_required", "1"),
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
row.consumable_code = (it.get("consumable_code") or "").strip() or None
|
||||
row.consumable_name = (it.get("consumable_name") or "").strip() or None
|
||||
row.spec_model = (it.get("spec_model") or "").strip() or None
|
||||
row.standard_usage = (it.get("standard_usage") or "").strip() or None
|
||||
row.actual_usage = (it.get("actual_usage") or "").strip() or None
|
||||
row.unit = (it.get("unit") or "").strip() or None
|
||||
row.requisition_usage_id = req_id or None
|
||||
row.is_required = it.get("is_required", "1")
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskStepConsumable,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: (it.get("consumable_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.consumable_code or "").strip(),
|
||||
duplicate_message="耗材编号重复",
|
||||
foreign_message="耗材不属于当前步骤",
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for it in items:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
if req_id:
|
||||
usage_items.append({
|
||||
"requisitionUsageId": req_id,
|
||||
@@ -2445,10 +2494,22 @@ def replace_step_configured_reagents(
|
||||
task_no = task_row.task_no or ""
|
||||
|
||||
try:
|
||||
_soft_delete_sub(db, BizProductionTaskStepConfiguredReagent, "step_id", step_id, now)
|
||||
def build_row(row_id: str) -> BizProductionTaskStepConfiguredReagent:
|
||||
return BizProductionTaskStepConfiguredReagent(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for idx, it in enumerate(items):
|
||||
def apply_row(
|
||||
row: BizProductionTaskStepConfiguredReagent,
|
||||
it: Dict[str, Any],
|
||||
idx: int,
|
||||
_now: datetime,
|
||||
) -> None:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
@@ -2460,19 +2521,38 @@ def replace_step_configured_reagents(
|
||||
it.get("actual_usage"),
|
||||
req_id,
|
||||
)
|
||||
db.add(BizProductionTaskStepConfiguredReagent(
|
||||
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
|
||||
configured_reagent_code=(it.get("configured_reagent_code") or "").strip() or None,
|
||||
configured_reagent_name=(it.get("configured_reagent_name") or "").strip() or None,
|
||||
spec=(it.get("spec") or "").strip() or None,
|
||||
standard_usage=(it.get("standard_usage") or "").strip() or None,
|
||||
actual_usage=(it.get("actual_usage") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
requisition_usage_id=req_id or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
row.configured_reagent_code = (it.get("configured_reagent_code") or "").strip() or None
|
||||
row.configured_reagent_name = (it.get("configured_reagent_name") or "").strip() or None
|
||||
row.spec = (it.get("spec") or "").strip() or None
|
||||
row.standard_usage = (it.get("standard_usage") or "").strip() or None
|
||||
row.actual_usage = (it.get("actual_usage") or "").strip() or None
|
||||
row.unit = (it.get("unit") or "").strip() or None
|
||||
row.requisition_usage_id = req_id or None
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskStepConfiguredReagent,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: (it.get("configured_reagent_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.configured_reagent_code or "").strip(),
|
||||
duplicate_message="配置试剂编号重复",
|
||||
foreign_message="配置试剂不属于当前步骤",
|
||||
)
|
||||
|
||||
usage_items: List[Dict[str, Any]] = []
|
||||
for it in items:
|
||||
req_id = (
|
||||
(it.get("requisition_usage_id") or it.get("requisitionUsageId") or "").strip()
|
||||
)
|
||||
if req_id:
|
||||
usage_items.append({
|
||||
"requisitionUsageId": req_id,
|
||||
@@ -2492,23 +2572,62 @@ def replace_step_configured_reagents(
|
||||
raise
|
||||
|
||||
|
||||
def _apply_step_output_fields(
|
||||
row: BizProductionTaskStepOutput,
|
||||
item: Dict[str, Any],
|
||||
*,
|
||||
order_num: int,
|
||||
user_id: Optional[str],
|
||||
now: datetime,
|
||||
) -> None:
|
||||
row.output_type_id = (item.get("output_type_id") or "").strip() or None
|
||||
row.output_name = (item.get("output_name") or "").strip() or None
|
||||
row.output_barcode = (item.get("output_barcode") or "").strip() or None
|
||||
row.remark = (item.get("remark") or "").strip() or None
|
||||
row.order_num = order_num
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
row.del_flag = 0
|
||||
|
||||
|
||||
def replace_step_outputs(
|
||||
db: Session, step_id: str, tenant_no: str,
|
||||
user_id: Optional[str], items: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""按产出物 ID 更新原行。带 ID 的行原地修改,没有 ID 的才新增,提交列表里没有的才软删除。
|
||||
|
||||
质检工单用 production_step_output_id 指向这里。整表软删再插入会换掉 ID,关联就断了。
|
||||
"""
|
||||
now = datetime.now()
|
||||
_soft_delete_sub(db, BizProductionTaskStepOutput, "step_id", step_id, now)
|
||||
for idx, it in enumerate(items):
|
||||
db.add(BizProductionTaskStepOutput(
|
||||
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
|
||||
output_type_id=(it.get("output_type_id") or "").strip() or None,
|
||||
output_name=(it.get("output_name") or "").strip() or None,
|
||||
output_barcode=(it.get("output_barcode") or "").strip() or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
|
||||
def build_row(row_id: str) -> BizProductionTaskStepOutput:
|
||||
return BizProductionTaskStepOutput(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
result_rows = sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskStepOutput,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=lambda row, item, idx, now_: _apply_step_output_fields(
|
||||
row, item, order_num=idx, user_id=user_id, now=now_
|
||||
),
|
||||
duplicate_message="产出物编号重复",
|
||||
foreign_message="产出物不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
return [_step_output_to_dict(row) for row in result_rows]
|
||||
|
||||
|
||||
def replace_step_data_collections(
|
||||
@@ -2516,17 +2635,45 @@ def replace_step_data_collections(
|
||||
user_id: Optional[str], items: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
_soft_delete_sub(db, BizProductionTaskStepDataCollection, "step_id", step_id, now)
|
||||
for idx, it in enumerate(items):
|
||||
db.add(BizProductionTaskStepDataCollection(
|
||||
id=generate_uuid(), step_id=step_id, tenant_no=tenant_no,
|
||||
collection_name=(it.get("collection_name") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
collection_value=(it.get("collection_value") or "").strip() or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
|
||||
def build_row(row_id: str) -> BizProductionTaskStepDataCollection:
|
||||
return BizProductionTaskStepDataCollection(
|
||||
id=row_id,
|
||||
step_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(
|
||||
row: BizProductionTaskStepDataCollection,
|
||||
it: Dict[str, Any],
|
||||
idx: int,
|
||||
_now: datetime,
|
||||
) -> None:
|
||||
row.collection_name = (it.get("collection_name") or "").strip() or None
|
||||
row.unit = (it.get("unit") or "").strip() or None
|
||||
row.collection_value = (it.get("collection_value") or "").strip() or None
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskStepDataCollection,
|
||||
parent_attr="step_id",
|
||||
parent_id=step_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: (it.get("collection_name") or "").strip(),
|
||||
row_natural_key=lambda row: (row.collection_name or "").strip(),
|
||||
duplicate_message="数据采集编号重复",
|
||||
foreign_message="数据采集不属于当前步骤",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -2604,58 +2751,129 @@ def spawn_temporary_work_order_from_task(
|
||||
}
|
||||
|
||||
|
||||
def _task_input_identity(input_type: Optional[str], input_id: Optional[str], input_code: Optional[str]) -> str:
|
||||
kind = (input_type or "").strip()
|
||||
ident = (input_id or "").strip() or (input_code or "").strip()
|
||||
if not kind or not ident:
|
||||
return ""
|
||||
return f"{kind}:{ident}"
|
||||
|
||||
|
||||
def _task_input_item_identity(it: Dict[str, Any]) -> str:
|
||||
return _task_input_identity(
|
||||
it.get("input_type") or it.get("inputType"),
|
||||
it.get("input_id") or it.get("inputId"),
|
||||
it.get("input_code") or it.get("inputCode"),
|
||||
)
|
||||
|
||||
|
||||
def _set_cell_product_status(
|
||||
db: Session,
|
||||
product_id: Optional[str],
|
||||
*,
|
||||
user_id: Optional[str],
|
||||
now: datetime,
|
||||
to_status: str,
|
||||
from_statuses: tuple,
|
||||
) -> None:
|
||||
pid = (product_id or "").strip()
|
||||
if not pid:
|
||||
return
|
||||
product = db.query(BizCellProduct).filter(
|
||||
BizCellProduct.id == pid, BizCellProduct.del_flag == 0
|
||||
).first()
|
||||
if product and product.product_status in from_statuses:
|
||||
product.product_status = to_status
|
||||
product.update_by = user_id
|
||||
product.update_time = now
|
||||
db.add(product)
|
||||
|
||||
|
||||
def set_task_inputs(
|
||||
db: Session, task_id: str, tenant_no: str,
|
||||
user_id: Optional[str], items: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
|
||||
# --- 1. 加载旧 inputs 并按 cell_product 恢复状态 ---
|
||||
old_rows = db.query(BizProductionTaskInput).filter(
|
||||
and_(
|
||||
BizProductionTaskInput.task_id == task_id,
|
||||
BizProductionTaskInput.tenant_no == tenant_no,
|
||||
BizProductionTaskInput.del_flag == 0,
|
||||
)
|
||||
).all()
|
||||
old_identity = {
|
||||
row.id: (row.input_type or "", row.input_id or "")
|
||||
for row in old_rows
|
||||
}
|
||||
|
||||
def build_row(row_id: str) -> BizProductionTaskInput:
|
||||
return BizProductionTaskInput(
|
||||
id=row_id,
|
||||
task_id=task_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row: BizProductionTaskInput, it: Dict[str, Any], idx: int, _now: datetime) -> None:
|
||||
row.input_type = (it.get("input_type") or it.get("inputType") or "").strip() or None
|
||||
row.input_id = (it.get("input_id") or it.get("inputId") or "").strip() or None
|
||||
row.input_code = (it.get("input_code") or it.get("inputCode") or "").strip() or None
|
||||
row.input_name = (it.get("input_name") or it.get("inputName") or "").strip() or None
|
||||
row.remark = (it.get("remark") or "").strip() or None
|
||||
row.order_num = idx
|
||||
|
||||
saved = sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskInput,
|
||||
parent_attr="task_id",
|
||||
parent_id=task_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=_task_input_item_identity,
|
||||
row_natural_key=lambda row: _task_input_identity(row.input_type, row.input_id, row.input_code),
|
||||
duplicate_message="原材料编号重复",
|
||||
foreign_message="原材料不属于当前任务",
|
||||
)
|
||||
saved_ids = {row.id for row in saved}
|
||||
for old_row in old_rows:
|
||||
# 如果是细胞产品,恢复状态
|
||||
if old_row.input_type == "cell_product" and old_row.input_id:
|
||||
product = db.query(BizCellProduct).filter(
|
||||
BizCellProduct.id == old_row.input_id, BizCellProduct.del_flag == 0
|
||||
).first()
|
||||
if product and product.product_status == "in_production":
|
||||
product.product_status = "frozen"
|
||||
product.update_by = user_id
|
||||
product.update_time = now
|
||||
db.add(product)
|
||||
|
||||
# --- 2. 全量软删除旧行 ---
|
||||
_soft_delete_sub(db, BizProductionTaskInput, "task_id", task_id, now)
|
||||
|
||||
# --- 3. 按传入顺序批量新建 ---
|
||||
for idx, it in enumerate(items):
|
||||
inp_type = (it.get("input_type") or it.get("inputType") or "").strip()
|
||||
inp_id = (it.get("input_id") or it.get("inputId") or "").strip()
|
||||
# 如果是细胞产品,设置状态为 in_production
|
||||
if inp_type == "cell_product" and inp_id:
|
||||
product = db.query(BizCellProduct).filter(
|
||||
BizCellProduct.id == inp_id, BizCellProduct.del_flag == 0
|
||||
).first()
|
||||
if product and product.product_status in ("frozen", "thawed"):
|
||||
product.product_status = "in_production"
|
||||
product.update_by = user_id
|
||||
product.update_time = now
|
||||
db.add(product)
|
||||
db.add(BizProductionTaskInput(
|
||||
id=generate_uuid(), task_id=task_id, tenant_no=tenant_no,
|
||||
input_type=inp_type or None,
|
||||
input_id=inp_id or None,
|
||||
input_code=(it.get("input_code") or it.get("inputCode") or "").strip() or None,
|
||||
input_name=(it.get("input_name") or it.get("inputName") or "").strip() or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
order_num=idx, create_by=user_id, create_dept_id="",
|
||||
create_time=now, update_by=user_id, update_time=now, del_flag=0,
|
||||
))
|
||||
if old_row.id in saved_ids:
|
||||
continue
|
||||
if old_row.input_type == "cell_product":
|
||||
_set_cell_product_status(
|
||||
db,
|
||||
old_row.input_id,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
to_status="frozen",
|
||||
from_statuses=("in_production",),
|
||||
)
|
||||
for row in saved:
|
||||
previous = old_identity.get(row.id)
|
||||
current = (row.input_type or "", row.input_id or "")
|
||||
if previous != current and previous and previous[0] == "cell_product":
|
||||
_set_cell_product_status(
|
||||
db,
|
||||
previous[1],
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
to_status="frozen",
|
||||
from_statuses=("in_production",),
|
||||
)
|
||||
if row.input_type == "cell_product":
|
||||
_set_cell_product_status(
|
||||
db,
|
||||
row.input_id,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
to_status="in_production",
|
||||
from_statuses=("frozen", "thawed"),
|
||||
)
|
||||
|
||||
db.flush()
|
||||
db.commit()
|
||||
|
||||
@@ -8,8 +8,10 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.models.models import (
|
||||
BizProcessRoute,
|
||||
BizProcessRouteStep,
|
||||
BizProductionTask,
|
||||
BizProductionTaskSet,
|
||||
BizProductionTaskSetItem,
|
||||
@@ -110,6 +112,39 @@ def collect_missing_route_ids(items: List[Dict[str, Any]], existing_ids: Set[str
|
||||
return missing
|
||||
|
||||
|
||||
def collect_route_ids_without_steps(
|
||||
items: List[Dict[str, Any]],
|
||||
route_ids_with_steps: Set[str],
|
||||
) -> List[str]:
|
||||
"""返回条目中已绑定路线但无有效工序步骤的 route_id(去重保序)。"""
|
||||
empty: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
with_steps = route_ids_with_steps or set()
|
||||
for item in items or []:
|
||||
rid = _strip_or_none(item.get("route_id"))
|
||||
if not rid or rid in with_steps or rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
empty.append(rid)
|
||||
return empty
|
||||
|
||||
|
||||
def _load_route_ids_with_steps(db: Session, route_ids: List[str]) -> Set[str]:
|
||||
ids = [rid for rid in route_ids if rid]
|
||||
if not ids:
|
||||
return set()
|
||||
rows = (
|
||||
db.query(BizProcessRouteStep.route_id)
|
||||
.filter(
|
||||
BizProcessRouteStep.route_id.in_(ids),
|
||||
BizProcessRouteStep.del_flag == 0,
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
return {r[0] for r in rows if r and r[0]}
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if page_size <= 0:
|
||||
return 0
|
||||
@@ -222,41 +257,46 @@ def _replace_items(
|
||||
create_dept_id: str,
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
existing = (
|
||||
db.query(BizProductionTaskSetItem)
|
||||
.filter(
|
||||
BizProductionTaskSetItem.set_id == set_id,
|
||||
BizProductionTaskSetItem.tenant_no == tenant_no,
|
||||
BizProductionTaskSetItem.del_flag == 0,
|
||||
|
||||
def _set_item_key(item):
|
||||
return f"{(item.get('route_id') or '').strip()}|{(item.get('task_name') or '').strip()}"
|
||||
|
||||
def build_item(row_id):
|
||||
return BizProductionTaskSetItem(
|
||||
id=row_id,
|
||||
set_id=set_id,
|
||||
task_name="",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for row in existing:
|
||||
row.del_flag = 1
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
for idx, item in enumerate(items, start=1):
|
||||
|
||||
def apply_item(row, item, idx, _now):
|
||||
rid = item.get("route_id")
|
||||
db.add(
|
||||
BizProductionTaskSetItem(
|
||||
id=generate_uuid(),
|
||||
set_id=set_id,
|
||||
task_name=item["task_name"],
|
||||
route_id=rid,
|
||||
route_name=route_names.get(rid or "") if rid else None,
|
||||
description=item.get("description"),
|
||||
remark=item.get("remark"),
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
row.task_name = item["task_name"]
|
||||
row.route_id = rid
|
||||
row.route_name = route_names.get(rid or "") if rid else None
|
||||
row.description = item.get("description")
|
||||
row.remark = item.get("remark")
|
||||
row.order_num = idx + 1
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizProductionTaskSetItem,
|
||||
parent_attr="set_id",
|
||||
parent_id=set_id,
|
||||
tenant_no=tenant_no,
|
||||
items=items,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_item,
|
||||
apply_fields=apply_item,
|
||||
natural_key=_set_item_key,
|
||||
row_natural_key=lambda row: f"{(row.route_id or '').strip()}|{(row.task_name or '').strip()}",
|
||||
duplicate_message="任务集合条目重复",
|
||||
foreign_message="任务集合条目不属于当前集合",
|
||||
)
|
||||
|
||||
|
||||
def _item_count_map(db: Session, tenant_no: str, set_ids: List[str]) -> Dict[str, int]:
|
||||
@@ -664,12 +704,16 @@ def create_tasks_from_set(
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
name_map = _load_route_name_map(
|
||||
db, tenant_no, [p["route_id"] for p in payloads if p.get("route_id")]
|
||||
)
|
||||
route_ids = [p["route_id"] for p in payloads if p.get("route_id")]
|
||||
name_map = _load_route_name_map(db, tenant_no, route_ids)
|
||||
missing = collect_missing_route_ids(payloads, set(name_map.keys()))
|
||||
if missing:
|
||||
raise ValueError("工艺路线已删除,无法派发:" + "、".join(missing))
|
||||
with_steps = _load_route_ids_with_steps(db, route_ids)
|
||||
empty_steps = collect_route_ids_without_steps(payloads, with_steps)
|
||||
if empty_steps:
|
||||
labels = [name_map.get(rid) or rid for rid in empty_steps]
|
||||
raise ValueError("工艺路线无工序步骤,无法派发:" + "、".join(labels))
|
||||
lab_storage_code = resolve_work_order_lab_storage_code(
|
||||
db, tenant_no=tenant_no, wo=wo
|
||||
)
|
||||
|
||||
@@ -182,7 +182,7 @@ def enrich_step_completed_by_names(db: Session, steps: List[Dict]) -> None:
|
||||
return
|
||||
users = {
|
||||
u.id: u
|
||||
for u in db.query(SysUser).filter(SysUser.id.in_(uids), SysUser.del_flag == 0).all()
|
||||
for u in db.query(SysUser).filter(SysUser.id.in_(uids)).all()
|
||||
}
|
||||
for step in steps:
|
||||
uid = (step.get("completedBy") or "").strip()
|
||||
|
||||
@@ -347,6 +347,8 @@ def _enrich_outbound_items(
|
||||
"projectName": product.project_name or "",
|
||||
"cellTypeName": product.cell_type_name or "",
|
||||
"specText": product.spec_text or "",
|
||||
"packageId": getattr(product, "package_id", None) or "",
|
||||
"packageText": getattr(product, "package_text", None) or "",
|
||||
"agreementPrice": _dec_price(product.agreement_price),
|
||||
"actualPrice": _dec_price(product.actual_price),
|
||||
"quantity": product.quantity,
|
||||
@@ -435,6 +437,8 @@ def _enrich_outbound_items(
|
||||
it["totalCourseCount"] = course.get("totalCourseCount")
|
||||
it["cellType"] = sched.cell_type or ""
|
||||
it["specText"] = sched.spec_text or ""
|
||||
it["packageId"] = getattr(sched, "package_id", None) or ""
|
||||
it["packageText"] = getattr(sched, "package_text", None) or ""
|
||||
it["infusionTime"] = _fmt_dt(sched.infusion_time) if sched.infusion_time else ""
|
||||
it["infusionLocation"] = sched.infusion_location or ""
|
||||
it["products"] = products_map.get(it["infusionScheduleId"], [])
|
||||
@@ -583,6 +587,8 @@ def _attach_outbound_detail(
|
||||
out["infusionSchedule"] = {
|
||||
"cellType": sched.cell_type if sched else "",
|
||||
"specText": sched.spec_text if sched else "",
|
||||
"packageId": getattr(sched, "package_id", None) or "" if sched else "",
|
||||
"packageText": getattr(sched, "package_text", None) or "" if sched else "",
|
||||
"infusionTime": _fmt_dt(sched.infusion_time) if sched and sched.infusion_time else "",
|
||||
"infusionLocation": sched.infusion_location if sched else "",
|
||||
"infusionMethod": sched.infusion_method if sched else "",
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import and_, exists, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid, parse_datetime
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
BizCellSampleType,
|
||||
@@ -746,7 +747,7 @@ def replace_schedules(
|
||||
user_id: Optional[str],
|
||||
schedules: List[Dict[str, Any]],
|
||||
) -> List[BizProtocolPaymentSchedule]:
|
||||
"""仅在全部批次为 open、全部转账为 dfj 时重建批次。"""
|
||||
"""仅在全部批次为 open、全部转账为 dfj 时按期次更新批次。去掉的期次连同草稿转账一起软删。"""
|
||||
sp, old_schedules, transfers = _lock_sample_payment_graph(
|
||||
db,
|
||||
sample_payment_id=sample_payment_id,
|
||||
@@ -755,45 +756,74 @@ def replace_schedules(
|
||||
if _locked_schedule_ids(old_schedules, transfers):
|
||||
raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
|
||||
now = _now()
|
||||
if old_schedules:
|
||||
for t in transfers:
|
||||
t.del_flag = 1
|
||||
t.update_by = user_id
|
||||
t.update_time = now
|
||||
db.add(t)
|
||||
for s in old_schedules:
|
||||
s.del_flag = 1
|
||||
s.update_by = user_id
|
||||
s.update_time = now
|
||||
db.add(s)
|
||||
|
||||
created: List[BizProtocolPaymentSchedule] = []
|
||||
payload = []
|
||||
for idx, raw in enumerate(schedules or []):
|
||||
due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
schedule_no = _schedule_no_of(raw, idx + 1)
|
||||
obj = BizProtocolPaymentSchedule(
|
||||
id=generate_uuid(),
|
||||
due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
|
||||
payload.append(
|
||||
{
|
||||
"id": (raw.get("id") or "").strip(),
|
||||
"schedule_no": schedule_no,
|
||||
"due_amount": _dec(
|
||||
raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")
|
||||
),
|
||||
"due_date": due_date,
|
||||
"remark": (raw.get("remark") or "").strip() or None,
|
||||
"order_num": idx,
|
||||
}
|
||||
)
|
||||
|
||||
def build_row(row_id):
|
||||
return BizProtocolPaymentSchedule(
|
||||
id=row_id,
|
||||
sample_payment_id=sp.id,
|
||||
collection_protocol_id=sp.collection_protocol_id,
|
||||
sample_code=sp.sample_code,
|
||||
schedule_no=schedule_no,
|
||||
due_amount=_dec(raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")),
|
||||
due_date=due_date,
|
||||
schedule_status="open",
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
remark=(raw.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
created.append(obj)
|
||||
db.flush()
|
||||
|
||||
def apply_row(row, item, _idx, _now):
|
||||
row.collection_protocol_id = sp.collection_protocol_id
|
||||
row.sample_code = sp.sample_code
|
||||
row.schedule_no = item["schedule_no"]
|
||||
row.due_amount = item["due_amount"]
|
||||
row.due_date = item["due_date"]
|
||||
row.schedule_status = row.schedule_status or "open"
|
||||
row.remark = item["remark"]
|
||||
row.order_num = item["order_num"]
|
||||
|
||||
created = sync_child_rows(
|
||||
db,
|
||||
BizProtocolPaymentSchedule,
|
||||
parent_attr="sample_payment_id",
|
||||
parent_id=sp.id,
|
||||
tenant_no=tenant_no,
|
||||
items=payload,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: str(item["schedule_no"]),
|
||||
row_natural_key=lambda row: str(row.schedule_no or ""),
|
||||
duplicate_message="应缴批次期次重复",
|
||||
foreign_message="应缴批次不属于当前缴费方案",
|
||||
reuse_deleted=True,
|
||||
)
|
||||
kept_ids = {row.id for row in created}
|
||||
for transfer in transfers:
|
||||
if transfer.schedule_id in kept_ids:
|
||||
continue
|
||||
transfer.del_flag = 1
|
||||
transfer.update_by = user_id
|
||||
transfer.update_time = now
|
||||
db.add(transfer)
|
||||
db.flush()
|
||||
return created
|
||||
|
||||
|
||||
@@ -1350,8 +1380,12 @@ def save_sample_payments_for_protocol(
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
sample_payments: List[Dict[str, Any]],
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
"""按样本保存缴费方案;锁定批次/转账所在方案禁止结构性变更。"""
|
||||
"""按样本保存缴费方案;锁定批次/转账所在方案禁止结构性变更。
|
||||
|
||||
commit=False 时仅 flush,由调用方统一提交,避免协议其它子资源已提交后缴费校验失败导致半成功。
|
||||
"""
|
||||
existing = (
|
||||
db.query(BizProtocolSamplePayment)
|
||||
.filter(
|
||||
@@ -1477,7 +1511,10 @@ def save_sample_payments_for_protocol(
|
||||
_soft_delete_sample_payment(
|
||||
db, sp=sp, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
db.commit()
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
|
||||
|
||||
def list_deposit_transfers_for_protocol(
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.crud import qc_task as task_svc
|
||||
from app.crud import qc_task_assignee as assignee_svc
|
||||
from app.crud import qc_third_party_item as third_item_svc
|
||||
from app.crud.qc_third_party_company import get_company
|
||||
from app.crud.user_display import user_display_name_map
|
||||
from app.models.models import (
|
||||
BizQcBatchAssignee,
|
||||
BizQcTask,
|
||||
@@ -20,7 +21,6 @@ from app.models.models import (
|
||||
BizQcWorkOrderInspectionBatch,
|
||||
BizQcWorkOrderLabItem,
|
||||
BizQcWorkOrderThirdItem,
|
||||
SysUser,
|
||||
)
|
||||
|
||||
_CONFIGURABLE_WORK_ORDER_STATUSES = frozenset({"dpz", "ypz", "ywc", "yzf"})
|
||||
@@ -180,13 +180,8 @@ def _batch_assignee_user_ids(db: Session, batch_id: str, tenant_no: str) -> List
|
||||
def _assignee_names(db: Session, user_ids: List[str]) -> str:
|
||||
if not user_ids:
|
||||
return ""
|
||||
rows = (
|
||||
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
|
||||
.filter(SysUser.id.in_(user_ids), SysUser.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
name_map = {r.id: (r.nick_name or r.user_no or r.id) for r in rows}
|
||||
return "、".join(name_map.get(uid, uid) for uid in user_ids)
|
||||
name_map = user_display_name_map(db, user_ids)
|
||||
return "、".join(name_map.get(uid) or uid for uid in user_ids)
|
||||
|
||||
|
||||
def _prefetch_batch_side_data(
|
||||
@@ -248,12 +243,7 @@ def _prefetch_batch_side_data(
|
||||
[uid for ids in assignees_by_batch.values() for uid in ids]
|
||||
)
|
||||
if all_user_ids:
|
||||
for row in (
|
||||
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
|
||||
.filter(SysUser.id.in_(all_user_ids), SysUser.del_flag == 0)
|
||||
.all()
|
||||
):
|
||||
name_map[row.id] = row.nick_name or row.user_no or row.id
|
||||
name_map = user_display_name_map(db, all_user_ids)
|
||||
|
||||
return {
|
||||
"assignees_by_batch": assignees_by_batch,
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.models.qc_param import BizQcItemParam
|
||||
|
||||
|
||||
@@ -80,18 +80,23 @@ def save_params_for_item(
|
||||
_validate_param(p, idx)
|
||||
|
||||
now = datetime.now()
|
||||
db.query(BizQcItemParam).filter(
|
||||
BizQcItemParam.item_source == item_source,
|
||||
BizQcItemParam.item_id == item_id,
|
||||
BizQcItemParam.tenant_no == tenant_no,
|
||||
BizQcItemParam.del_flag == 0,
|
||||
).update(
|
||||
{"del_flag": 1, "update_by": user_id, "update_time": now},
|
||||
synchronize_session="fetch",
|
||||
)
|
||||
db.flush()
|
||||
|
||||
for idx, p in enumerate(params):
|
||||
def build_row(row_id):
|
||||
return BizQcItemParam(
|
||||
id=row_id,
|
||||
item_source=item_source,
|
||||
item_id=item_id,
|
||||
param_name="",
|
||||
param_code="",
|
||||
param_type="text",
|
||||
required_flag="0",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row, p, idx, _now):
|
||||
param_type = (p.get("paramType") or "text").strip()
|
||||
options_val = None
|
||||
default_val = None
|
||||
@@ -99,28 +104,32 @@ def save_params_for_item(
|
||||
opts = _parse_options_list(p.get("options"))
|
||||
options_val = json.dumps(opts, ensure_ascii=False)
|
||||
default_val = opts[0] if opts else None
|
||||
row.param_name = p.get("paramName", "")
|
||||
row.param_code = p.get("paramCode", "")
|
||||
row.param_unit = p.get("paramUnit") or None
|
||||
row.param_type = param_type
|
||||
row.options = options_val
|
||||
row.default_value = default_val
|
||||
row.required_flag = p.get("requiredFlag", "0")
|
||||
row.order_num = p.get("orderNum", idx)
|
||||
|
||||
obj = BizQcItemParam(
|
||||
id=generate_uuid(),
|
||||
item_source=item_source,
|
||||
item_id=item_id,
|
||||
param_name=p.get("paramName", ""),
|
||||
param_code=p.get("paramCode", ""),
|
||||
param_unit=p.get("paramUnit") or None,
|
||||
param_type=param_type,
|
||||
options=options_val,
|
||||
default_value=default_val,
|
||||
required_flag=p.get("requiredFlag", "0"),
|
||||
tenant_no=tenant_no,
|
||||
order_num=p.get("orderNum", idx),
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizQcItemParam,
|
||||
parent_attr="item_id",
|
||||
parent_id=item_id,
|
||||
tenant_no=tenant_no,
|
||||
items=params,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("paramCode") or "").strip(),
|
||||
row_natural_key=lambda row: (row.param_code or "").strip(),
|
||||
duplicate_message="参数编码重复",
|
||||
foreign_message="参数不属于当前检测项目",
|
||||
extra_filters={"item_source": item_source},
|
||||
reuse_deleted=True,
|
||||
)
|
||||
db.commit()
|
||||
return list_params_by_item(db, item_source, item_id, tenant_no)
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.qc_material_stock_batch import _dec, _fmt_date, _fmt_dt, _parse_date, _require_str
|
||||
from app.models.models import SysUser
|
||||
from app.models.qc_material import (
|
||||
@@ -439,6 +440,44 @@ def _resolve_unused_item_fields(
|
||||
return out
|
||||
|
||||
|
||||
def _unused_line_key(usage_id: Optional[str], batch_id: Optional[str]) -> str:
|
||||
usage = (usage_id or "").strip()
|
||||
if usage:
|
||||
return f"usage:{usage}"
|
||||
batch = (batch_id or "").strip()
|
||||
if batch:
|
||||
return f"batch:{batch}"
|
||||
return ""
|
||||
|
||||
|
||||
def _prepare_unused_item(db: Session, it: Dict[str, Any], tenant_no: str) -> Dict[str, Any]:
|
||||
it = _resolve_unused_item_fields(db, it, tenant_no)
|
||||
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
|
||||
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
|
||||
if qty is None or Decimal(str(qty)) <= 0:
|
||||
raise ValueError("未用完数量必须大于0")
|
||||
unit_price = it.get("unit_price") if it.get("unit_price") is not None else it.get("unitPrice")
|
||||
return {
|
||||
"id": str(it.get("id") or "").strip(),
|
||||
"usage_id": (it.get("usage_id") or it.get("usageId") or "").strip() or None,
|
||||
"batch_id": (it.get("batch_id") or it.get("batchId") or "").strip() or None,
|
||||
"material_code": (it.get("material_code") or it.get("materialCode") or "").strip() or None,
|
||||
"material_name": name,
|
||||
"spec_model": (it.get("spec_model") or it.get("specModel") or "").strip() or None,
|
||||
"unit": (it.get("unit") or "").strip() or None,
|
||||
"stock_in_no": (it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
|
||||
"stock_in_date": _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate")),
|
||||
"expire_date": _parse_optional_date(it.get("expire_date") or it.get("expireDate")),
|
||||
"catalog_no": (it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
|
||||
"batch_no": (it.get("batch_no") or it.get("batchNo") or "").strip() or None,
|
||||
"manufacturer": (it.get("manufacturer") or "").strip() or None,
|
||||
"unit_price": Decimal(str(unit_price)) if unit_price is not None else None,
|
||||
"holder": (it.get("holder") or "").strip() or None,
|
||||
"unused_qty": Decimal(str(qty)),
|
||||
"remark": (it.get("remark") or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def save_unused_lines(
|
||||
db: Session,
|
||||
check_id: str,
|
||||
@@ -455,72 +494,61 @@ def save_unused_lines(
|
||||
raise ValueError("仅进行中的盘点单可登记未用完物料")
|
||||
|
||||
now = datetime.now()
|
||||
old_rows = (
|
||||
db.query(BizQcMaterialStockCheckUnusedLine)
|
||||
.filter(
|
||||
BizQcMaterialStockCheckUnusedLine.check_id == check_id,
|
||||
BizQcMaterialStockCheckUnusedLine.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for r in old_rows:
|
||||
r.del_flag = 1
|
||||
r.update_by = user_id
|
||||
r.update_time = now
|
||||
db.add(r)
|
||||
prepared = [_prepare_unused_item(db, it, tenant_no) for it in (items or [])]
|
||||
|
||||
count = 0
|
||||
for it in items or []:
|
||||
it = _resolve_unused_item_fields(db, it, tenant_no)
|
||||
name = _require_str(it.get("material_name") or it.get("materialName"), "物料名称")
|
||||
qty = it.get("unused_qty") if it.get("unused_qty") is not None else it.get("unusedQty")
|
||||
if qty is None or Decimal(str(qty)) <= 0:
|
||||
raise ValueError("未用完数量必须大于0")
|
||||
qty_dec = Decimal(str(qty))
|
||||
stock_in_date = _parse_optional_date(it.get("stock_in_date") or it.get("stockInDate"))
|
||||
expire_date = _parse_optional_date(it.get("expire_date") or it.get("expireDate"))
|
||||
usage_id = (it.get("usage_id") or it.get("usageId") or "").strip() or None
|
||||
batch_id = (it.get("batch_id") or it.get("batchId") or "").strip() or None
|
||||
|
||||
row = BizQcMaterialStockCheckUnusedLine(
|
||||
id=generate_uuid(),
|
||||
def build_row(row_id: str):
|
||||
return BizQcMaterialStockCheckUnusedLine(
|
||||
id=row_id,
|
||||
check_id=check_id,
|
||||
usage_id=usage_id,
|
||||
batch_id=batch_id,
|
||||
material_code=(it.get("material_code") or it.get("materialCode") or "").strip() or None,
|
||||
material_name=name,
|
||||
spec_model=(it.get("spec_model") or it.get("specModel") or "").strip() or None,
|
||||
unit=(it.get("unit") or "").strip() or None,
|
||||
stock_in_no=(it.get("stock_in_no") or it.get("stockInNo") or "").strip() or None,
|
||||
stock_in_date=stock_in_date,
|
||||
expire_date=expire_date,
|
||||
catalog_no=(it.get("catalog_no") or it.get("catalogNo") or "").strip() or None,
|
||||
batch_no=(it.get("batch_no") or it.get("batchNo") or "").strip() or None,
|
||||
manufacturer=(it.get("manufacturer") or "").strip() or None,
|
||||
unit_price=(
|
||||
Decimal(str(it.get("unit_price") or it.get("unitPrice")))
|
||||
if (it.get("unit_price") is not None or it.get("unitPrice") is not None)
|
||||
else None
|
||||
),
|
||||
holder=(it.get("holder") or "").strip() or None,
|
||||
unused_qty=qty_dec,
|
||||
material_name="",
|
||||
unused_qty=0,
|
||||
tenant_no=tenant_no,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
count += 1
|
||||
|
||||
def apply_row(row, it, idx, _now):
|
||||
row.usage_id = it["usage_id"]
|
||||
row.batch_id = it["batch_id"]
|
||||
row.material_code = it["material_code"]
|
||||
row.material_name = it["material_name"]
|
||||
row.spec_model = it["spec_model"]
|
||||
row.unit = it["unit"]
|
||||
row.stock_in_no = it["stock_in_no"]
|
||||
row.stock_in_date = it["stock_in_date"]
|
||||
row.expire_date = it["expire_date"]
|
||||
row.catalog_no = it["catalog_no"]
|
||||
row.batch_no = it["batch_no"]
|
||||
row.manufacturer = it["manufacturer"]
|
||||
row.unit_price = it["unit_price"]
|
||||
row.holder = it["holder"]
|
||||
row.unused_qty = it["unused_qty"]
|
||||
row.remark = it["remark"]
|
||||
row.order_num = idx
|
||||
|
||||
saved = sync_child_rows(
|
||||
db,
|
||||
BizQcMaterialStockCheckUnusedLine,
|
||||
parent_attr="check_id",
|
||||
parent_id=check_id,
|
||||
tenant_no=tenant_no,
|
||||
items=prepared,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda it: _unused_line_key(it.get("usage_id"), it.get("batch_id")),
|
||||
row_natural_key=lambda row: _unused_line_key(row.usage_id, row.batch_id),
|
||||
duplicate_message="未用完明细重复",
|
||||
foreign_message="未用完明细不属于当前盘点单",
|
||||
)
|
||||
|
||||
check.update_by = user_id
|
||||
check.update_time = now
|
||||
db.add(check)
|
||||
db.commit()
|
||||
return count
|
||||
return len(saved)
|
||||
|
||||
|
||||
def complete_check(
|
||||
|
||||
@@ -5,8 +5,8 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud import qc_item_param as param_svc
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.qc_task import get_task
|
||||
from app.models.models import BizQcWorkOrderLabItem, BizQcWorkOrderThirdItem
|
||||
from app.models.qc_param import BizQcTaskResult
|
||||
@@ -152,36 +152,53 @@ def save_results_for_task(
|
||||
if not task:
|
||||
raise ValueError("任务不存在")
|
||||
now = datetime.now()
|
||||
db.query(BizQcTaskResult).filter(
|
||||
BizQcTaskResult.task_id == task_id,
|
||||
BizQcTaskResult.tenant_no == tenant_no,
|
||||
BizQcTaskResult.del_flag == 0,
|
||||
).update(
|
||||
{"del_flag": 1, "update_by": user_id, "update_time": now},
|
||||
synchronize_session="fetch",
|
||||
)
|
||||
db.flush()
|
||||
|
||||
for res in results:
|
||||
obj = BizQcTaskResult(
|
||||
id=generate_uuid(),
|
||||
def _result_key(item: Dict[str, Any]) -> str:
|
||||
param_id = (item.get("paramId") or "").strip()
|
||||
if param_id:
|
||||
return "id:" + param_id
|
||||
return "code:" + (item.get("paramCode") or "").strip()
|
||||
|
||||
def build_row(row_id):
|
||||
return BizQcTaskResult(
|
||||
id=row_id,
|
||||
task_id=task_id,
|
||||
param_id=res.get("paramId") or None,
|
||||
param_name=res.get("paramName", ""),
|
||||
param_code=res.get("paramCode") or None,
|
||||
param_unit=res.get("paramUnit") or None,
|
||||
param_type=res.get("paramType", "text"),
|
||||
param_value=res.get("paramValue") or None,
|
||||
param_name="",
|
||||
param_type="text",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
def apply_row(row, res, _idx, _now):
|
||||
row.param_id = res.get("paramId") or None
|
||||
row.param_name = res.get("paramName", "")
|
||||
row.param_code = res.get("paramCode") or None
|
||||
row.param_unit = res.get("paramUnit") or None
|
||||
row.param_type = res.get("paramType", "text")
|
||||
row.param_value = res.get("paramValue") or None
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizQcTaskResult,
|
||||
parent_attr="task_id",
|
||||
parent_id=task_id,
|
||||
tenant_no=tenant_no,
|
||||
items=results,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=_result_key,
|
||||
row_natural_key=lambda row: (
|
||||
"id:" + row.param_id.strip()
|
||||
if (row.param_id or "").strip()
|
||||
else "code:" + (row.param_code or "").strip()
|
||||
),
|
||||
duplicate_message="检测结果重复",
|
||||
foreign_message="检测结果不属于当前任务",
|
||||
)
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""临时质检列表:同生产工单成组,组位置按组内最新创建时间倒序。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cmp_to_key
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import Query
|
||||
|
||||
from app.models.models import BizQcWorkOrder
|
||||
|
||||
|
||||
def uses_temporary_production_cluster_list_sort(work_order_type: Optional[str]) -> bool:
|
||||
return (work_order_type or "").strip() == "temporary"
|
||||
|
||||
|
||||
def temporary_production_cluster_key(
|
||||
production_work_order_id: Optional[str], row_id: str
|
||||
) -> str:
|
||||
pwo = (production_work_order_id or "").strip()
|
||||
if pwo:
|
||||
return f"pwo:{pwo}"
|
||||
return f"id:{(row_id or '').strip()}"
|
||||
|
||||
|
||||
def _max_create_time_by_cluster(
|
||||
rows: Sequence[Tuple[str, Optional[str], str]],
|
||||
) -> Dict[str, str]:
|
||||
"""rows: (id, production_work_order_id, create_time_str)"""
|
||||
group_max: Dict[str, str] = {}
|
||||
for row_id, pwo_id, create_time in rows:
|
||||
key = temporary_production_cluster_key(pwo_id, row_id)
|
||||
t = (create_time or "").strip()
|
||||
prev = group_max.get(key, "")
|
||||
if t > prev:
|
||||
group_max[key] = t
|
||||
return group_max
|
||||
|
||||
|
||||
def compare_temporary_production_cluster_desc(
|
||||
id_a: str,
|
||||
pwo_a: Optional[str],
|
||||
time_a: Optional[str],
|
||||
group_max_a: str,
|
||||
id_b: str,
|
||||
pwo_b: Optional[str],
|
||||
time_b: Optional[str],
|
||||
group_max_b: str,
|
||||
) -> int:
|
||||
"""负值表示 a 应排在 b 前。"""
|
||||
ga = (group_max_a or "").strip()
|
||||
gb = (group_max_b or "").strip()
|
||||
if ga != gb:
|
||||
return -1 if ga > gb else 1
|
||||
key_a = temporary_production_cluster_key(pwo_a, id_a)
|
||||
key_b = temporary_production_cluster_key(pwo_b, id_b)
|
||||
if key_a != key_b:
|
||||
return -1 if key_a < key_b else 1
|
||||
ta = (time_a or "").strip()
|
||||
tb = (time_b or "").strip()
|
||||
if ta != tb:
|
||||
return -1 if ta > tb else 1
|
||||
ia = (id_a or "").strip()
|
||||
ib = (id_b or "").strip()
|
||||
if ia != ib:
|
||||
return -1 if ia > ib else 1
|
||||
return 0
|
||||
|
||||
|
||||
def sort_dicts_by_temporary_production_cluster(
|
||||
items: List[Dict[str, Any]],
|
||||
*,
|
||||
id_key: str = "id",
|
||||
pwo_key: str = "productionWorkOrderId",
|
||||
time_key: str = "createTime",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""按设计规则排序质检工单 dict 列表(供单测 / 内存排序)。"""
|
||||
triples: List[Tuple[str, Optional[str], str]] = []
|
||||
for item in items:
|
||||
rid = str(item.get(id_key) or "")
|
||||
pwo = item.get(pwo_key)
|
||||
pwo_s: Optional[str]
|
||||
if pwo is None:
|
||||
pwo_s = None
|
||||
elif isinstance(pwo, str):
|
||||
pwo_s = pwo
|
||||
else:
|
||||
pwo_s = str(pwo)
|
||||
triples.append((rid, pwo_s, str(item.get(time_key) or "")))
|
||||
group_max = _max_create_time_by_cluster(triples)
|
||||
|
||||
def cmp(a: Dict[str, Any], b: Dict[str, Any]) -> int:
|
||||
id_a = str(a.get(id_key) or "")
|
||||
id_b = str(b.get(id_key) or "")
|
||||
pwo_a = a.get(pwo_key)
|
||||
pwo_b = b.get(pwo_key)
|
||||
if pwo_a is not None and not isinstance(pwo_a, str):
|
||||
pwo_a = str(pwo_a)
|
||||
if pwo_b is not None and not isinstance(pwo_b, str):
|
||||
pwo_b = str(pwo_b)
|
||||
time_a = str(a.get(time_key) or "")
|
||||
time_b = str(b.get(time_key) or "")
|
||||
key_a = temporary_production_cluster_key(pwo_a, id_a)
|
||||
key_b = temporary_production_cluster_key(pwo_b, id_b)
|
||||
return compare_temporary_production_cluster_desc(
|
||||
id_a,
|
||||
pwo_a,
|
||||
time_a,
|
||||
group_max.get(key_a, ""),
|
||||
id_b,
|
||||
pwo_b,
|
||||
time_b,
|
||||
group_max.get(key_b, ""),
|
||||
)
|
||||
|
||||
return sorted(items, key=cmp_to_key(cmp))
|
||||
|
||||
|
||||
def apply_temporary_production_cluster_order(q: Query) -> Query:
|
||||
"""对已过滤的 BizQcWorkOrder Query 施加成组排序(MySQL 5.7 友好)。"""
|
||||
session = q.session
|
||||
base = (
|
||||
q.order_by(None)
|
||||
.with_entities(
|
||||
BizQcWorkOrder.id.label("wo_id"),
|
||||
BizQcWorkOrder.production_work_order_id.label("pwo_id"),
|
||||
BizQcWorkOrder.create_time.label("ct"),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
pwo_max = (
|
||||
session.query(
|
||||
base.c.pwo_id.label("pwo_id"),
|
||||
func.max(base.c.ct).label("group_ct"),
|
||||
)
|
||||
.select_from(base)
|
||||
.filter(
|
||||
base.c.pwo_id.isnot(None),
|
||||
base.c.pwo_id != "",
|
||||
)
|
||||
.group_by(base.c.pwo_id)
|
||||
.subquery()
|
||||
)
|
||||
return (
|
||||
q.outerjoin(
|
||||
pwo_max,
|
||||
and_(
|
||||
BizQcWorkOrder.production_work_order_id == pwo_max.c.pwo_id,
|
||||
BizQcWorkOrder.production_work_order_id.isnot(None),
|
||||
BizQcWorkOrder.production_work_order_id != "",
|
||||
),
|
||||
)
|
||||
.order_by(
|
||||
func.coalesce(pwo_max.c.group_ct, BizQcWorkOrder.create_time).desc(),
|
||||
func.coalesce(BizQcWorkOrder.production_work_order_id, BizQcWorkOrder.id).asc(),
|
||||
BizQcWorkOrder.create_time.desc(),
|
||||
BizQcWorkOrder.id.desc(),
|
||||
)
|
||||
)
|
||||
@@ -6,10 +6,14 @@ from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy import and_, exists, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db_collation import eq_unicode, like_unicode, unicode_ci
|
||||
from app.core.protocol_status_display import (
|
||||
DEFAULT_QC_DONATION_PROTOCOL_STATUSES,
|
||||
resolve_research_donation_protocol_status_filter,
|
||||
)
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud import qc_lab_test_item as lab_item_svc
|
||||
from app.crud import qc_task as task_svc
|
||||
@@ -20,6 +24,10 @@ from app.crud.qc_inbound_lab_storage_sort import (
|
||||
inbound_lab_storage_order_by_clauses,
|
||||
uses_inbound_lab_storage_list_sort,
|
||||
)
|
||||
from app.crud.qc_temporary_production_cluster_sort import (
|
||||
apply_temporary_production_cluster_order,
|
||||
uses_temporary_production_cluster_list_sort,
|
||||
)
|
||||
from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.utils.sample_special import apply_special_to_item_from_samples
|
||||
from app.crud import sample_reception as sample_reception_svc
|
||||
@@ -219,10 +227,51 @@ def _enrich_work_order_cell_products(
|
||||
return items
|
||||
|
||||
|
||||
def _step_output_identity(row: BizProductionTaskStepOutput) -> Tuple[str, str, str, str]:
|
||||
return (
|
||||
(row.step_id or "").strip(),
|
||||
(row.output_name or "").strip(),
|
||||
(row.output_barcode or "").strip(),
|
||||
(row.output_type_id or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
def _live_output_replacements(
|
||||
db: Session,
|
||||
tenant_no: str,
|
||||
deleted_rows: List[BizProductionTaskStepOutput],
|
||||
) -> Dict[str, BizProductionTaskStepOutput]:
|
||||
"""已软删产出物 → 同一步骤上名称、条码、产出类型都一致的唯一有效行。"""
|
||||
step_ids = list({(row.step_id or "").strip() for row in deleted_rows if (row.step_id or "").strip()})
|
||||
if not step_ids:
|
||||
return {}
|
||||
live_rows = (
|
||||
db.query(BizProductionTaskStepOutput)
|
||||
.filter(
|
||||
BizProductionTaskStepOutput.step_id.in_(step_ids),
|
||||
BizProductionTaskStepOutput.tenant_no == tenant_no,
|
||||
BizProductionTaskStepOutput.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
grouped: Dict[Tuple[str, str, str, str], List[BizProductionTaskStepOutput]] = defaultdict(list)
|
||||
for row in live_rows:
|
||||
grouped[_step_output_identity(row)].append(row)
|
||||
replacements: Dict[str, BizProductionTaskStepOutput] = {}
|
||||
for old in deleted_rows:
|
||||
matches = [row for row in grouped.get(_step_output_identity(old), []) if row.id != old.id]
|
||||
if len(matches) == 1:
|
||||
replacements[old.id] = matches[0]
|
||||
return replacements
|
||||
|
||||
|
||||
def _enrich_work_order_step_outputs(
|
||||
db: Session, tenant_no: str, items: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""步骤产出物触发的质检:无细胞产品时用工单条码/名称展示质检对象。"""
|
||||
"""步骤产出物触发的质检:无细胞产品时用有效产出物的条码和名称展示质检对象。
|
||||
|
||||
工单若仍指向已软删产出物,改用同一步骤上的那条有效产出物,不再拿软删行当质检对象。
|
||||
"""
|
||||
if not items:
|
||||
return items
|
||||
step_output_ids = list(
|
||||
@@ -240,17 +289,23 @@ def _enrich_work_order_step_outputs(
|
||||
.filter(
|
||||
BizProductionTaskStepOutput.id.in_(step_output_ids),
|
||||
BizProductionTaskStepOutput.tenant_no == tenant_no,
|
||||
BizProductionTaskStepOutput.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
row_map = {r.id: r for r in rows}
|
||||
deleted_rows = [row for row in rows if int(row.del_flag or 0) == 1]
|
||||
live_by_deleted_id = (
|
||||
_live_output_replacements(db, tenant_no, deleted_rows) if deleted_rows else {}
|
||||
)
|
||||
row_map = {row.id: row for row in rows}
|
||||
for item in items:
|
||||
if (item.get("cellProductId") or "").strip():
|
||||
continue
|
||||
so = row_map.get(item.get("productionStepOutputId") or "")
|
||||
if not so:
|
||||
if so is not None and int(so.del_flag or 0) == 1:
|
||||
so = live_by_deleted_id.get(so.id)
|
||||
if so is None or int(so.del_flag or 0) == 1:
|
||||
continue
|
||||
item["productionStepOutputId"] = so.id
|
||||
if not (item.get("cellProductCode") or "").strip():
|
||||
item["cellProductCode"] = (so.output_barcode or "").strip()
|
||||
if not (item.get("cellProductName") or "").strip():
|
||||
@@ -298,6 +353,8 @@ def _enrich_outbound_work_orders(
|
||||
"projectName": product.project_name or "",
|
||||
"cellTypeName": product.cell_type_name or "",
|
||||
"specText": product.spec_text or "",
|
||||
"packageId": getattr(product, "package_id", None) or "",
|
||||
"packageText": getattr(product, "package_text", None) or "",
|
||||
"agreementPrice": _dec(product.agreement_price),
|
||||
"actualPrice": _dec(product.actual_price),
|
||||
"quantity": product.quantity,
|
||||
@@ -381,6 +438,8 @@ def _enrich_outbound_work_orders(
|
||||
item["totalCourseCount"] = course.get("totalCourseCount")
|
||||
item["cellType"] = sched.cell_type or ""
|
||||
item["specText"] = sched.spec_text or ""
|
||||
item["packageId"] = getattr(sched, "package_id", None) or ""
|
||||
item["packageText"] = getattr(sched, "package_text", None) or ""
|
||||
item["infusionTime"] = _fmt_dt(sched.infusion_time)
|
||||
item["products"] = products_map.get(sid, [])
|
||||
return items
|
||||
@@ -1566,6 +1625,52 @@ def _protocol_sample_ids_for_sample_type(
|
||||
return ids
|
||||
|
||||
|
||||
def _research_donation_status_exists(statuses: List[str]):
|
||||
"""质检工单关联科研捐赠单且 donation_status 命中 statuses。"""
|
||||
return exists().where(
|
||||
and_(
|
||||
eq_unicode(BizResearchDonation.id, BizQcWorkOrder.collection_protocol_id),
|
||||
BizResearchDonation.del_flag == 0,
|
||||
BizResearchDonation.donation_status.in_(statuses),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def apply_research_donation_protocol_status_filter(
|
||||
query,
|
||||
protocol_status: Optional[str],
|
||||
*,
|
||||
reception_source: Optional[str] = None,
|
||||
force_research_donation_scope: bool = False,
|
||||
):
|
||||
"""科研捐赠质检:按协议/捐赠状态过滤。
|
||||
|
||||
- force_research_donation_scope=True:用于全量 Tab 统计,仅剔除 RD 废弃行,其它来源不动。
|
||||
- 否则仅当 reception_source == research_donation 时生效。
|
||||
"""
|
||||
rs = (reception_source or "").strip()
|
||||
if force_research_donation_scope:
|
||||
# 角标与列表默认一致:科研捐赠仅计待生效/生效中
|
||||
not_default = and_(
|
||||
BizQcWorkOrder.reception_source == "research_donation",
|
||||
~_research_donation_status_exists(list(DEFAULT_QC_DONATION_PROTOCOL_STATUSES)),
|
||||
)
|
||||
return query.filter(~not_default)
|
||||
|
||||
if rs != "research_donation":
|
||||
return query
|
||||
|
||||
mode, statuses = resolve_research_donation_protocol_status_filter(protocol_status)
|
||||
if mode == "none":
|
||||
return query.filter(BizQcWorkOrder.id.in_([]))
|
||||
if mode == "in":
|
||||
return query.filter(_research_donation_status_exists(statuses))
|
||||
# default:与前端默认勾选一致
|
||||
return query.filter(
|
||||
_research_donation_status_exists(list(DEFAULT_QC_DONATION_PROTOCOL_STATUSES))
|
||||
)
|
||||
|
||||
|
||||
def list_work_orders(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -1578,6 +1683,7 @@ def list_work_orders(
|
||||
reception_source: Optional[str] = None,
|
||||
trigger_source: Optional[str] = None,
|
||||
sample_type_id: Optional[str] = None,
|
||||
protocol_status: Optional[str] = None,
|
||||
create_time_start: Optional[str] = None,
|
||||
create_time_end: Optional[str] = None,
|
||||
infusion_time_start: Optional[str] = None,
|
||||
@@ -1708,6 +1814,11 @@ def list_work_orders(
|
||||
if user_info:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
q0 = apply_data_scope(q0, BizQcWorkOrder, user_info, db, tenant_no)
|
||||
q0 = apply_research_donation_protocol_status_filter(
|
||||
q0,
|
||||
protocol_status,
|
||||
reception_source=rs,
|
||||
)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizQcWorkOrder.id)).scalar() or 0
|
||||
@@ -1716,6 +1827,8 @@ def list_work_orders(
|
||||
q_page = _filtered()
|
||||
if uses_inbound_lab_storage_list_sort(reception_source):
|
||||
q_page = q_page.order_by(*inbound_lab_storage_order_by_clauses())
|
||||
elif uses_temporary_production_cluster_list_sort(work_order_type):
|
||||
q_page = apply_temporary_production_cluster_order(q_page)
|
||||
else:
|
||||
q_page = q_page.order_by(BizQcWorkOrder.create_time.desc())
|
||||
rows = (
|
||||
@@ -1941,6 +2054,9 @@ def count_unfinished_work_orders_by_tab(
|
||||
BizQcWorkOrder.tenant_no == tenant_no,
|
||||
)
|
||||
q = _unfinished_work_order_filter(q)
|
||||
q = apply_research_donation_protocol_status_filter(
|
||||
q, None, force_research_donation_scope=True
|
||||
)
|
||||
if user_info:
|
||||
from app.core.data_scope import apply_data_scope
|
||||
|
||||
@@ -1997,6 +2113,9 @@ def count_mine_unfinished_work_orders_by_tab(
|
||||
)
|
||||
)
|
||||
q = _unfinished_work_order_filter(q)
|
||||
q = apply_research_donation_protocol_status_filter(
|
||||
q, None, force_research_donation_scope=True
|
||||
)
|
||||
rows = (
|
||||
q.group_by(
|
||||
BizQcWorkOrder.work_order_type,
|
||||
@@ -3215,9 +3334,9 @@ def list_temp_qc_work_orders(
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizQcWorkOrder.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
q_page = apply_temporary_production_cluster_order(_filtered())
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizQcWorkOrder.create_time.desc())
|
||||
q_page
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
@@ -3422,6 +3541,8 @@ def build_outbound_pilot_context_by_schedule(
|
||||
"infusionMethod": sched.infusion_method or "",
|
||||
"cellType": sched.cell_type or "",
|
||||
"specText": sched.spec_text or "",
|
||||
"packageId": getattr(sched, "package_id", None) or "",
|
||||
"packageText": getattr(sched, "package_text", None) or "",
|
||||
"scheduleStatus": sched.schedule_status or "",
|
||||
"infusionStatus": sched.infusion_status or "",
|
||||
"qcStatus": sched.qc_status or "",
|
||||
@@ -3510,6 +3631,8 @@ def build_outbound_pilot_context_by_schedule(
|
||||
"projectName": p.project_name or "",
|
||||
"cellTypeName": p.cell_type_name or "",
|
||||
"specText": p.spec_text or "",
|
||||
"packageId": getattr(p, "package_id", None) or "",
|
||||
"packageText": getattr(p, "package_text", None) or "",
|
||||
"agreementPrice": _dec(p.agreement_price),
|
||||
"actualPrice": _dec(p.actual_price),
|
||||
"quantity": p.quantity,
|
||||
|
||||
@@ -11,17 +11,22 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.protocol_status_display import assert_protocol_status_transition
|
||||
from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.crud.base import camel_to_snake
|
||||
from app.crud.biz_no_generator import allocate_next_biz_no
|
||||
from app.crud.collection_protocol import (
|
||||
append_protocol_status_change,
|
||||
attachments_to_urls,
|
||||
urls_to_attachment_list,
|
||||
validate_persisted_sample_codes_immutable,
|
||||
validate_sample_codes_not_used,
|
||||
_format_sample_collect_summary,
|
||||
_load_express_name_map,
|
||||
_load_sample_type_name_map,
|
||||
_load_site_name_map,
|
||||
_normalize_yyyymmdd_date,
|
||||
_owned_child_row,
|
||||
_retarget_protocol_sample_id,
|
||||
)
|
||||
from app.crud.protocol_donation_convert import (
|
||||
assert_biz_no_available,
|
||||
@@ -44,6 +49,8 @@ from app.models.models import (
|
||||
BizProtocolPartyCustomer,
|
||||
BizProtocolSample,
|
||||
BizProtocolSampleTransport,
|
||||
BizProductionWorkOrder,
|
||||
BizQcSmsNotification,
|
||||
BizQcWorkOrder,
|
||||
BizSampleReception,
|
||||
SysUser,
|
||||
@@ -1156,7 +1163,7 @@ def create_donation(
|
||||
donation_no=donation_no,
|
||||
monthly_verify_no=monthly_verify_no,
|
||||
donation_status=(raw.get("donation_status") or "dsx").strip(),
|
||||
donation_date=(raw.get("donation_date") or "").strip() or None,
|
||||
donation_date=_normalize_yyyymmdd_date(raw.get("donation_date")),
|
||||
source_type=(raw.get("source_type") or "direct").strip(),
|
||||
source_protocol_id=(raw.get("source_protocol_id") or "").strip() or None,
|
||||
consent_form_urls=consent or None,
|
||||
@@ -1247,7 +1254,7 @@ def update_donation(
|
||||
commit: bool = True,
|
||||
sync_customers: bool = True,
|
||||
) -> BizResearchDonation:
|
||||
"""更新科研捐赠单:主表字段 + 子资源 upsert(软删旧 + 创建新)。"""
|
||||
"""更新科研捐赠单:主表字段就地更新,子资源按行同步。"""
|
||||
obj = (
|
||||
db.query(BizResearchDonation)
|
||||
.filter(
|
||||
@@ -1287,7 +1294,10 @@ def update_donation(
|
||||
val = raw[k]
|
||||
if val is None:
|
||||
continue
|
||||
setattr(obj, k, (val or "").strip() or None)
|
||||
if k == "donation_date":
|
||||
setattr(obj, k, _normalize_yyyymmdd_date(val))
|
||||
else:
|
||||
setattr(obj, k, (val or "").strip() or None)
|
||||
for k in _MAIN_DT_FIELDS:
|
||||
if k in raw:
|
||||
val = raw[k]
|
||||
@@ -1300,11 +1310,9 @@ def update_donation(
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
|
||||
# party upsert: 软删旧 → 创建新
|
||||
if "party" in raw:
|
||||
from app.services.party_customer_sync import sync_party_customers
|
||||
|
||||
_soft_delete_party(db, donation_id, tenant_no, user_id)
|
||||
party_raw = _normalize_fields(raw["party"] or {})
|
||||
if sync_customers:
|
||||
party_raw = sync_party_customers(
|
||||
@@ -1320,9 +1328,7 @@ def update_donation(
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
|
||||
# collection upsert
|
||||
if "collection" in raw:
|
||||
_soft_delete_collection(db, donation_id, tenant_no, user_id)
|
||||
coll_raw = _normalize_fields(raw["collection"] or {})
|
||||
_create_collection(
|
||||
db, donation_id, tenant_no, user_id, coll_raw, now,
|
||||
@@ -1332,18 +1338,18 @@ def update_donation(
|
||||
# samples upsert
|
||||
if "samples" in raw:
|
||||
existing_samples = list_samples(db, donation_id, tenant_no)
|
||||
samples_raw = raw["samples"] or []
|
||||
validate_persisted_sample_codes_immutable(existing_samples, samples_raw)
|
||||
old_id_to_code = {
|
||||
s.id: (s.sample_code or "").strip()
|
||||
for s in existing_samples
|
||||
if (s.sample_code or "").strip()
|
||||
}
|
||||
_soft_delete_samples(db, donation_id, tenant_no, user_id)
|
||||
_soft_delete_transports(db, donation_id, tenant_no, user_id)
|
||||
samples_raw = raw["samples"] or []
|
||||
new_samples = _create_samples(
|
||||
db, donation_id, tenant_no, user_id, samples_raw, now,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
kept_ids = {s.id for s in new_samples}
|
||||
new_code_to_id = {
|
||||
(s.sample_code or "").strip(): s.id
|
||||
for s in new_samples
|
||||
@@ -1352,7 +1358,9 @@ def update_donation(
|
||||
_migrate_donation_sample_child_refs_on_replace(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
old_id_to_code=old_id_to_code,
|
||||
old_id_to_code={
|
||||
sid: code for sid, code in old_id_to_code.items() if sid not in kept_ids
|
||||
},
|
||||
new_code_to_id=new_code_to_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
@@ -2220,6 +2228,26 @@ _PARTY_DATE_FIELDS = ["expected_delivery_date"]
|
||||
_PARTY_DT_FIELDS = ["newborn_delivery_time"]
|
||||
|
||||
|
||||
def _apply_single_child_fields(obj, raw, str_fields, int_fields=(), dec_fields=(), date_fields=(), dt_fields=()):
|
||||
obj.remark = (raw.get("remark") or "").strip() or None
|
||||
for key in str_fields:
|
||||
setattr(obj, key, ((raw.get(key) or "").strip() or None) if key in raw else None)
|
||||
for key in int_fields:
|
||||
setattr(obj, key, raw.get(key) if key in raw else None)
|
||||
for key in dec_fields:
|
||||
setattr(obj, key, _optional_decimal(raw.get(key)) if key in raw else None)
|
||||
for key in date_fields:
|
||||
setattr(obj, key, optional_date(raw.get(key)) if key in raw else None)
|
||||
for key in dt_fields:
|
||||
if key not in raw:
|
||||
setattr(obj, key, None)
|
||||
continue
|
||||
val = raw.get(key)
|
||||
if isinstance(val, str):
|
||||
val = parse_datetime(val)
|
||||
setattr(obj, key, val)
|
||||
|
||||
|
||||
def _create_party(
|
||||
db: Session,
|
||||
donation_id: str,
|
||||
@@ -2229,40 +2257,46 @@ def _create_party(
|
||||
now: datetime,
|
||||
create_dept_id: str = "",
|
||||
) -> BizResearchDonationParty:
|
||||
obj = BizResearchDonationParty(
|
||||
id=generate_uuid(),
|
||||
research_donation_id=donation_id,
|
||||
def build_row(row_id):
|
||||
return BizResearchDonationParty(
|
||||
id=row_id,
|
||||
research_donation_id=donation_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row, item, _idx, _now):
|
||||
row.order_num = 0
|
||||
_apply_single_child_fields(
|
||||
row,
|
||||
item,
|
||||
_PARTY_STR_FIELDS,
|
||||
_PARTY_INT_FIELDS,
|
||||
_PARTY_DEC_FIELDS,
|
||||
_PARTY_DATE_FIELDS,
|
||||
_PARTY_DT_FIELDS,
|
||||
)
|
||||
|
||||
rows = sync_child_rows(
|
||||
db,
|
||||
BizResearchDonationParty,
|
||||
parent_attr="research_donation_id",
|
||||
parent_id=donation_id,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=(raw.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
items=[raw or {}],
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda _item: "party",
|
||||
row_natural_key=lambda _row: "party",
|
||||
duplicate_message="当事人信息重复",
|
||||
foreign_message="当事人信息不属于当前捐赠单",
|
||||
)
|
||||
for k in _PARTY_STR_FIELDS:
|
||||
if k in raw:
|
||||
setattr(obj, k, (raw[k] or "").strip() or None)
|
||||
for k in _PARTY_INT_FIELDS:
|
||||
if k in raw:
|
||||
setattr(obj, k, raw[k])
|
||||
for k in _PARTY_DEC_FIELDS:
|
||||
if k in raw:
|
||||
setattr(obj, k, _optional_decimal(raw[k]))
|
||||
for k in _PARTY_DATE_FIELDS:
|
||||
if k in raw:
|
||||
setattr(obj, k, optional_date(raw[k]))
|
||||
for k in _PARTY_DT_FIELDS:
|
||||
if k in raw:
|
||||
val = raw[k]
|
||||
if isinstance(val, str):
|
||||
val = parse_datetime(val)
|
||||
setattr(obj, k, val)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
return obj
|
||||
return rows[0]
|
||||
|
||||
|
||||
_COLL_STR_FIELDS = [
|
||||
@@ -2284,31 +2318,43 @@ def _create_collection(
|
||||
now: datetime,
|
||||
create_dept_id: str = "",
|
||||
) -> BizResearchDonationCollection:
|
||||
obj = BizResearchDonationCollection(
|
||||
id=generate_uuid(),
|
||||
research_donation_id=donation_id,
|
||||
def build_row(row_id):
|
||||
return BizResearchDonationCollection(
|
||||
id=row_id,
|
||||
research_donation_id=donation_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row, item, _idx, _now):
|
||||
row.order_num = 0
|
||||
_apply_single_child_fields(
|
||||
row,
|
||||
item,
|
||||
_COLL_STR_FIELDS,
|
||||
dt_fields=_COLL_DT_FIELDS,
|
||||
)
|
||||
|
||||
rows = sync_child_rows(
|
||||
db,
|
||||
BizResearchDonationCollection,
|
||||
parent_attr="research_donation_id",
|
||||
parent_id=donation_id,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=(raw.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
items=[raw or {}],
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda _item: "collection",
|
||||
row_natural_key=lambda _row: "collection",
|
||||
duplicate_message="采集信息重复",
|
||||
foreign_message="采集信息不属于当前捐赠单",
|
||||
)
|
||||
for k in _COLL_STR_FIELDS:
|
||||
if k in raw:
|
||||
setattr(obj, k, (raw[k] or "").strip() or None)
|
||||
for k in _COLL_DT_FIELDS:
|
||||
if k in raw:
|
||||
val = raw[k]
|
||||
if isinstance(val, str):
|
||||
val = parse_datetime(val)
|
||||
setattr(obj, k, val)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
return obj
|
||||
return rows[0]
|
||||
|
||||
|
||||
_SAMPLE_STR_FIELDS = [
|
||||
@@ -2375,6 +2421,55 @@ def _migrate_donation_sample_child_refs_on_replace(
|
||||
row.update_time = now
|
||||
db.add(row)
|
||||
|
||||
_retarget_protocol_sample_id(
|
||||
db,
|
||||
BizProductionWorkOrder,
|
||||
old_ids=old_ids,
|
||||
old_id_to_code=old_id_to_code,
|
||||
new_code_to_id=new_code_to_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
)
|
||||
_retarget_protocol_sample_id(
|
||||
db,
|
||||
BizQcSmsNotification,
|
||||
old_ids=old_ids,
|
||||
old_id_to_code=old_id_to_code,
|
||||
new_code_to_id=new_code_to_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
del_flag_alive="0",
|
||||
)
|
||||
|
||||
|
||||
def _apply_donation_sample_fields(obj, raw, *, order_num, user_id, now) -> None:
|
||||
obj.order_num = order_num
|
||||
obj.remark = (raw.get("remark") or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = now
|
||||
obj.del_flag = 0
|
||||
for key in _SAMPLE_STR_FIELDS:
|
||||
if key == "sample_status":
|
||||
continue
|
||||
if key in raw:
|
||||
setattr(obj, key, (raw[key] or "").strip() or None)
|
||||
if "sample_status" in raw:
|
||||
status_val = raw.get("sample_status")
|
||||
obj.sample_status = (
|
||||
str(status_val).strip()
|
||||
if status_val is not None and str(status_val).strip()
|
||||
else None
|
||||
)
|
||||
for key in _SAMPLE_DEC_FIELDS:
|
||||
if key in raw and raw[key] is not None:
|
||||
setattr(obj, key, Decimal(str(raw[key])))
|
||||
for key in _SAMPLE_INT_FIELDS:
|
||||
if key in raw:
|
||||
setattr(obj, key, raw[key])
|
||||
apply_sample_special_fields(obj, raw)
|
||||
|
||||
|
||||
def _create_samples(
|
||||
db: Session,
|
||||
@@ -2385,19 +2480,62 @@ def _create_samples(
|
||||
now: datetime,
|
||||
create_dept_id: str = "",
|
||||
) -> List[BizResearchDonationSample]:
|
||||
existing = list_samples(db, donation_id, tenant_no)
|
||||
by_id = {sample.id: sample for sample in existing}
|
||||
by_code: Dict[str, List[BizResearchDonationSample]] = {}
|
||||
for sample in existing:
|
||||
code = (sample.sample_code or "").strip()
|
||||
if code:
|
||||
by_code.setdefault(code, []).append(sample)
|
||||
claimed = set()
|
||||
planned = []
|
||||
for idx, raw_item in enumerate(samples or []):
|
||||
raw = _normalize_fields(raw_item)
|
||||
validate_sample_special_raw(raw, index=idx)
|
||||
sid = (raw.get("id") or "").strip()
|
||||
code = (raw.get("sample_code") or "").strip()
|
||||
row = by_id.get(sid) if sid else None
|
||||
if row is None and sid:
|
||||
row = _owned_child_row(
|
||||
db,
|
||||
BizResearchDonationSample,
|
||||
sid,
|
||||
parent_attr="research_donation_id",
|
||||
parent_id=donation_id,
|
||||
tenant_no=tenant_no,
|
||||
message="样本不属于当前捐赠单",
|
||||
)
|
||||
if row is None and code:
|
||||
for candidate in by_code.get(code, []):
|
||||
if candidate.id not in claimed:
|
||||
row = candidate
|
||||
break
|
||||
if row is not None:
|
||||
if row.id in claimed:
|
||||
raise ValueError("样本编号重复")
|
||||
claimed.add(row.id)
|
||||
planned.append((idx, raw, row))
|
||||
kept_codes = {
|
||||
(sample.sample_code or "").strip()
|
||||
for sample in existing
|
||||
if sample.id in claimed and (sample.sample_code or "").strip()
|
||||
}
|
||||
validate_sample_codes_not_used(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
samples=samples,
|
||||
samples=[
|
||||
raw
|
||||
for _idx, raw, row in planned
|
||||
if row is None or (raw.get("sample_code") or "").strip() not in kept_codes
|
||||
],
|
||||
exclude_donation_id=donation_id,
|
||||
)
|
||||
result = []
|
||||
assigned_codes: List[str] = []
|
||||
for idx, raw_item in enumerate(samples or []):
|
||||
raw = _normalize_fields(raw_item)
|
||||
validate_sample_special_raw(raw, index=idx)
|
||||
kept_transport_ids = set()
|
||||
for idx, raw, obj in planned:
|
||||
code = (raw.get("sample_code") or "").strip()
|
||||
if not code and (raw.get("sample_type_id") or "").strip():
|
||||
if obj is None and not code and (raw.get("sample_type_id") or "").strip():
|
||||
code = next_donation_sample_code(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
@@ -2406,43 +2544,24 @@ def _create_samples(
|
||||
raw["sample_code"] = code
|
||||
if code:
|
||||
assigned_codes.append(code)
|
||||
obj = BizResearchDonationSample(
|
||||
id=generate_uuid(),
|
||||
research_donation_id=donation_id,
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
remark=(raw.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
for k in _SAMPLE_STR_FIELDS:
|
||||
if k == "sample_status":
|
||||
continue
|
||||
if k in raw:
|
||||
setattr(obj, k, (raw[k] or "").strip() or None)
|
||||
if "sample_status" in raw:
|
||||
status_val = raw.get("sample_status")
|
||||
obj.sample_status = (
|
||||
str(status_val).strip()
|
||||
if status_val is not None and str(status_val).strip()
|
||||
else None
|
||||
if obj is None:
|
||||
obj = BizResearchDonationSample(
|
||||
id=(raw.get("id") or "").strip() or generate_uuid(),
|
||||
research_donation_id=donation_id,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=now,
|
||||
)
|
||||
for k in _SAMPLE_DEC_FIELDS:
|
||||
if k in raw and raw[k] is not None:
|
||||
setattr(obj, k, Decimal(str(raw[k])))
|
||||
for k in _SAMPLE_INT_FIELDS:
|
||||
if k in raw:
|
||||
setattr(obj, k, raw[k])
|
||||
apply_sample_special_fields(obj, raw)
|
||||
_apply_donation_sample_fields(
|
||||
obj, raw, order_num=idx, user_id=user_id, now=now
|
||||
)
|
||||
obj.research_donation_id = donation_id
|
||||
obj.tenant_no = tenant_no
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
result.append(obj)
|
||||
|
||||
# 预计到达等仍写运输表;运费由 apply_freight_on_protocol_save 挂钩
|
||||
transport_raw = raw.get("transport") if isinstance(raw.get("transport"), dict) else {}
|
||||
if not transport_raw and raw.get("eta_time"):
|
||||
transport_raw = {"eta_time": raw.get("eta_time")}
|
||||
@@ -2451,7 +2570,7 @@ def _create_samples(
|
||||
):
|
||||
transport_raw = {**transport_raw, "eta_time": raw.get("eta_time")}
|
||||
if transport_raw:
|
||||
_create_transport(
|
||||
transport_row = _create_transport(
|
||||
db,
|
||||
donation_id,
|
||||
obj.id,
|
||||
@@ -2462,7 +2581,31 @@ def _create_samples(
|
||||
now,
|
||||
sample_code=obj.sample_code,
|
||||
)
|
||||
if transport_row is not None:
|
||||
kept_transport_ids.add(transport_row.id)
|
||||
|
||||
for sample in existing:
|
||||
if sample.id in claimed:
|
||||
continue
|
||||
sample.del_flag = 1
|
||||
sample.update_by = user_id
|
||||
sample.update_time = now
|
||||
db.add(sample)
|
||||
for transport in (
|
||||
db.query(BizResearchDonationSampleTransport)
|
||||
.filter(
|
||||
BizResearchDonationSampleTransport.research_donation_id == donation_id,
|
||||
BizResearchDonationSampleTransport.tenant_no == tenant_no,
|
||||
BizResearchDonationSampleTransport.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
):
|
||||
if transport.id in kept_transport_ids:
|
||||
continue
|
||||
transport.del_flag = 1
|
||||
transport.update_by = user_id
|
||||
transport.update_time = now
|
||||
db.add(transport)
|
||||
return result
|
||||
|
||||
|
||||
@@ -2492,30 +2635,46 @@ def _create_transport(
|
||||
else:
|
||||
express_fee = None
|
||||
|
||||
obj = BizResearchDonationSampleTransport(
|
||||
existing = (
|
||||
db.query(BizResearchDonationSampleTransport)
|
||||
.filter(
|
||||
BizResearchDonationSampleTransport.research_donation_id == donation_id,
|
||||
BizResearchDonationSampleTransport.sample_id == sample_id,
|
||||
BizResearchDonationSampleTransport.tenant_no == tenant_no,
|
||||
BizResearchDonationSampleTransport.del_flag == 0,
|
||||
)
|
||||
.order_by(BizResearchDonationSampleTransport.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
obj = existing[0] if existing else BizResearchDonationSampleTransport(
|
||||
id=generate_uuid(),
|
||||
research_donation_id=donation_id,
|
||||
sample_id=sample_id,
|
||||
sample_code=sample_code or None,
|
||||
transport_type=transport_type,
|
||||
express_company_id=(tnorm.get("express_company_id") or "").strip() or None,
|
||||
waybill_no=(tnorm.get("waybill_no") or "").strip() or None,
|
||||
dest_site_id=(tnorm.get("dest_site_id") or "").strip() or None,
|
||||
express_fee=express_fee,
|
||||
eta_time=eta_time,
|
||||
carrier_name=(tnorm.get("carrier_name") or "").strip() or None,
|
||||
carrier_phone=(tnorm.get("carrier_phone") or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num,
|
||||
remark=(tnorm.get("remark") or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
obj.sample_code = sample_code or None
|
||||
obj.transport_type = transport_type
|
||||
obj.express_company_id = (tnorm.get("express_company_id") or "").strip() or None
|
||||
obj.waybill_no = (tnorm.get("waybill_no") or "").strip() or None
|
||||
obj.dest_site_id = (tnorm.get("dest_site_id") or "").strip() or None
|
||||
obj.express_fee = express_fee
|
||||
obj.eta_time = eta_time
|
||||
obj.carrier_name = (tnorm.get("carrier_name") or "").strip() or None
|
||||
obj.carrier_phone = (tnorm.get("carrier_phone") or "").strip() or None
|
||||
obj.order_num = order_num
|
||||
obj.remark = (tnorm.get("remark") or "").strip() or None
|
||||
obj.del_flag = 0
|
||||
obj.update_by = user_id
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
for extra in existing[1:]:
|
||||
extra.del_flag = 1
|
||||
extra.update_by = user_id
|
||||
extra.update_time = now
|
||||
db.add(extra)
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
+49
-43
@@ -9,6 +9,7 @@ from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows, sync_link_rows
|
||||
from app.models.models import (
|
||||
SysPost,
|
||||
SysPostRole,
|
||||
@@ -377,26 +378,18 @@ def update_role_menus(
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> None:
|
||||
# uk_role_menu 为 (role_id, menu_id, del_flag):先全量软删再插入会在再次保存时
|
||||
# 出现两条 (role_id, menu_id, del_flag=1),触发 1062。关联表采用整角色物理清理后重建。
|
||||
unique_ids = list(dict.fromkeys(menu_ids or []))
|
||||
db.query(SysRoleMenu).filter(SysRoleMenu.role_id == role_id).delete(synchronize_session=False)
|
||||
now = datetime.now()
|
||||
for mid in unique_ids:
|
||||
obj = SysRoleMenu(
|
||||
id=generate_uuid(),
|
||||
role_id=role_id,
|
||||
menu_id=mid,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
sync_link_rows(
|
||||
db,
|
||||
SysRoleMenu,
|
||||
parent_attr="role_id",
|
||||
parent_id=role_id,
|
||||
link_attr="menu_id",
|
||||
link_ids=menu_ids,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
duplicate_message="菜单重复",
|
||||
foreign_message="菜单关联不属于当前角色",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -430,28 +423,41 @@ def update_role_data_scope(
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
|
||||
# soft-delete old scope details
|
||||
db.query(SysRoleDataScopeDetail).filter(
|
||||
and_(SysRoleDataScopeDetail.role_id == role_id, SysRoleDataScopeDetail.del_flag == 0)
|
||||
).update({"del_flag": 1, "update_time": datetime.now()})
|
||||
now = datetime.now()
|
||||
details = scope_details if data_scope == "5" and scope_details else []
|
||||
|
||||
# insert new scope details (only when custom data_scope == '5')
|
||||
if data_scope == "5" and scope_details:
|
||||
now = datetime.now()
|
||||
for detail in scope_details:
|
||||
sd = SysRoleDataScopeDetail(
|
||||
id=generate_uuid(),
|
||||
role_id=role_id,
|
||||
scope_type=detail.get("scopeType", "dept"),
|
||||
scope_value=detail.get("scopeValue", ""),
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(sd)
|
||||
def build_scope(row_id):
|
||||
return SysRoleDataScopeDetail(
|
||||
id=row_id,
|
||||
role_id=role_id,
|
||||
scope_type="",
|
||||
scope_value="",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_scope(row, detail, _idx, _now):
|
||||
row.scope_type = detail.get("scopeType", "dept")
|
||||
row.scope_value = detail.get("scopeValue", "")
|
||||
row.order_num = 0
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
SysRoleDataScopeDetail,
|
||||
parent_attr="role_id",
|
||||
parent_id=role_id,
|
||||
tenant_no=tenant_no,
|
||||
items=details,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_scope,
|
||||
apply_fields=apply_scope,
|
||||
natural_key=lambda detail: f"{detail.get('scopeType', 'dept')}:{detail.get('scopeValue', '')}",
|
||||
row_natural_key=lambda row: f"{row.scope_type}:{row.scope_value}",
|
||||
duplicate_message="数据权限重复",
|
||||
foreign_message="数据权限不属于当前角色",
|
||||
reuse_deleted=True,
|
||||
)
|
||||
db.commit()
|
||||
@@ -23,7 +23,7 @@ def _resolve_issued_user_display_name(
|
||||
uid = (user_id or "").strip()
|
||||
if not uid:
|
||||
return None
|
||||
u = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
|
||||
u = db.query(SysUser).filter(SysUser.id == uid).first()
|
||||
if not u:
|
||||
return None
|
||||
return (u.nick_name or u.user_no or "").strip() or None
|
||||
|
||||
@@ -1230,12 +1230,29 @@ def apply_freight_on_protocol_save(
|
||||
if keep_count != stale_count:
|
||||
raise ValueError("已结算的运费不能调整样本")
|
||||
settled_relink_ids.add(freight_id)
|
||||
keep_sample_ids = set()
|
||||
for idx, sample_obj in enumerate(saved_samples or []):
|
||||
if idx >= len(input_samples or []):
|
||||
break
|
||||
raw = input_samples[idx] if isinstance(input_samples[idx], dict) else {}
|
||||
sample_id = str(getattr(sample_obj, "id", None) or "").strip()
|
||||
if not sample_id:
|
||||
continue
|
||||
draft = raw.get("freightDraft")
|
||||
if draft is None:
|
||||
draft = raw.get("freight_draft")
|
||||
if _input_freight_id(raw) or isinstance(draft, dict):
|
||||
keep_sample_ids.add(sample_id)
|
||||
removed_freight_item = False
|
||||
for stale in stale_items:
|
||||
if stale.sample_id in keep_sample_ids:
|
||||
continue
|
||||
stale.del_flag = 1
|
||||
stale.update_by = user_id
|
||||
stale.update_time = now
|
||||
db.add(stale)
|
||||
if stale_items:
|
||||
removed_freight_item = True
|
||||
if removed_freight_item:
|
||||
db.flush()
|
||||
|
||||
draft_groups: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@@ -1131,12 +1131,6 @@ def update_sample_reception(
|
||||
rec.update_by = user_id
|
||||
rec.update_time = datetime.now()
|
||||
|
||||
check_result_svc.soft_delete_reception_check_results(
|
||||
db,
|
||||
reception_id=reception_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
check_result_svc.save_reception_check_results(
|
||||
db,
|
||||
reception_id=reception_id,
|
||||
|
||||
@@ -7,8 +7,8 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud import sample_reception_check_item as check_item_svc
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.models.models import (
|
||||
BizSampleReception,
|
||||
BizSampleReceptionCheckItem,
|
||||
@@ -105,7 +105,7 @@ def soft_delete_reception_check_results(
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
) -> None:
|
||||
"""软删除某次接收的全部检查项结果(编辑接收时先清空再重写)。"""
|
||||
"""软删除某次接收的全部检查项结果。删除接收记录时使用。"""
|
||||
now = datetime.now()
|
||||
(
|
||||
db.query(BizSampleReceptionCheckResult)
|
||||
@@ -143,26 +143,56 @@ def save_reception_check_results(
|
||||
if unknown:
|
||||
raise ValueError("存在无效的检查项")
|
||||
now = datetime.now()
|
||||
payload = []
|
||||
for item in active_items:
|
||||
item_id = str(item["id"])
|
||||
checked = submitted.get(item_id, False)
|
||||
row = BizSampleReceptionCheckResult(
|
||||
id=generate_uuid(),
|
||||
payload.append(
|
||||
{
|
||||
"check_item_id": item_id,
|
||||
"item_code": item["itemCode"],
|
||||
"item_name": item["itemName"],
|
||||
"checked": 1 if submitted.get(item_id, False) else 0,
|
||||
"order_num": item.get("orderNum") or 0,
|
||||
}
|
||||
)
|
||||
|
||||
def build_row(row_id):
|
||||
return BizSampleReceptionCheckResult(
|
||||
id=row_id,
|
||||
reception_id=reception_id,
|
||||
check_item_id=item_id,
|
||||
item_code=item["itemCode"],
|
||||
item_name=item["itemName"],
|
||||
checked=1 if checked else 0,
|
||||
check_item_id="",
|
||||
item_code="",
|
||||
item_name="",
|
||||
checked=0,
|
||||
tenant_no=tenant_no,
|
||||
order_num=item.get("orderNum") or 0,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
def apply_row(row, item, _idx, _now):
|
||||
row.check_item_id = item["check_item_id"]
|
||||
row.item_code = item["item_code"]
|
||||
row.item_name = item["item_name"]
|
||||
row.checked = item["checked"]
|
||||
row.order_num = item["order_num"]
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizSampleReceptionCheckResult,
|
||||
parent_attr="reception_id",
|
||||
parent_id=reception_id,
|
||||
tenant_no=tenant_no,
|
||||
items=payload,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: item["check_item_id"],
|
||||
row_natural_key=lambda row: row.check_item_id or "",
|
||||
duplicate_message="检查项重复",
|
||||
foreign_message="检查结果不属于当前接收记录",
|
||||
)
|
||||
|
||||
|
||||
def sync_legacy_columns_from_results(
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any, Dict, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud import sample_reception as reception_svc
|
||||
from app.models.models import (
|
||||
BizCellSampleType,
|
||||
BizCollectionProtocol,
|
||||
@@ -21,11 +22,13 @@ from app.services.sample_storage_cert_qc import (
|
||||
normalize_payload,
|
||||
resolve_sample_type_key,
|
||||
)
|
||||
from app.services.sample_storage_cert_qc_prefill import collect_prefill
|
||||
|
||||
|
||||
def _row_to_dict(
|
||||
row: BizSampleStorageCertQc,
|
||||
*,
|
||||
lab_flow_code: str = "",
|
||||
protocol_code: str = "",
|
||||
mother_name: str = "",
|
||||
sample_type_name: str = "",
|
||||
@@ -33,7 +36,7 @@ def _row_to_dict(
|
||||
return {
|
||||
"id": row.id,
|
||||
"sampleCode": row.sample_code or "",
|
||||
"labFlowCode": row.sample_code or "",
|
||||
"labFlowCode": lab_flow_code or "",
|
||||
"protocolCode": protocol_code or "",
|
||||
"motherName": mother_name or "",
|
||||
"sampleTypeName": sample_type_name or "",
|
||||
@@ -99,15 +102,30 @@ def _load_sample_context(db: Session, tenant_no: str, sample_code: str):
|
||||
)
|
||||
if pc:
|
||||
mother_name = pc.mother_name or ""
|
||||
return sample, type_name, protocol_code, mother_name
|
||||
lab_flow_code = ""
|
||||
sc = (sample.sample_code or "").strip()
|
||||
if sc:
|
||||
code_map = reception_svc.get_lab_storage_code_map_by_sample_codes(db, tenant_no, [sc])
|
||||
lab_flow_code = code_map.get(sc, "") or ""
|
||||
return sample, type_name, protocol_code, mother_name, lab_flow_code
|
||||
|
||||
|
||||
def _with_prefill(db: Session, tenant_no: str, detail: Dict[str, Any]) -> Dict[str, Any]:
|
||||
detail["prefill"] = collect_prefill(
|
||||
db,
|
||||
tenant_no,
|
||||
detail.get("sampleCode") or "",
|
||||
detail.get("sampleTypeKey"),
|
||||
)
|
||||
return detail
|
||||
|
||||
|
||||
def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[str, Any]]:
|
||||
"""样本不存在返回 None;存在但未填返回 empty_detail。"""
|
||||
"""样本不存在返回 None;存在但未填返回 empty_detail。prefill 只补空,不写入已存档 payload。"""
|
||||
ctx = _load_sample_context(db, tenant_no, sample_code)
|
||||
if not ctx:
|
||||
return None
|
||||
sample, type_name, protocol_code, mother_name = ctx
|
||||
sample, type_name, protocol_code, mother_name, lab_flow_code = ctx
|
||||
key = resolve_sample_type_key(type_name)
|
||||
row = (
|
||||
db.query(BizSampleStorageCertQc)
|
||||
@@ -119,20 +137,24 @@ def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[s
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
return empty_detail(
|
||||
detail = empty_detail(
|
||||
sample_code=sample.sample_code or "",
|
||||
lab_flow_code=lab_flow_code,
|
||||
protocol_code=protocol_code,
|
||||
mother_name=mother_name,
|
||||
sample_type_name=type_name,
|
||||
sample_type_key=key,
|
||||
collection_protocol_id=sample.collection_protocol_id or "",
|
||||
)
|
||||
return _row_to_dict(
|
||||
return _with_prefill(db, tenant_no, detail)
|
||||
detail = _row_to_dict(
|
||||
row,
|
||||
lab_flow_code=lab_flow_code,
|
||||
protocol_code=protocol_code,
|
||||
mother_name=mother_name,
|
||||
sample_type_name=type_name,
|
||||
)
|
||||
return _with_prefill(db, tenant_no, detail)
|
||||
|
||||
|
||||
def upsert(
|
||||
@@ -147,7 +169,7 @@ def upsert(
|
||||
ctx = _load_sample_context(db, tenant_no, sample_code)
|
||||
if not ctx:
|
||||
raise ValueError("样本不存在")
|
||||
sample, type_name, protocol_code, mother_name = ctx
|
||||
sample, type_name, protocol_code, mother_name, lab_flow_code = ctx
|
||||
key = resolve_sample_type_key(type_name)
|
||||
if not key:
|
||||
raise ValueError("该样本类型不支持填写储存证质检信息")
|
||||
@@ -193,6 +215,7 @@ def upsert(
|
||||
db.refresh(row)
|
||||
return _row_to_dict(
|
||||
row,
|
||||
lab_flow_code=lab_flow_code,
|
||||
protocol_code=protocol_code,
|
||||
mother_name=mother_name,
|
||||
sample_type_name=type_name,
|
||||
|
||||
@@ -741,6 +741,30 @@ def _search_slot_select_matched_ids(
|
||||
return matched_ids
|
||||
|
||||
|
||||
def search_slot_ids_by_keyword(
|
||||
db: Session,
|
||||
tenant_no: str,
|
||||
keyword: str,
|
||||
*,
|
||||
max_results: int = SLOT_SELECT_KEYWORD_SCAN_LIMIT,
|
||||
) -> List[str]:
|
||||
"""按路径/格位编码关键词解析储物格 ID(供细胞产品列表等跨实体检索复用)。"""
|
||||
kw = (keyword or "").strip()
|
||||
if not kw or max_results <= 0:
|
||||
return []
|
||||
base = db.query(BizStorageFacilitySlot).filter(
|
||||
BizStorageFacilitySlot.del_flag == 0,
|
||||
BizStorageFacilitySlot.tenant_no == tenant_no,
|
||||
)
|
||||
return _search_slot_select_matched_ids(
|
||||
db,
|
||||
base,
|
||||
tenant_no,
|
||||
kw,
|
||||
max_results=max_results,
|
||||
)
|
||||
|
||||
|
||||
def _apply_slot_select_query_filters(
|
||||
db: Session,
|
||||
query,
|
||||
|
||||
+39
-66
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_link_rows
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.models import SysUser, SysUserRole, SysUserPost, SysDept
|
||||
|
||||
@@ -179,29 +180,19 @@ def set_user_post_ids_only(
|
||||
post_ids: List[str],
|
||||
updater_id: Optional[str],
|
||||
) -> None:
|
||||
"""仅替换 sys_user_post,不影响 sys_user_role。"""
|
||||
now = datetime.now()
|
||||
uniq = [p for p in dict.fromkeys(post_ids or []) if p]
|
||||
db.query(SysUserPost).filter(
|
||||
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
|
||||
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
|
||||
for pid in uniq:
|
||||
db.add(
|
||||
SysUserPost(
|
||||
id=generate_uuid(),
|
||||
user_id=user_id,
|
||||
post_id=pid,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=updater_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=updater_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
"""按职位更新 sys_user_post,不影响 sys_user_role。"""
|
||||
sync_link_rows(
|
||||
db,
|
||||
SysUserPost,
|
||||
parent_attr="user_id",
|
||||
parent_id=user_id,
|
||||
link_attr="post_id",
|
||||
link_ids=post_ids,
|
||||
tenant_no=tenant_no,
|
||||
user_id=updater_id,
|
||||
duplicate_message="职位重复",
|
||||
foreign_message="职位关联不属于当前用户",
|
||||
)
|
||||
|
||||
|
||||
def list_user_ids_bound_to_post(db: Session, post_id: str, tenant_no: str) -> List[str]:
|
||||
@@ -514,51 +505,33 @@ def _replace_associations(
|
||||
role_ids: Optional[List[str]] = None,
|
||||
post_ids: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""全量替换关联。仅当对应参数不为 None 时才替换该侧(避免只改职位时清空直接角色)。"""
|
||||
now = datetime.now()
|
||||
"""按关联 id 更新。仅当对应参数不为 None 时才替换该侧(避免只改职位时清空直接角色)。"""
|
||||
if role_ids is not None:
|
||||
db.query(SysUserRole).filter(
|
||||
and_(SysUserRole.user_id == user_id, SysUserRole.del_flag == 0)
|
||||
).update({"del_flag": 1, "update_time": now})
|
||||
for rid in role_ids:
|
||||
if not rid:
|
||||
continue
|
||||
db.add(SysUserRole(
|
||||
id=generate_uuid(),
|
||||
user_id=user_id,
|
||||
role_id=rid,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=creator_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=creator_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
))
|
||||
|
||||
sync_link_rows(
|
||||
db,
|
||||
SysUserRole,
|
||||
parent_attr="user_id",
|
||||
parent_id=user_id,
|
||||
link_attr="role_id",
|
||||
link_ids=role_ids,
|
||||
tenant_no=tenant_no,
|
||||
user_id=creator_id,
|
||||
duplicate_message="角色重复",
|
||||
foreign_message="角色关联不属于当前用户",
|
||||
)
|
||||
if post_ids is not None:
|
||||
db.query(SysUserPost).filter(
|
||||
and_(SysUserPost.user_id == user_id, SysUserPost.del_flag == 0)
|
||||
).update({"del_flag": 1, "update_time": now})
|
||||
for pid in post_ids:
|
||||
if not pid:
|
||||
continue
|
||||
db.add(SysUserPost(
|
||||
id=generate_uuid(),
|
||||
user_id=user_id,
|
||||
post_id=pid,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=None,
|
||||
create_by=creator_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=creator_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
))
|
||||
sync_link_rows(
|
||||
db,
|
||||
SysUserPost,
|
||||
parent_attr="user_id",
|
||||
parent_id=user_id,
|
||||
link_attr="post_id",
|
||||
link_ids=post_ids,
|
||||
tenant_no=tenant_no,
|
||||
user_id=creator_id,
|
||||
duplicate_message="职位重复",
|
||||
foreign_message="职位关联不属于当前用户",
|
||||
)
|
||||
|
||||
|
||||
def _parse_hire_date(value: Optional[Union[str, date]]) -> Optional[date]:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""历史单据上的人员姓名。
|
||||
|
||||
用户管理里删除人员只会把 sys_user.del_flag 置为 1,账号不能再登录,
|
||||
也不能再被选为新的负责人。已经写在工单、步骤、协议上的 user_id
|
||||
仍要显示 nick_name;查不到人时调用方再回退成 id。
|
||||
|
||||
选人、校验、登录不要用本模块。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import SysUser
|
||||
|
||||
|
||||
def user_display_name_map(db: Session, user_ids: Iterable[str]) -> Dict[str, str]:
|
||||
"""按 user_id 取展示名,包含已删除用户。优先昵称,否则账号。"""
|
||||
ids: List[str] = []
|
||||
seen = set()
|
||||
for raw in user_ids or []:
|
||||
uid = str(raw or "").strip()
|
||||
if not uid or uid in seen:
|
||||
continue
|
||||
seen.add(uid)
|
||||
ids.append(uid)
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
|
||||
.filter(SysUser.id.in_(ids))
|
||||
.all()
|
||||
)
|
||||
out: Dict[str, str] = {}
|
||||
for row in rows:
|
||||
name = (row.nick_name or "").strip() or (row.user_no or "").strip()
|
||||
if name:
|
||||
out[row.id] = name
|
||||
return out
|
||||
+2
-1
@@ -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"""
|
||||
|
||||
|
||||
@@ -31,6 +31,43 @@ MarketContractAmount = condecimal(
|
||||
)
|
||||
MarketSampleTypeId = constr(min_length=1, max_length=64)
|
||||
|
||||
_CUSTOMER_INT_LABELS = {
|
||||
'birth_order': '胎次',
|
||||
'newborn_weight_gram': '新生儿体重',
|
||||
}
|
||||
|
||||
|
||||
def _blank_to_none(value):
|
||||
"""移动端数字框清空后会提交 "",可选数字应视为未填。"""
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
return None if text == '' else text
|
||||
return value
|
||||
|
||||
|
||||
def _optional_customer_int(value, field_name: str):
|
||||
if value is None or isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
label = _CUSTOMER_INT_LABELS[field_name]
|
||||
if isinstance(value, float):
|
||||
if value.is_integer():
|
||||
return int(value)
|
||||
raise ValueError(f'{label}须为整数')
|
||||
if isinstance(value, str):
|
||||
text = value.strip().replace(',', '')
|
||||
if text == '':
|
||||
return None
|
||||
if re.fullmatch(r'[+-]?\d+\.?', text):
|
||||
return int(text.rstrip('.'))
|
||||
if re.fullmatch(r'[+-]?\d+\.\d+', text):
|
||||
number = Decimal(text)
|
||||
if number == number.to_integral_value():
|
||||
return int(number)
|
||||
raise ValueError(f'{label}须为整数')
|
||||
return value
|
||||
|
||||
|
||||
class MarketAttachmentDownloadBody(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
||||
@@ -193,6 +230,16 @@ class MarketProtocolCustomerBody(BaseModel):
|
||||
collector_user_id: Optional[str] = Field(None, alias='collectorUserId')
|
||||
remark: Optional[str] = None
|
||||
|
||||
@field_validator('birth_order', 'newborn_weight_gram', mode='before')
|
||||
@classmethod
|
||||
def normalize_optional_int(cls, value, info):
|
||||
return _optional_customer_int(value, info.field_name)
|
||||
|
||||
@field_validator('apgar_1min', 'apgar_5min', mode='before')
|
||||
@classmethod
|
||||
def normalize_optional_float(cls, value):
|
||||
return _blank_to_none(value)
|
||||
|
||||
|
||||
class MarketProtocolCollectionBody(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
||||
@@ -390,11 +437,17 @@ class MarketProtocolPaymentDetailBody(BaseModel):
|
||||
|
||||
|
||||
class MarketProtocolBody(BaseModel):
|
||||
"""移动协议白名单;状态、审核、财务、退款及转换字段一律拒绝。"""
|
||||
"""移动协议白名单。协议/捐赠状态可改;审核、财务、退款及转换时间字段一律拒绝。"""
|
||||
|
||||
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
||||
|
||||
source: Literal['cell_storage', 'research_donation']
|
||||
protocol_status: Optional[
|
||||
Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zky']
|
||||
] = Field(None, alias='protocolStatus')
|
||||
donation_status: Optional[
|
||||
Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zckc']
|
||||
] = Field(None, alias='donationStatus')
|
||||
protocol_no: Optional[str] = Field(None, alias='protocolNo')
|
||||
donation_no: Optional[str] = Field(None, alias='donationNo')
|
||||
register_date: Optional[str] = Field(None, alias='registerDate')
|
||||
@@ -416,6 +469,21 @@ class MarketProtocolBody(BaseModel):
|
||||
None, alias='paymentDetails'
|
||||
)
|
||||
|
||||
@field_validator('register_date', 'donation_date')
|
||||
@classmethod
|
||||
def validate_protocol_ymd(cls, value: Optional[str]) -> Optional[str]:
|
||||
"""接受 YYYYMMDD / YYYY-MM-DD,统一规范为 CHAR(8) YYYYMMDD。"""
|
||||
if value in (None, ''):
|
||||
return value
|
||||
text = str(value).strip().replace('-', '').replace('/', '')
|
||||
if len(text) != 8 or not text.isdigit():
|
||||
raise ValueError('日期必须使用 YYYYMMDD 格式')
|
||||
try:
|
||||
datetime.strptime(text, '%Y%m%d')
|
||||
except ValueError as exc:
|
||||
raise ValueError('日期必须是有效的 YYYYMMDD') from exc
|
||||
return text
|
||||
|
||||
|
||||
class MarketProtocolAuditBody(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
||||
|
||||
@@ -63,6 +63,7 @@ class StepReorderBody(BaseModel):
|
||||
# Step sub-table items
|
||||
class StepReagentItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
reagent_code: Optional[str] = Field(None, alias="reagentCode")
|
||||
reagent_name: Optional[str] = Field(None, alias="reagentName")
|
||||
spec: Optional[str] = None
|
||||
@@ -75,6 +76,7 @@ class StepReagentItem(BaseModel):
|
||||
|
||||
class StepConsumableItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
consumable_code: Optional[str] = Field(None, alias="consumableCode")
|
||||
consumable_name: Optional[str] = Field(None, alias="consumableName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
@@ -87,6 +89,7 @@ class StepConsumableItem(BaseModel):
|
||||
|
||||
class StepOutputItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
output_type_id: Optional[str] = Field(None, alias="outputTypeId")
|
||||
output_name: Optional[str] = Field(None, alias="outputName")
|
||||
output_barcode: Optional[str] = Field(None, alias="outputBarcode")
|
||||
@@ -96,6 +99,7 @@ class StepOutputItem(BaseModel):
|
||||
|
||||
class StepDataCollectionItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
collection_name: Optional[str] = Field(None, alias="collectionName")
|
||||
unit: Optional[str] = None
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
|
||||
@@ -88,6 +88,7 @@ class TaskStepUpdate(BaseModel):
|
||||
|
||||
class TaskStepMaterialItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
material_code: Optional[str] = None
|
||||
material_name: Optional[str] = None
|
||||
spec: Optional[str] = None
|
||||
@@ -102,6 +103,7 @@ class TaskStepMaterialItem(BaseModel):
|
||||
|
||||
|
||||
class TaskStepOutputItem(BaseModel):
|
||||
id: Optional[str] = None
|
||||
output_type_id: Optional[str] = None
|
||||
output_name: Optional[str] = None
|
||||
output_barcode: Optional[str] = None
|
||||
@@ -109,6 +111,7 @@ class TaskStepOutputItem(BaseModel):
|
||||
|
||||
|
||||
class TaskStepDataCollectionItem(BaseModel):
|
||||
id: Optional[str] = None
|
||||
collection_name: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
collection_value: Optional[str] = None
|
||||
@@ -117,6 +120,7 @@ class TaskStepDataCollectionItem(BaseModel):
|
||||
|
||||
class TaskInputItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
input_type: str = Field(..., alias="inputType")
|
||||
input_id: Optional[str] = Field(None, alias="inputId")
|
||||
input_code: Optional[str] = Field(None, alias="inputCode")
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""基础实验交付日前一天:服务号模板提醒。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import (
|
||||
BizPilotApplication,
|
||||
BizPilotInfusionPlan,
|
||||
BizPilotInfusionSchedule,
|
||||
BizPilotInfusionScheduleProduct,
|
||||
BizWxNotifyLog,
|
||||
SysUser,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NOTIFY_BIZ_TYPE = "basic_experiment_delivery_remind"
|
||||
CANCELLED_SCHEDULE_STATUS = "yqx"
|
||||
|
||||
|
||||
def format_delivery_product_name(
|
||||
*,
|
||||
project_name: str = "",
|
||||
spec_text: str = "",
|
||||
package_text: str = "",
|
||||
) -> str:
|
||||
"""产品名称拼接:名称(规格、包装);空段省略。"""
|
||||
name = (project_name or "").strip()
|
||||
spec = (spec_text or "").strip()
|
||||
package = (package_text or "").strip()
|
||||
parts = [p for p in (spec, package) if p]
|
||||
if not name and not parts:
|
||||
return ""
|
||||
if not parts:
|
||||
return name
|
||||
suffix = "、".join(parts)
|
||||
if name:
|
||||
return f"{name}({suffix})"
|
||||
return f"({suffix})"
|
||||
|
||||
|
||||
def _notify_enabled() -> bool:
|
||||
if not bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False)):
|
||||
return False
|
||||
tid = (getattr(settings, "WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY", "") or "").strip()
|
||||
return bool(tid)
|
||||
|
||||
|
||||
def _already_success(db: Session, *, tenant_no: str, schedule_id: str, notify_date: str) -> bool:
|
||||
row = (
|
||||
db.query(BizWxNotifyLog)
|
||||
.filter(
|
||||
BizWxNotifyLog.tenant_no == tenant_no,
|
||||
BizWxNotifyLog.biz_type == NOTIFY_BIZ_TYPE,
|
||||
BizWxNotifyLog.biz_id == schedule_id,
|
||||
BizWxNotifyLog.notify_date == notify_date,
|
||||
BizWxNotifyLog.status == "success",
|
||||
BizWxNotifyLog.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
def _write_log(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
schedule_id: str,
|
||||
notify_date: str,
|
||||
user_id: str,
|
||||
status: str,
|
||||
errcode: str = "",
|
||||
errmsg: str = "",
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
db.add(
|
||||
BizWxNotifyLog(
|
||||
id=str(uuid.uuid4()).replace("-", "").upper(),
|
||||
biz_type=NOTIFY_BIZ_TYPE,
|
||||
biz_id=schedule_id,
|
||||
notify_date=notify_date,
|
||||
user_id=user_id or "",
|
||||
status=status,
|
||||
errcode=(errcode or "")[:64] or None,
|
||||
errmsg=(errmsg or "")[:512] or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark="",
|
||||
create_by=user_id or "",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id or "",
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _iter_candidate_schedules(db: Session, *, delivery_day: date) -> List[Any]:
|
||||
"""返回 (schedule, application, product) 元组列表。"""
|
||||
day_start = datetime.combine(delivery_day, datetime.min.time())
|
||||
day_end = datetime.combine(delivery_day, datetime.max.time()).replace(microsecond=0)
|
||||
rows = (
|
||||
db.query(BizPilotInfusionSchedule, BizPilotApplication, BizPilotInfusionScheduleProduct)
|
||||
.join(
|
||||
BizPilotInfusionPlan,
|
||||
BizPilotInfusionPlan.id == BizPilotInfusionSchedule.infusion_plan_id,
|
||||
)
|
||||
.join(
|
||||
BizPilotApplication,
|
||||
BizPilotApplication.id == BizPilotInfusionPlan.pilot_application_id,
|
||||
)
|
||||
.outerjoin(
|
||||
BizPilotInfusionScheduleProduct,
|
||||
(
|
||||
BizPilotInfusionScheduleProduct.infusion_schedule_id
|
||||
== BizPilotInfusionSchedule.id
|
||||
)
|
||||
& (BizPilotInfusionScheduleProduct.del_flag == 0),
|
||||
)
|
||||
.filter(
|
||||
BizPilotApplication.biz_type == "basic_experiment",
|
||||
BizPilotApplication.del_flag == 0,
|
||||
BizPilotInfusionPlan.del_flag == 0,
|
||||
BizPilotInfusionSchedule.del_flag == 0,
|
||||
BizPilotInfusionSchedule.infusion_time >= day_start,
|
||||
BizPilotInfusionSchedule.infusion_time <= day_end,
|
||||
BizPilotInfusionSchedule.schedule_status != CANCELLED_SCHEDULE_STATUS,
|
||||
)
|
||||
.order_by(BizPilotInfusionSchedule.infusion_time.asc())
|
||||
.all()
|
||||
)
|
||||
# 一计划多产品时取首条产品行(基础实验通常一行)
|
||||
seen: set = set()
|
||||
out = []
|
||||
for sched, app, prod in rows:
|
||||
if sched.id in seen:
|
||||
continue
|
||||
seen.add(sched.id)
|
||||
out.append((sched, app, prod))
|
||||
return out
|
||||
|
||||
|
||||
def run_delivery_remind_for_date(
|
||||
db: Session,
|
||||
*,
|
||||
today: Optional[date] = None,
|
||||
) -> Dict[str, int]:
|
||||
"""扫描「明天」交付计划并发送提醒。返回计数统计。"""
|
||||
stats = {"candidates": 0, "sent": 0, "skipped": 0, "failed": 0}
|
||||
if not _notify_enabled():
|
||||
logger.info("basic experiment delivery remind skipped: notify disabled or no template")
|
||||
return stats
|
||||
|
||||
base = today or date.today()
|
||||
delivery_day = base + timedelta(days=1)
|
||||
notify_date = base.strftime("%Y-%m-%d")
|
||||
|
||||
from app.services.wechat_mp_message import send_basic_experiment_delivery_remind
|
||||
|
||||
candidates = _iter_candidate_schedules(db, delivery_day=delivery_day)
|
||||
stats["candidates"] = len(candidates)
|
||||
|
||||
for sched, app, prod in candidates:
|
||||
tenant_no = (app.tenant_no or sched.tenant_no or "").strip() or "zkxw"
|
||||
schedule_id = sched.id
|
||||
if _already_success(db, tenant_no=tenant_no, schedule_id=schedule_id, notify_date=notify_date):
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
creator_id = (app.create_by or "").strip()
|
||||
if not creator_id:
|
||||
logger.info(
|
||||
"delivery remind skip: no create_by schedule=%s app=%s",
|
||||
schedule_id,
|
||||
app.id,
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
user = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == creator_id, SysUser.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
mp = (getattr(user, "wx_mp_openid", None) or "").strip() if user else ""
|
||||
if not mp:
|
||||
logger.info(
|
||||
"delivery remind skip: no mp_openid user=%s schedule=%s",
|
||||
creator_id,
|
||||
schedule_id,
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
product_name = format_delivery_product_name(
|
||||
project_name=(prod.project_name if prod else "") or "",
|
||||
spec_text=(prod.spec_text if prod else "") or (sched.spec_text or ""),
|
||||
package_text=(prod.package_text if prod else "") or (sched.package_text or ""),
|
||||
)
|
||||
qty = 1
|
||||
if prod is not None and prod.quantity is not None:
|
||||
qty = int(prod.quantity)
|
||||
time_text = ""
|
||||
if sched.infusion_time:
|
||||
time_text = sched.infusion_time.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
result = send_basic_experiment_delivery_remind(
|
||||
mp_openid=mp,
|
||||
customer_name=app.customer_name or "",
|
||||
product_name=product_name,
|
||||
quantity=str(qty),
|
||||
contact_name=app.customer_contact or "",
|
||||
time_text=time_text,
|
||||
)
|
||||
if result.get("success"):
|
||||
_write_log(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
schedule_id=schedule_id,
|
||||
notify_date=notify_date,
|
||||
user_id=creator_id,
|
||||
status="success",
|
||||
)
|
||||
stats["sent"] += 1
|
||||
else:
|
||||
logger.error(
|
||||
"delivery remind send failed schedule=%s user=%s err=%s",
|
||||
schedule_id,
|
||||
creator_id,
|
||||
result,
|
||||
)
|
||||
stats["failed"] += 1
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("delivery remind commit failed")
|
||||
raise
|
||||
return stats
|
||||
|
||||
|
||||
def run_delivery_remind_job(get_db_session) -> None:
|
||||
"""APScheduler 入口。"""
|
||||
db = get_db_session()
|
||||
try:
|
||||
stats = run_delivery_remind_for_date(db)
|
||||
logger.info("basic experiment delivery remind done: %s", stats)
|
||||
except Exception:
|
||||
logger.exception("basic experiment delivery remind job failed")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import parse_datetime
|
||||
from app.core.protocol_status_display import (
|
||||
assert_protocol_status_transition,
|
||||
build_void_type_label_map,
|
||||
resolve_void_type_label,
|
||||
)
|
||||
@@ -46,6 +47,13 @@ from app.models.research_donation import (
|
||||
BizResearchDonationSampleTransport,
|
||||
)
|
||||
from app.schemas.market_mobile import MarketProtocolBody
|
||||
from app.services.sample_collect_wx_notify import (
|
||||
PROTOCOL_KIND_CELL,
|
||||
PROTOCOL_KIND_DONATION,
|
||||
sample_codes_from_orm_rows,
|
||||
sample_codes_from_payload,
|
||||
schedule_sample_collect_notify,
|
||||
)
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_cell_protocol_scope,
|
||||
@@ -1346,6 +1354,7 @@ def _persist_sample_payments_if_any(
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
sample_payments=raw,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -1416,7 +1425,6 @@ def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dic
|
||||
.filter(
|
||||
SysUser.id == hid,
|
||||
SysUser.tenant_no == tenant_no,
|
||||
SysUser.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -1437,7 +1445,6 @@ def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dic
|
||||
.filter(
|
||||
SysUser.id == hid2,
|
||||
SysUser.tenant_no == tenant_no,
|
||||
SysUser.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -1862,8 +1869,8 @@ def create_protocol(
|
||||
row = cell_crud.create_collection_protocol(
|
||||
db,
|
||||
protocol_no=body.protocol_no or '',
|
||||
# 对齐 Web 新增弹窗:默认生效中,移动端不接收客户端改状态
|
||||
protocol_status='sxz',
|
||||
# 对齐 Web 新增弹窗:未传时默认生效中
|
||||
protocol_status=(body.protocol_status or '').strip() or 'sxz',
|
||||
protocol_attachments=attachments.get('protocol_attachments'),
|
||||
consent_form_urls=attachments.get('consent_form_urls'),
|
||||
health_survey_form_urls=attachments.get(
|
||||
@@ -1930,14 +1937,21 @@ def create_protocol(
|
||||
assert_cell_protocol_access(db, row.id, ctx, user_info=user_info)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=ctx.tenant_no,
|
||||
protocol_id=row.id,
|
||||
protocol_kind=PROTOCOL_KIND_CELL,
|
||||
before_codes=[],
|
||||
submitted_codes=sample_codes_from_payload(samples),
|
||||
)
|
||||
return _cell_detail(db, row, ctx.tenant_no)
|
||||
|
||||
if body.sample_payments:
|
||||
raise ValueError('科研捐赠不支持样本缴费方案')
|
||||
data = {
|
||||
'donation_no': body.donation_no or '',
|
||||
# 对齐 Web 新增弹窗:默认生效中,移动端不接收客户端改状态
|
||||
'donation_status': 'sxz',
|
||||
# 对齐 Web 新增弹窗:未传时默认生效中
|
||||
'donation_status': (body.donation_status or '').strip() or 'sxz',
|
||||
'donation_date': body.donation_date,
|
||||
'collector_user_id': ctx.user_id
|
||||
if ctx.scope_kind == 'specialist'
|
||||
@@ -1977,6 +1991,13 @@ def create_protocol(
|
||||
assert_donation_access(db, row.id, ctx, user_info=user_info)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=ctx.tenant_no,
|
||||
protocol_id=row.id,
|
||||
protocol_kind=PROTOCOL_KIND_DONATION,
|
||||
before_codes=[],
|
||||
submitted_codes=sample_codes_from_payload(samples),
|
||||
)
|
||||
return _donation_detail(db, row, ctx.tenant_no)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
@@ -2051,6 +2072,7 @@ def update_protocol(
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
protocol_no=body.protocol_no,
|
||||
protocol_status=(body.protocol_status or '').strip() or None,
|
||||
protocol_attachments=attachments.get('protocol_attachments'),
|
||||
consent_form_urls=attachments.get('consent_form_urls'),
|
||||
health_survey_form_urls=attachments.get(
|
||||
@@ -2133,6 +2155,17 @@ def update_protocol(
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=ctx.tenant_no,
|
||||
protocol_id=row.id,
|
||||
protocol_kind=PROTOCOL_KIND_CELL,
|
||||
before_codes=sample_codes_from_orm_rows(current_samples),
|
||||
submitted_codes=(
|
||||
sample_codes_from_payload(samples)
|
||||
if body.samples is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return _cell_detail(db, row, ctx.tenant_no)
|
||||
|
||||
row = _lock_donation_access(
|
||||
@@ -2181,6 +2214,11 @@ def update_protocol(
|
||||
),
|
||||
**collection,
|
||||
}
|
||||
donation_status = (body.donation_status or '').strip()
|
||||
if donation_status:
|
||||
assert_protocol_status_transition(
|
||||
row.donation_status or '', donation_status, kind='donation'
|
||||
)
|
||||
data = {
|
||||
'donation_no': body.donation_no,
|
||||
'donation_date': body.donation_date,
|
||||
@@ -2194,6 +2232,8 @@ def update_protocol(
|
||||
'infectious_disease_report_urls'
|
||||
),
|
||||
}
|
||||
if donation_status:
|
||||
data['donation_status'] = donation_status
|
||||
if body.customer is not None:
|
||||
data['party'] = customer
|
||||
if body.collection is not None:
|
||||
@@ -2214,6 +2254,17 @@ def update_protocol(
|
||||
row = assert_donation_access(db, row.id, ctx, user_info=user_info)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=ctx.tenant_no,
|
||||
protocol_id=row.id,
|
||||
protocol_kind=PROTOCOL_KIND_DONATION,
|
||||
before_codes=sample_codes_from_orm_rows(current_samples),
|
||||
submitted_codes=(
|
||||
sample_codes_from_payload(samples)
|
||||
if body.samples is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return _donation_detail(db, row, ctx.tenant_no)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
@@ -2237,7 +2237,7 @@ def _apply_instance_list_filters(
|
||||
if not joined_initiator:
|
||||
q = q.outerjoin(
|
||||
SysUser,
|
||||
(SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
|
||||
SysUser.id == OaInstance.initiator_user_id,
|
||||
)
|
||||
joined_initiator = True
|
||||
like = f"%{combined}%"
|
||||
@@ -2265,7 +2265,7 @@ def _apply_instance_list_filters(
|
||||
if not joined_initiator:
|
||||
q = q.outerjoin(
|
||||
SysUser,
|
||||
(SysUser.id == OaInstance.initiator_user_id) & (SysUser.del_flag == 0),
|
||||
SysUser.id == OaInstance.initiator_user_id,
|
||||
)
|
||||
joined_initiator = True
|
||||
q = q.filter(SysUser.nick_name.like(f"%{iname}%"))
|
||||
@@ -2922,7 +2922,10 @@ def list_tasks(
|
||||
}
|
||||
|
||||
if kind == "mention":
|
||||
from app.services.oa_instance_comment import list_mentioned_instance_ids
|
||||
from app.services.oa_instance_comment import (
|
||||
list_mentioned_instance_ids,
|
||||
mention_read_map,
|
||||
)
|
||||
|
||||
ids = list_mentioned_instance_ids(db, tenant_no=tenant_no, user_id=user_id)
|
||||
if not ids:
|
||||
@@ -2947,8 +2950,20 @@ def list_tasks(
|
||||
total = len(insts)
|
||||
insts = insts[offset : offset + page_size]
|
||||
rows = [_instance_to_dict(i) for i in insts]
|
||||
enriched = _enrich_list_rows(
|
||||
db, tenant_no=tenant_no, rows=rows, instances=insts
|
||||
)
|
||||
read_map = mention_read_map(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
instance_ids=[str(i.id) for i in insts],
|
||||
)
|
||||
for row in enriched:
|
||||
rid = str(row.get("id") or row.get("instanceId") or "").strip()
|
||||
row["isRead"] = bool(read_map.get(rid, True))
|
||||
return {
|
||||
"list": _enrich_list_rows(db, tenant_no=tenant_no, rows=rows, instances=insts),
|
||||
"list": enriched,
|
||||
"total": int(total),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
@@ -197,11 +206,7 @@ def _send_applicant_progress(
|
||||
actor = None
|
||||
aid = (actor_user_id or "").strip()
|
||||
if aid:
|
||||
actor = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == aid, SysUser.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
actor = db.query(SysUser).filter(SysUser.id == aid).first()
|
||||
approver_name = (actor.nick_name if actor else "") or ""
|
||||
pagepath = build_oa_detail_pagepath(
|
||||
instance_id=iid,
|
||||
@@ -224,6 +229,126 @@ def _oa_wx_notify_enabled() -> bool:
|
||||
return bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False))
|
||||
|
||||
|
||||
def _send_mention_one(
|
||||
db,
|
||||
*,
|
||||
instance_id: str,
|
||||
mentioned_user_id: str,
|
||||
commenter_user_id: str,
|
||||
) -> None:
|
||||
"""评论 @:复用抄送模板;thing2=评论人,thing5=「xxx 在评论中提到了你」。"""
|
||||
uid = (mentioned_user_id or "").strip()
|
||||
if not uid:
|
||||
return
|
||||
user = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
|
||||
mp = (getattr(user, "wx_mp_openid", None) or "").strip() if user else ""
|
||||
if not mp:
|
||||
logger.info(
|
||||
"oa wx mention skip: no mp_openid user=%s inst=%s", uid, instance_id
|
||||
)
|
||||
return
|
||||
iid = (instance_id or "").strip()
|
||||
inst = (
|
||||
db.query(OaInstance)
|
||||
.filter(OaInstance.id == iid, OaInstance.del_flag == 0)
|
||||
.first()
|
||||
if iid
|
||||
else None
|
||||
)
|
||||
process_name = ""
|
||||
if inst and inst.app_id:
|
||||
app = (
|
||||
db.query(OaApp)
|
||||
.filter(OaApp.id == inst.app_id, OaApp.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
process_name = (app.app_name if app else "") or ""
|
||||
if not process_name and inst:
|
||||
process_name = (inst.title or "").strip()
|
||||
|
||||
commenter_name = ""
|
||||
cid = (commenter_user_id or "").strip()
|
||||
if cid:
|
||||
commenter = db.query(SysUser).filter(SysUser.id == cid).first()
|
||||
commenter_name = (commenter.nick_name if commenter else "") or ""
|
||||
|
||||
pagepath = build_oa_detail_pagepath(
|
||||
instance_id=iid,
|
||||
task_id="",
|
||||
mode="view",
|
||||
return_path=RETURN_MENTIONS,
|
||||
)
|
||||
send_oa_cc_notify(
|
||||
mp_openid=mp,
|
||||
pagepath=pagepath,
|
||||
title=_build_mention_title(commenter_name),
|
||||
applicant=commenter_name or "未知",
|
||||
node_name="",
|
||||
time_text=datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
process_name=process_name or "审批",
|
||||
)
|
||||
|
||||
|
||||
def notify_oa_mentions_sync(
|
||||
*,
|
||||
instance_id: str,
|
||||
commenter_user_id: str,
|
||||
mentioned_user_ids: Iterable[str],
|
||||
) -> None:
|
||||
if not _oa_wx_notify_enabled():
|
||||
logger.info("oa wx mention skipped: WX_MP_NOTIFY_ENABLED=false")
|
||||
return
|
||||
iid = (instance_id or "").strip()
|
||||
ids = [str(x).strip() for x in (mentioned_user_ids or []) if str(x).strip()]
|
||||
if not iid or not ids:
|
||||
return
|
||||
db = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
for uid in ids:
|
||||
try:
|
||||
_send_mention_one(
|
||||
db,
|
||||
instance_id=iid,
|
||||
mentioned_user_id=uid,
|
||||
commenter_user_id=commenter_user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"oa wx mention failed user=%s inst=%s", uid, iid
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("oa wx mention batch failed inst=%s", iid)
|
||||
finally:
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def schedule_oa_mention_notifies(
|
||||
*,
|
||||
instance_id: str,
|
||||
commenter_user_id: str,
|
||||
mentioned_user_ids: List[str],
|
||||
) -> None:
|
||||
if not _oa_wx_notify_enabled():
|
||||
logger.info("oa wx mention schedule skipped: WX_MP_NOTIFY_ENABLED=false")
|
||||
return
|
||||
iid = (instance_id or "").strip()
|
||||
ids = [str(x).strip() for x in (mentioned_user_ids or []) if str(x).strip()]
|
||||
if not iid or not ids:
|
||||
return
|
||||
threading.Thread(
|
||||
target=notify_oa_mentions_sync,
|
||||
kwargs={
|
||||
"instance_id": iid,
|
||||
"commenter_user_id": (commenter_user_id or "").strip(),
|
||||
"mentioned_user_ids": ids,
|
||||
},
|
||||
daemon=True,
|
||||
name="oa-wx-mention",
|
||||
).start()
|
||||
|
||||
|
||||
def notify_oa_tasks_sync(task_ids: Iterable[str]) -> None:
|
||||
if not _oa_wx_notify_enabled():
|
||||
logger.info("oa wx notify skipped: WX_MP_NOTIFY_ENABLED=false")
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
"""样本编码全链路纠错:预览影响面 + 事务级联更新。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
BizCellProduct,
|
||||
BizProductionTaskInput,
|
||||
BizProductionWorkOrder,
|
||||
BizProtocolPaymentSchedule,
|
||||
BizProtocolPaymentTransfer,
|
||||
BizProtocolSample,
|
||||
BizProtocolSamplePayment,
|
||||
BizProtocolSampleTransport,
|
||||
BizQcTask,
|
||||
BizQcWorkOrder,
|
||||
BizSampleCode,
|
||||
BizSampleCodeCorrectionLog,
|
||||
BizSampleCustomerService,
|
||||
BizSampleFreightItem,
|
||||
BizSampleInsurance,
|
||||
BizSampleProductionReception,
|
||||
BizSampleReception,
|
||||
BizSampleStorageCert,
|
||||
BizSampleStorageCertQc,
|
||||
BizStorageReleaseReview,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonationSample,
|
||||
BizResearchDonationSampleTransport,
|
||||
)
|
||||
|
||||
SOURCE_CELL = "cell_storage"
|
||||
SOURCE_DONATION = "research_donation"
|
||||
VALID_SOURCES = {SOURCE_CELL, SOURCE_DONATION}
|
||||
|
||||
# (key, label, model, code_attr, has_reception_source, cell_only)
|
||||
_TABLE_SPECS: List[Tuple[str, str, Any, str, bool, bool]] = [
|
||||
("biz_protocol_sample_payment", "按样本缴费", BizProtocolSamplePayment, "sample_code", False, True),
|
||||
("biz_protocol_payment_schedule", "缴费期次", BizProtocolPaymentSchedule, "sample_code", False, True),
|
||||
("biz_protocol_payment_transfer", "缴费转账", BizProtocolPaymentTransfer, "sample_code", False, True),
|
||||
("biz_protocol_sample_transport", "协议样本运输", BizProtocolSampleTransport, "sample_code", False, True),
|
||||
(
|
||||
"biz_research_donation_sample_transport",
|
||||
"捐赠样本运输",
|
||||
BizResearchDonationSampleTransport,
|
||||
"sample_code",
|
||||
False,
|
||||
False,
|
||||
),
|
||||
("biz_sample_freight_item", "运费明细", BizSampleFreightItem, "sample_code", False, False),
|
||||
("biz_sample_reception", "样本接收", BizSampleReception, "sample_code", True, False),
|
||||
("biz_qc_work_order", "质检工单", BizQcWorkOrder, "sample_code", True, False),
|
||||
("biz_qc_task", "质检任务", BizQcTask, "sample_code", False, False),
|
||||
("biz_storage_release_review", "入库放行", BizStorageReleaseReview, "sample_code", True, False),
|
||||
("biz_sample_storage_cert_qc", "储存证质检", BizSampleStorageCertQc, "sample_code", False, True),
|
||||
(
|
||||
"biz_sample_production_reception",
|
||||
"生产部门接收",
|
||||
BizSampleProductionReception,
|
||||
"sample_code",
|
||||
True,
|
||||
False,
|
||||
),
|
||||
("biz_production_work_order", "生产工单", BizProductionWorkOrder, "sample_code", True, False),
|
||||
("biz_sample_storage_cert", "储存证", BizSampleStorageCert, "sample_code", False, True),
|
||||
("biz_sample_insurance", "样本保险", BizSampleInsurance, "sample_code", False, True),
|
||||
("biz_sample_customer_service", "客户服务扩展", BizSampleCustomerService, "sample_code", False, True),
|
||||
]
|
||||
|
||||
_UNIQUE_TABLES = {
|
||||
"biz_sample_storage_cert",
|
||||
"biz_sample_insurance",
|
||||
"biz_sample_customer_service",
|
||||
"biz_sample_storage_cert_qc",
|
||||
}
|
||||
|
||||
|
||||
class SampleCodeCorrectionError(ValueError):
|
||||
"""业务校验失败(可直接返回给前端)。"""
|
||||
|
||||
|
||||
def _norm(code: Optional[str]) -> str:
|
||||
return (code or "").strip()
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _load_source_sample(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
reception_source: str,
|
||||
sample_code: str,
|
||||
):
|
||||
code = _norm(sample_code)
|
||||
if reception_source == SOURCE_CELL:
|
||||
return (
|
||||
db.query(BizProtocolSample)
|
||||
.filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolSample.sample_code == code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return (
|
||||
db.query(BizResearchDonationSample)
|
||||
.filter(
|
||||
BizResearchDonationSample.tenant_no == tenant_no,
|
||||
BizResearchDonationSample.del_flag == 0,
|
||||
BizResearchDonationSample.sample_code == code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _code_used_by_other_sample(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
sample_code: str,
|
||||
exclude_sample_id: Optional[str],
|
||||
) -> bool:
|
||||
code = _norm(sample_code)
|
||||
q1 = db.query(BizProtocolSample.id).filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolSample.sample_code == code,
|
||||
)
|
||||
q2 = db.query(BizResearchDonationSample.id).filter(
|
||||
BizResearchDonationSample.tenant_no == tenant_no,
|
||||
BizResearchDonationSample.del_flag == 0,
|
||||
BizResearchDonationSample.sample_code == code,
|
||||
)
|
||||
if exclude_sample_id:
|
||||
q1 = q1.filter(BizProtocolSample.id != exclude_sample_id)
|
||||
q2 = q2.filter(BizResearchDonationSample.id != exclude_sample_id)
|
||||
return q1.first() is not None or q2.first() is not None
|
||||
|
||||
|
||||
def _count_by_code(
|
||||
db: Session,
|
||||
model,
|
||||
*,
|
||||
tenant_no: str,
|
||||
code: str,
|
||||
code_attr: str = "sample_code",
|
||||
reception_source: Optional[str] = None,
|
||||
has_reception_source: bool = False,
|
||||
) -> int:
|
||||
col = getattr(model, code_attr)
|
||||
q = db.query(model).filter(
|
||||
model.tenant_no == tenant_no,
|
||||
model.del_flag == 0,
|
||||
col == code,
|
||||
)
|
||||
if has_reception_source and reception_source:
|
||||
q = q.filter(model.reception_source == reception_source)
|
||||
return int(q.count())
|
||||
|
||||
|
||||
def _update_by_code(
|
||||
db: Session,
|
||||
model,
|
||||
*,
|
||||
tenant_no: str,
|
||||
old_code: str,
|
||||
new_code: str,
|
||||
code_attr: str = "sample_code",
|
||||
reception_source: Optional[str] = None,
|
||||
has_reception_source: bool = False,
|
||||
user_id: Optional[str] = None,
|
||||
) -> int:
|
||||
col = getattr(model, code_attr)
|
||||
q = db.query(model).filter(
|
||||
model.tenant_no == tenant_no,
|
||||
model.del_flag == 0,
|
||||
col == old_code,
|
||||
)
|
||||
if has_reception_source and reception_source:
|
||||
q = q.filter(model.reception_source == reception_source)
|
||||
rows = q.all()
|
||||
now = _now()
|
||||
for row in rows:
|
||||
setattr(row, code_attr, new_code)
|
||||
if hasattr(row, "update_by"):
|
||||
row.update_by = user_id
|
||||
if hasattr(row, "update_time"):
|
||||
row.update_time = now
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _count_task_inputs(db: Session, tenant_no: str, code: str) -> int:
|
||||
return int(
|
||||
db.query(BizProductionTaskInput)
|
||||
.filter(
|
||||
BizProductionTaskInput.tenant_no == tenant_no,
|
||||
BizProductionTaskInput.del_flag == 0,
|
||||
BizProductionTaskInput.input_type == "sample",
|
||||
BizProductionTaskInput.input_code == code,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def _update_task_inputs(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
old_code: str,
|
||||
new_code: str,
|
||||
user_id: Optional[str],
|
||||
) -> int:
|
||||
rows = (
|
||||
db.query(BizProductionTaskInput)
|
||||
.filter(
|
||||
BizProductionTaskInput.tenant_no == tenant_no,
|
||||
BizProductionTaskInput.del_flag == 0,
|
||||
BizProductionTaskInput.input_type == "sample",
|
||||
BizProductionTaskInput.input_code == old_code,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
now = _now()
|
||||
for row in rows:
|
||||
row.input_code = new_code
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _count_same_product_code(db: Session, tenant_no: str, code: str) -> int:
|
||||
return int(
|
||||
db.query(BizCellProduct)
|
||||
.filter(
|
||||
BizCellProduct.tenant_no == tenant_no,
|
||||
BizCellProduct.del_flag == 0,
|
||||
BizCellProduct.product_code == code,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def _unique_conflict(
|
||||
db: Session,
|
||||
model,
|
||||
*,
|
||||
tenant_no: str,
|
||||
new_code: str,
|
||||
) -> bool:
|
||||
return (
|
||||
db.query(model.id)
|
||||
.filter(
|
||||
model.tenant_no == tenant_no,
|
||||
model.del_flag == 0,
|
||||
model.sample_code == new_code,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _spec_applies(reception_source: str, cell_only: bool, table_key: str) -> bool:
|
||||
if reception_source == SOURCE_CELL:
|
||||
if table_key == "biz_research_donation_sample_transport":
|
||||
return False
|
||||
return True
|
||||
# donation
|
||||
if cell_only:
|
||||
return False
|
||||
if table_key.startswith("biz_protocol_"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def preview_correction(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
reception_source: str,
|
||||
old_sample_code: str,
|
||||
new_sample_code: str,
|
||||
new_sample_code_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
source = _norm(reception_source) or SOURCE_CELL
|
||||
old_code = _norm(old_sample_code)
|
||||
new_code = _norm(new_sample_code)
|
||||
blockers: List[str] = []
|
||||
|
||||
if source not in VALID_SOURCES:
|
||||
blockers.append("receptionSource 必须是 cell_storage 或 research_donation")
|
||||
if not old_code:
|
||||
blockers.append("旧样本编码不能为空")
|
||||
if not new_code:
|
||||
blockers.append("新样本编码不能为空")
|
||||
if old_code and new_code and old_code == new_code:
|
||||
blockers.append("新样本编码不能与旧编码相同")
|
||||
|
||||
sample = None
|
||||
if not blockers:
|
||||
sample = _load_source_sample(
|
||||
db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
|
||||
)
|
||||
if sample is None:
|
||||
blockers.append(f"未找到来源「{source}」下样本编码「{old_code}」")
|
||||
elif _code_used_by_other_sample(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
sample_code=new_code,
|
||||
exclude_sample_id=sample.id,
|
||||
):
|
||||
blockers.append(f"样本编码「{new_code}」已被其它协议/捐赠样本占用")
|
||||
|
||||
impacts: List[Dict[str, Any]] = []
|
||||
if sample is not None and not blockers:
|
||||
sot_key = (
|
||||
"biz_protocol_sample"
|
||||
if source == SOURCE_CELL
|
||||
else "biz_research_donation_sample"
|
||||
)
|
||||
impacts.append(
|
||||
{
|
||||
"table": sot_key,
|
||||
"label": "样本赋值源表",
|
||||
"count": 1,
|
||||
}
|
||||
)
|
||||
|
||||
pool_count = 0
|
||||
if source == SOURCE_CELL:
|
||||
pool_id = _norm(new_sample_code_id) or _norm(getattr(sample, "sample_code_id", None))
|
||||
if pool_id:
|
||||
pool_row = (
|
||||
db.query(BizSampleCode)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.id == pool_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if pool_row and _norm(pool_row.sample_code) == old_code:
|
||||
pool_count = 1
|
||||
elif not pool_row and _norm(new_sample_code_id):
|
||||
blockers.append(f"指定的新编码池 ID「{new_sample_code_id}」不存在")
|
||||
existing_new_pool = (
|
||||
db.query(BizSampleCode)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.sample_code == new_code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing_new_pool and pool_count == 0:
|
||||
impacts.append(
|
||||
{
|
||||
"table": "biz_sample_code",
|
||||
"label": "编码池(切换占用)",
|
||||
"count": 1,
|
||||
}
|
||||
)
|
||||
elif pool_count:
|
||||
impacts.append(
|
||||
{
|
||||
"table": "biz_sample_code",
|
||||
"label": "编码池(就地改码)",
|
||||
"count": pool_count,
|
||||
}
|
||||
)
|
||||
|
||||
for key, label, model, attr, has_rs, cell_only in _TABLE_SPECS:
|
||||
if not _spec_applies(source, cell_only, key):
|
||||
continue
|
||||
cnt = _count_by_code(
|
||||
db,
|
||||
model,
|
||||
tenant_no=tenant_no,
|
||||
code=old_code,
|
||||
code_attr=attr,
|
||||
reception_source=source if has_rs else None,
|
||||
has_reception_source=has_rs,
|
||||
)
|
||||
if cnt:
|
||||
impacts.append({"table": key, "label": label, "count": cnt})
|
||||
if (
|
||||
key in _UNIQUE_TABLES
|
||||
and source == SOURCE_CELL
|
||||
and cnt > 0
|
||||
and _unique_conflict(db, model, tenant_no=tenant_no, new_code=new_code)
|
||||
):
|
||||
blockers.append(
|
||||
f"表 {key} 已存在样本编码「{new_code}」记录,无法直接改码"
|
||||
)
|
||||
|
||||
ti = _count_task_inputs(db, tenant_no, old_code)
|
||||
if ti:
|
||||
impacts.append(
|
||||
{
|
||||
"table": "biz_production_task_input",
|
||||
"label": "生产任务投入物(样本)",
|
||||
"count": ti,
|
||||
}
|
||||
)
|
||||
|
||||
same_product_old = (
|
||||
_count_same_product_code(db, tenant_no, old_code) if old_code else 0
|
||||
)
|
||||
same_product_new = (
|
||||
_count_same_product_code(db, tenant_no, new_code) if new_code else 0
|
||||
)
|
||||
|
||||
total = sum(int(i.get("count") or 0) for i in impacts)
|
||||
return {
|
||||
"ok": len(blockers) == 0,
|
||||
"blockers": blockers,
|
||||
"receptionSource": source,
|
||||
"oldSampleCode": old_code,
|
||||
"newSampleCode": new_code,
|
||||
"sampleId": getattr(sample, "id", None) if sample else None,
|
||||
"impacts": impacts,
|
||||
"totalAffectedRows": total,
|
||||
"hints": {
|
||||
"sameProductCodeAsOld": same_product_old,
|
||||
"sameProductCodeAsNew": same_product_new,
|
||||
"labStorageCodeUnchanged": True,
|
||||
"cellProductCodeNotAutoUpdated": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_pool(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
sample: BizProtocolSample,
|
||||
old_code: str,
|
||||
new_code: str,
|
||||
new_sample_code_id: Optional[str],
|
||||
user_id: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""返回 pool 处理结果摘要。"""
|
||||
now = _now()
|
||||
result = {"action": "none", "poolId": None}
|
||||
|
||||
preferred_id = _norm(new_sample_code_id)
|
||||
existing_new = (
|
||||
db.query(BizSampleCode)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.sample_code == new_code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if preferred_id:
|
||||
preferred = (
|
||||
db.query(BizSampleCode)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.id == preferred_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if preferred is None:
|
||||
raise SampleCodeCorrectionError(f"指定的新编码池 ID「{preferred_id}」不存在")
|
||||
if _norm(preferred.sample_code) not in {new_code, old_code}:
|
||||
# 允许指向已是新码的池行,或指向即将就地改码的旧池行
|
||||
if _norm(preferred.sample_code) != new_code:
|
||||
preferred.sample_code = new_code
|
||||
preferred.update_by = user_id
|
||||
preferred.update_time = now
|
||||
result["action"] = "rename_preferred"
|
||||
existing_new = preferred
|
||||
|
||||
old_pool_id = _norm(getattr(sample, "sample_code_id", None))
|
||||
old_pool = None
|
||||
if old_pool_id:
|
||||
old_pool = (
|
||||
db.query(BizSampleCode)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.id == old_pool_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_new is not None:
|
||||
sample.sample_code_id = existing_new.id
|
||||
sample.sample_code = new_code
|
||||
existing_new.issued = 1
|
||||
existing_new.update_by = user_id
|
||||
existing_new.update_time = now
|
||||
result["action"] = result["action"] if result["action"] != "none" else "reassign"
|
||||
result["poolId"] = existing_new.id
|
||||
if (
|
||||
old_pool
|
||||
and old_pool.id != existing_new.id
|
||||
and _norm(old_pool.sample_code) == old_code
|
||||
):
|
||||
still_used = (
|
||||
db.query(BizProtocolSample.id)
|
||||
.filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolSample.sample_code_id == old_pool.id,
|
||||
BizProtocolSample.id != sample.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if still_used is None:
|
||||
old_pool.issued = 0
|
||||
old_pool.update_by = user_id
|
||||
old_pool.update_time = now
|
||||
return result
|
||||
|
||||
if old_pool and _norm(old_pool.sample_code) == old_code:
|
||||
old_pool.sample_code = new_code
|
||||
old_pool.issued = 1
|
||||
old_pool.update_by = user_id
|
||||
old_pool.update_time = now
|
||||
sample.sample_code = new_code
|
||||
sample.sample_code_id = old_pool.id
|
||||
result["action"] = "rename_inplace"
|
||||
result["poolId"] = old_pool.id
|
||||
return result
|
||||
|
||||
sample.sample_code = new_code
|
||||
result["action"] = "sample_text_only"
|
||||
return result
|
||||
|
||||
|
||||
def apply_correction(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
reception_source: str,
|
||||
old_sample_code: str,
|
||||
new_sample_code: str,
|
||||
new_sample_code_id: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
preview = preview_correction(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
reception_source=reception_source,
|
||||
old_sample_code=old_sample_code,
|
||||
new_sample_code=new_sample_code,
|
||||
new_sample_code_id=new_sample_code_id,
|
||||
)
|
||||
if not preview.get("ok"):
|
||||
raise SampleCodeCorrectionError(
|
||||
";".join(preview.get("blockers") or ["校验未通过"])
|
||||
)
|
||||
|
||||
source = preview["receptionSource"]
|
||||
old_code = preview["oldSampleCode"]
|
||||
new_code = preview["newSampleCode"]
|
||||
sample = _load_source_sample(
|
||||
db, tenant_no=tenant_no, reception_source=source, sample_code=old_code
|
||||
)
|
||||
if sample is None:
|
||||
raise SampleCodeCorrectionError(f"未找到样本编码「{old_code}」")
|
||||
|
||||
now = _now()
|
||||
impact_counts: Dict[str, int] = {}
|
||||
|
||||
if source == SOURCE_CELL:
|
||||
pool_info = _resolve_pool(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
sample=sample,
|
||||
old_code=old_code,
|
||||
new_code=new_code,
|
||||
new_sample_code_id=new_sample_code_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
impact_counts["biz_sample_code"] = 1 if pool_info.get("action") != "none" else 0
|
||||
sample.update_by = user_id
|
||||
sample.update_time = now
|
||||
impact_counts["biz_protocol_sample"] = 1
|
||||
else:
|
||||
sample.sample_code = new_code
|
||||
sample.update_by = user_id
|
||||
sample.update_time = now
|
||||
impact_counts["biz_research_donation_sample"] = 1
|
||||
|
||||
# UNIQUE 表先更新,避免其它逻辑读到半新半旧
|
||||
for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
|
||||
if key not in _UNIQUE_TABLES:
|
||||
continue
|
||||
if not _spec_applies(source, cell_only, key):
|
||||
continue
|
||||
n = _update_by_code(
|
||||
db,
|
||||
model,
|
||||
tenant_no=tenant_no,
|
||||
old_code=old_code,
|
||||
new_code=new_code,
|
||||
code_attr=attr,
|
||||
reception_source=source if has_rs else None,
|
||||
has_reception_source=has_rs,
|
||||
user_id=user_id,
|
||||
)
|
||||
if n:
|
||||
impact_counts[key] = n
|
||||
|
||||
for key, _label, model, attr, has_rs, cell_only in _TABLE_SPECS:
|
||||
if key in _UNIQUE_TABLES:
|
||||
continue
|
||||
if not _spec_applies(source, cell_only, key):
|
||||
continue
|
||||
n = _update_by_code(
|
||||
db,
|
||||
model,
|
||||
tenant_no=tenant_no,
|
||||
old_code=old_code,
|
||||
new_code=new_code,
|
||||
code_attr=attr,
|
||||
reception_source=source if has_rs else None,
|
||||
has_reception_source=has_rs,
|
||||
user_id=user_id,
|
||||
)
|
||||
if n:
|
||||
impact_counts[key] = n
|
||||
|
||||
ti = _update_task_inputs(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
old_code=old_code,
|
||||
new_code=new_code,
|
||||
user_id=user_id,
|
||||
)
|
||||
if ti:
|
||||
impact_counts["biz_production_task_input"] = ti
|
||||
|
||||
log = BizSampleCodeCorrectionLog(
|
||||
id=generate_uuid(),
|
||||
reception_source=source,
|
||||
old_sample_code=old_code,
|
||||
new_sample_code=new_code,
|
||||
impact_json=json.dumps(impact_counts, ensure_ascii=False),
|
||||
reason=_norm(reason) or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"logId": log.id,
|
||||
"receptionSource": source,
|
||||
"oldSampleCode": old_code,
|
||||
"newSampleCode": new_code,
|
||||
"impactCounts": impact_counts,
|
||||
"totalAffectedRows": sum(impact_counts.values()),
|
||||
"hints": preview.get("hints") or {},
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
"""协议新增样本:服务号模板消息通知。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.protocol_status_display import INACTIVE_PROTOCOL_STATUSES
|
||||
from app.models.models import (
|
||||
BizCellSampleType,
|
||||
BizCollectionProtocol,
|
||||
BizProtocolSample,
|
||||
BizProtocolSampleTransport,
|
||||
BizWxNotifyLog,
|
||||
SysUser,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonation,
|
||||
BizResearchDonationSample,
|
||||
BizResearchDonationSampleTransport,
|
||||
)
|
||||
from app.services.web_auth_roles import list_user_ids_with_effective_role_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NOTIFY_BIZ_TYPE = "sample_collect_notify"
|
||||
NOTIFY_DATE_ONCE = "1970-01-01"
|
||||
|
||||
PROTOCOL_KIND_CELL = "cell_storage"
|
||||
PROTOCOL_KIND_DONATION = "research_donation"
|
||||
|
||||
TASK_TYPE_CELL = "细胞存储"
|
||||
TASK_TYPE_DONATION = "科研捐赠"
|
||||
SAMPLE_STATUS_VOID = "yzf"
|
||||
|
||||
|
||||
def sample_codes_from_payload(samples: Optional[Iterable[Any]]) -> Set[str]:
|
||||
"""从请求体样本列表提取非空 sample_code(兼容 camelCase)。"""
|
||||
out: Set[str] = set()
|
||||
for raw in samples or []:
|
||||
if isinstance(raw, dict):
|
||||
code = (raw.get("sample_code") or raw.get("sampleCode") or "").strip()
|
||||
else:
|
||||
code = (getattr(raw, "sample_code", None) or "").strip()
|
||||
if code:
|
||||
out.add(code)
|
||||
return out
|
||||
|
||||
|
||||
def sample_codes_from_orm_rows(rows: Optional[Iterable[Any]]) -> Set[str]:
|
||||
out: Set[str] = set()
|
||||
for row in rows or []:
|
||||
code = (getattr(row, "sample_code", None) or "").strip()
|
||||
if code:
|
||||
out.add(code)
|
||||
return out
|
||||
|
||||
|
||||
def diff_new_sample_codes(
|
||||
*,
|
||||
before_codes: Iterable[str],
|
||||
submitted_codes: Iterable[str],
|
||||
) -> List[str]:
|
||||
"""提交相对保存前的新增 sample_code;稳定排序便于测试。"""
|
||||
before = {str(c).strip() for c in before_codes if str(c).strip()}
|
||||
submitted = {str(c).strip() for c in submitted_codes if str(c).strip()}
|
||||
return sorted(submitted - before)
|
||||
|
||||
|
||||
def format_sample_collect_title(eta_time: Optional[datetime] = None) -> str:
|
||||
"""工单标题:有 ETA → 样本采集(预计到达 M月D日);否则到达待定。"""
|
||||
if eta_time is not None:
|
||||
title = f"样本采集(预计到达 {eta_time.month}月{eta_time.day}日)"
|
||||
else:
|
||||
title = "样本采集(到达待定)"
|
||||
return title
|
||||
|
||||
|
||||
def format_protocol_create_time(dt: Optional[datetime]) -> str:
|
||||
if not dt:
|
||||
return ""
|
||||
return dt.strftime("%Y年%m月%d日 %H:%M")
|
||||
|
||||
|
||||
def _notify_enabled() -> bool:
|
||||
if not bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False)):
|
||||
return False
|
||||
tid = (getattr(settings, "WX_MP_TEMPLATE_SAMPLE_COLLECT", "") or "").strip()
|
||||
return bool(tid)
|
||||
|
||||
|
||||
def _role_name() -> str:
|
||||
return (
|
||||
getattr(settings, "WX_MP_SAMPLE_COLLECT_ROLE_NAME", "") or "样本采集通知人员"
|
||||
).strip() or "样本采集通知人员"
|
||||
|
||||
|
||||
def protocol_status_blocks_notify(status: Optional[str]) -> bool:
|
||||
"""已作废、属地废弃的协议单不发样本采集通知。"""
|
||||
return (status or "").strip() in INACTIVE_PROTOCOL_STATUSES
|
||||
|
||||
|
||||
def sample_status_blocks_notify(status: Optional[str]) -> bool:
|
||||
"""已作废样本不发样本采集通知。"""
|
||||
return (status or "").strip() == SAMPLE_STATUS_VOID
|
||||
|
||||
|
||||
def _sample_status_by_code(samples: Iterable[Any]) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
for sample in samples or []:
|
||||
code = (getattr(sample, "sample_code", None) or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
out[code] = (getattr(sample, "sample_status", None) or "").strip()
|
||||
return out
|
||||
|
||||
|
||||
def _task_type_for_kind(protocol_kind: str) -> str:
|
||||
if protocol_kind == PROTOCOL_KIND_DONATION:
|
||||
return TASK_TYPE_DONATION
|
||||
return TASK_TYPE_CELL
|
||||
|
||||
|
||||
def _sample_type_name_by_code(
|
||||
db: Session, *, tenant_no: str, samples: Iterable[Any]
|
||||
) -> Dict[str, str]:
|
||||
"""按样本编码取样本类型名称。无类型或类型已删除时为空串。"""
|
||||
rows = list(samples or [])
|
||||
type_ids = {
|
||||
(getattr(sample, "sample_type_id", None) or "").strip()
|
||||
for sample in rows
|
||||
if (getattr(sample, "sample_type_id", None) or "").strip()
|
||||
}
|
||||
name_by_id: Dict[str, str] = {}
|
||||
if type_ids:
|
||||
type_rows = (
|
||||
db.query(BizCellSampleType)
|
||||
.filter(
|
||||
BizCellSampleType.id.in_(list(type_ids)),
|
||||
BizCellSampleType.tenant_no == tenant_no,
|
||||
BizCellSampleType.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
name_by_id = {
|
||||
row.id: (row.type_name or "").strip() for row in type_rows
|
||||
}
|
||||
out: Dict[str, str] = {}
|
||||
for sample in rows:
|
||||
code = (getattr(sample, "sample_code", None) or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
type_id = (getattr(sample, "sample_type_id", None) or "").strip()
|
||||
out[code] = name_by_id.get(type_id, "") if type_id else ""
|
||||
return out
|
||||
|
||||
|
||||
def _biz_id(sample_code: str, user_id: str) -> str:
|
||||
return f"{(sample_code or '').strip()}:{(user_id or '').strip()}"
|
||||
|
||||
|
||||
def _already_success(
|
||||
db: Session, *, tenant_no: str, sample_code: str, user_id: str
|
||||
) -> bool:
|
||||
row = (
|
||||
db.query(BizWxNotifyLog)
|
||||
.filter(
|
||||
BizWxNotifyLog.tenant_no == tenant_no,
|
||||
BizWxNotifyLog.biz_type == NOTIFY_BIZ_TYPE,
|
||||
BizWxNotifyLog.biz_id == _biz_id(sample_code, user_id),
|
||||
BizWxNotifyLog.notify_date == NOTIFY_DATE_ONCE,
|
||||
BizWxNotifyLog.status == "success",
|
||||
BizWxNotifyLog.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
def _write_success_log(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
sample_code: str,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
db.add(
|
||||
BizWxNotifyLog(
|
||||
id=str(uuid.uuid4()).replace("-", "").upper(),
|
||||
biz_type=NOTIFY_BIZ_TYPE,
|
||||
biz_id=_biz_id(sample_code, user_id),
|
||||
notify_date=NOTIFY_DATE_ONCE,
|
||||
user_id=user_id or "",
|
||||
status="success",
|
||||
errcode=None,
|
||||
errmsg=None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark="",
|
||||
create_by=user_id or "",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id or "",
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def list_notify_recipient_users(db: Session, *, tenant_no: str) -> List[SysUser]:
|
||||
"""有效拥有「样本采集通知人员」角色且已绑定服务号的用户。
|
||||
|
||||
有效角色 = 用户直接绑角色 ∪ 经启用职位继承(sys_user_post → sys_post → sys_post_role),
|
||||
与登录权限解析一致。
|
||||
"""
|
||||
role_name = _role_name()
|
||||
user_ids = list_user_ids_with_effective_role_name(
|
||||
db, tenant_no=tenant_no, role_name=role_name
|
||||
)
|
||||
if not user_ids:
|
||||
return []
|
||||
rows = (
|
||||
db.query(SysUser)
|
||||
.filter(
|
||||
SysUser.id.in_(list(user_ids)),
|
||||
SysUser.tenant_no == tenant_no,
|
||||
SysUser.del_flag == 0,
|
||||
SysUser.wx_mp_openid.isnot(None),
|
||||
SysUser.wx_mp_openid != "",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
# 按 user_id 已去重;再按 openid 去重,避免一人多职位/多账号同微信重复推送
|
||||
seen_openid: Set[str] = set()
|
||||
out: List[SysUser] = []
|
||||
for u in rows:
|
||||
oid = (getattr(u, "wx_mp_openid", None) or "").strip()
|
||||
if not oid or oid in seen_openid:
|
||||
continue
|
||||
seen_openid.add(oid)
|
||||
out.append(u)
|
||||
return out
|
||||
|
||||
|
||||
def _load_cell_context(
|
||||
db: Session, *, protocol_id: str, tenant_no: str, new_codes: List[str]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
protocol = (
|
||||
db.query(BizCollectionProtocol)
|
||||
.filter(
|
||||
BizCollectionProtocol.id == protocol_id,
|
||||
BizCollectionProtocol.tenant_no == tenant_no,
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not protocol:
|
||||
return None
|
||||
samples = (
|
||||
db.query(BizProtocolSample)
|
||||
.filter(
|
||||
BizProtocolSample.collection_protocol_id == protocol_id,
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolSample.sample_code.in_(new_codes),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
code_to_id = {
|
||||
(s.sample_code or "").strip(): s.id
|
||||
for s in samples
|
||||
if (s.sample_code or "").strip()
|
||||
}
|
||||
sample_ids = list(code_to_id.values())
|
||||
eta_by_code: Dict[str, Optional[datetime]] = {c: None for c in new_codes}
|
||||
if sample_ids:
|
||||
transports = (
|
||||
db.query(BizProtocolSampleTransport)
|
||||
.filter(
|
||||
BizProtocolSampleTransport.collection_protocol_id == protocol_id,
|
||||
BizProtocolSampleTransport.tenant_no == tenant_no,
|
||||
BizProtocolSampleTransport.del_flag == 0,
|
||||
BizProtocolSampleTransport.sample_id.in_(sample_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
id_to_code = {sid: code for code, sid in code_to_id.items()}
|
||||
for t in transports:
|
||||
code = id_to_code.get((t.sample_id or "").strip()) or (
|
||||
getattr(t, "sample_code", None) or ""
|
||||
).strip()
|
||||
if code and code in eta_by_code and t.eta_time and eta_by_code[code] is None:
|
||||
eta_by_code[code] = t.eta_time
|
||||
creator = None
|
||||
if protocol.create_by:
|
||||
creator = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == protocol.create_by, SysUser.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
return {
|
||||
"create_time": protocol.create_time,
|
||||
"applicant": (creator.nick_name if creator else "") or "",
|
||||
"eta_by_code": eta_by_code,
|
||||
"protocol_status": (protocol.protocol_status or "").strip(),
|
||||
"sample_status_by_code": _sample_status_by_code(samples),
|
||||
"sample_type_by_code": _sample_type_name_by_code(
|
||||
db, tenant_no=tenant_no, samples=samples
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _load_donation_context(
|
||||
db: Session, *, protocol_id: str, tenant_no: str, new_codes: List[str]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
donation = (
|
||||
db.query(BizResearchDonation)
|
||||
.filter(
|
||||
BizResearchDonation.id == protocol_id,
|
||||
BizResearchDonation.tenant_no == tenant_no,
|
||||
BizResearchDonation.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not donation:
|
||||
return None
|
||||
samples = (
|
||||
db.query(BizResearchDonationSample)
|
||||
.filter(
|
||||
BizResearchDonationSample.research_donation_id == protocol_id,
|
||||
BizResearchDonationSample.tenant_no == tenant_no,
|
||||
BizResearchDonationSample.del_flag == 0,
|
||||
BizResearchDonationSample.sample_code.in_(new_codes),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
code_to_id = {
|
||||
(s.sample_code or "").strip(): s.id
|
||||
for s in samples
|
||||
if (s.sample_code or "").strip()
|
||||
}
|
||||
sample_ids = list(code_to_id.values())
|
||||
eta_by_code: Dict[str, Optional[datetime]] = {c: None for c in new_codes}
|
||||
if sample_ids:
|
||||
transports = (
|
||||
db.query(BizResearchDonationSampleTransport)
|
||||
.filter(
|
||||
BizResearchDonationSampleTransport.research_donation_id
|
||||
== protocol_id,
|
||||
BizResearchDonationSampleTransport.tenant_no == tenant_no,
|
||||
BizResearchDonationSampleTransport.del_flag == 0,
|
||||
BizResearchDonationSampleTransport.sample_id.in_(sample_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
id_to_code = {sid: code for code, sid in code_to_id.items()}
|
||||
for t in transports:
|
||||
code = id_to_code.get((t.sample_id or "").strip()) or (
|
||||
getattr(t, "sample_code", None) or ""
|
||||
).strip()
|
||||
if code and code in eta_by_code and t.eta_time and eta_by_code[code] is None:
|
||||
eta_by_code[code] = t.eta_time
|
||||
creator = None
|
||||
if donation.create_by:
|
||||
creator = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == donation.create_by, SysUser.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
return {
|
||||
"create_time": donation.create_time,
|
||||
"applicant": (creator.nick_name if creator else "") or "",
|
||||
"eta_by_code": eta_by_code,
|
||||
"protocol_status": (donation.donation_status or "").strip(),
|
||||
"sample_status_by_code": _sample_status_by_code(samples),
|
||||
"sample_type_by_code": _sample_type_name_by_code(
|
||||
db, tenant_no=tenant_no, samples=samples
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def run_sample_collect_notify(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
protocol_id: str,
|
||||
protocol_kind: str,
|
||||
new_sample_codes: Iterable[str],
|
||||
) -> Dict[str, int]:
|
||||
"""对新增样本向角色用户发送通知。返回计数。"""
|
||||
stats = {"codes": 0, "sent": 0, "skipped": 0, "failed": 0}
|
||||
if not _notify_enabled():
|
||||
logger.info("sample collect notify skipped: notify disabled or no template")
|
||||
return stats
|
||||
|
||||
codes = [c for c in (str(x).strip() for x in new_sample_codes) if c]
|
||||
if not codes:
|
||||
return stats
|
||||
stats["codes"] = len(codes)
|
||||
tenant = (tenant_no or "").strip() or "zkxw"
|
||||
kind = (protocol_kind or "").strip() or PROTOCOL_KIND_CELL
|
||||
|
||||
if kind == PROTOCOL_KIND_DONATION:
|
||||
ctx = _load_donation_context(
|
||||
db, protocol_id=protocol_id, tenant_no=tenant, new_codes=codes
|
||||
)
|
||||
else:
|
||||
ctx = _load_cell_context(
|
||||
db, protocol_id=protocol_id, tenant_no=tenant, new_codes=codes
|
||||
)
|
||||
if not ctx:
|
||||
logger.info(
|
||||
"sample collect notify skip: protocol missing id=%s kind=%s",
|
||||
protocol_id,
|
||||
kind,
|
||||
)
|
||||
return stats
|
||||
|
||||
protocol_status = (ctx.get("protocol_status") or "").strip()
|
||||
if protocol_status_blocks_notify(protocol_status):
|
||||
logger.info(
|
||||
"sample collect notify skip: inactive protocol id=%s kind=%s status=%s",
|
||||
protocol_id,
|
||||
kind,
|
||||
protocol_status,
|
||||
)
|
||||
stats["skipped"] = len(codes)
|
||||
return stats
|
||||
|
||||
recipients = list_notify_recipient_users(db, tenant_no=tenant)
|
||||
if not recipients:
|
||||
logger.info(
|
||||
"sample collect notify skip: no recipients tenant=%s role=%s",
|
||||
tenant,
|
||||
_role_name(),
|
||||
)
|
||||
return stats
|
||||
|
||||
from app.services.wechat_mp_message import send_sample_collect_notify
|
||||
|
||||
applicant = ctx.get("applicant") or ""
|
||||
time_text = format_protocol_create_time(ctx.get("create_time"))
|
||||
task_type = _task_type_for_kind(kind)
|
||||
eta_by_code: Dict[str, Optional[datetime]] = ctx.get("eta_by_code") or {}
|
||||
sample_type_by_code: Dict[str, str] = ctx.get("sample_type_by_code") or {}
|
||||
sample_status_by_code: Dict[str, str] = ctx.get("sample_status_by_code") or {}
|
||||
|
||||
for code in codes:
|
||||
if sample_status_blocks_notify(sample_status_by_code.get(code)):
|
||||
logger.info(
|
||||
"sample collect notify skip: voided sample code=%s protocol=%s",
|
||||
code,
|
||||
protocol_id,
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
title = format_sample_collect_title(eta_by_code.get(code))
|
||||
sample_type = (sample_type_by_code.get(code) or "").strip()
|
||||
for user in recipients:
|
||||
uid = (user.id or "").strip()
|
||||
mp = (getattr(user, "wx_mp_openid", None) or "").strip()
|
||||
if not uid or not mp:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
if _already_success(db, tenant_no=tenant, sample_code=code, user_id=uid):
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
result = send_sample_collect_notify(
|
||||
mp_openid=mp,
|
||||
title=title,
|
||||
applicant=applicant,
|
||||
time_text=time_text,
|
||||
task_type=task_type,
|
||||
sample_type=sample_type,
|
||||
pagepath="",
|
||||
)
|
||||
if result.get("success"):
|
||||
_write_success_log(
|
||||
db, tenant_no=tenant, sample_code=code, user_id=uid
|
||||
)
|
||||
stats["sent"] += 1
|
||||
else:
|
||||
logger.error(
|
||||
"sample collect notify failed code=%s user=%s err=%s",
|
||||
code,
|
||||
uid,
|
||||
result,
|
||||
)
|
||||
stats["failed"] += 1
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("sample collect notify commit failed")
|
||||
raise
|
||||
return stats
|
||||
|
||||
|
||||
def notify_sample_collect_sync(
|
||||
*,
|
||||
tenant_no: str,
|
||||
protocol_id: str,
|
||||
protocol_kind: str,
|
||||
new_sample_codes: List[str],
|
||||
) -> None:
|
||||
db = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
stats = run_sample_collect_notify(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=protocol_id,
|
||||
protocol_kind=protocol_kind,
|
||||
new_sample_codes=new_sample_codes,
|
||||
)
|
||||
logger.info(
|
||||
"sample collect notify done protocol=%s kind=%s stats=%s",
|
||||
protocol_id,
|
||||
protocol_kind,
|
||||
stats,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"sample collect notify batch failed protocol=%s kind=%s",
|
||||
protocol_id,
|
||||
protocol_kind,
|
||||
)
|
||||
finally:
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def schedule_sample_collect_notify(
|
||||
*,
|
||||
tenant_no: str,
|
||||
protocol_id: str,
|
||||
protocol_kind: str,
|
||||
before_codes: Iterable[str],
|
||||
submitted_codes: Optional[Iterable[str]],
|
||||
) -> None:
|
||||
"""保存成功后调度:submitted_codes 为 None 表示本次未改样本,不推。"""
|
||||
if not _notify_enabled():
|
||||
logger.info("sample collect notify schedule skipped: disabled")
|
||||
return
|
||||
if submitted_codes is None:
|
||||
return
|
||||
new_codes = diff_new_sample_codes(
|
||||
before_codes=before_codes, submitted_codes=submitted_codes
|
||||
)
|
||||
if not new_codes:
|
||||
return
|
||||
pid = (protocol_id or "").strip()
|
||||
if not pid:
|
||||
return
|
||||
threading.Thread(
|
||||
target=notify_sample_collect_sync,
|
||||
kwargs={
|
||||
"tenant_no": (tenant_no or "").strip() or "zkxw",
|
||||
"protocol_id": pid,
|
||||
"protocol_kind": (protocol_kind or "").strip() or PROTOCOL_KIND_CELL,
|
||||
"new_sample_codes": new_codes,
|
||||
},
|
||||
daemon=True,
|
||||
name="sample-collect-wx-notify",
|
||||
).start()
|
||||
@@ -85,7 +85,7 @@ def _user_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(SysUser.id, SysUser.nick_name)
|
||||
.filter(SysUser.id.in_(ids), SysUser.del_flag == 0)
|
||||
.filter(SysUser.id.in_(ids))
|
||||
.all()
|
||||
)
|
||||
return {str(row[0]): (row[1] or "") for row in rows}
|
||||
|
||||
@@ -57,6 +57,7 @@ def loads_payload(raw: Optional[str]) -> Dict[str, Any]:
|
||||
def empty_detail(
|
||||
*,
|
||||
sample_code: str,
|
||||
lab_flow_code: str = "",
|
||||
protocol_code: str = "",
|
||||
mother_name: str = "",
|
||||
sample_type_name: str = "",
|
||||
@@ -66,7 +67,7 @@ def empty_detail(
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"sampleCode": sample_code,
|
||||
"labFlowCode": sample_code,
|
||||
"labFlowCode": lab_flow_code or "",
|
||||
"protocolCode": protocol_code or "",
|
||||
"motherName": mother_name or "",
|
||||
"sampleTypeName": sample_type_name or "",
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
"""储存证质检信息:从已有业务数据补空字段。读不到的键不写入。
|
||||
|
||||
阴阳性只取检测参数里的阴/阳,不根据任务状态或结论推断;参数未填则留空。
|
||||
细菌检测默认「阴性」。围产期检测结果评论、脐带检测结果评判默认「合格」。
|
||||
同一采集协议下其他样本的母血可补当前样本缺的母亲血液项;本样本已有结果优先。不共享脐血。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import (
|
||||
BizCellProduct,
|
||||
BizProductionTask,
|
||||
BizProductionTaskOutput,
|
||||
BizProductionWorkOrder,
|
||||
BizProtocolCollectionInfo,
|
||||
BizProtocolSample,
|
||||
BizQcTask,
|
||||
BizQcTaskResult,
|
||||
BizQcWorkOrder,
|
||||
BizQcWorkOrderInspectionBatch,
|
||||
BizSampleProductionReception,
|
||||
BizSampleReception,
|
||||
)
|
||||
|
||||
_FIXED_COMMON = {"storage_years": "20年"}
|
||||
_FIXED_UC_MSC = {
|
||||
"available_cell_type": "间充质干细胞",
|
||||
"available_cell_morphology": "短梭/贴壁型",
|
||||
"available_immunophenotype": "符合间充质干细胞表型特性",
|
||||
"freeze_method": "程序降温盒降温",
|
||||
"storage_temp": "-196℃",
|
||||
}
|
||||
|
||||
|
||||
def _compact(name: Optional[str]) -> str:
|
||||
text = (name or "").strip().lower().replace("+", "+")
|
||||
for ch in " \t\r\n**//()()【】\"'“”+":
|
||||
text = text.replace(ch, "")
|
||||
return text
|
||||
|
||||
|
||||
def match_cd34_param(param_name: Optional[str]) -> Optional[str]:
|
||||
"""CD34+细胞流式参数名 → 储存证字段。CD45dim 优先于 CD34 总数。"""
|
||||
compact = _compact(param_name)
|
||||
if not compact:
|
||||
return None
|
||||
if "cd45dim" in compact:
|
||||
return "cd34_cd45dim_1e5"
|
||||
if "tnc" in compact or "有核细胞" in compact:
|
||||
return "tnc_1e8"
|
||||
if "cd34" in compact and "cd45" not in compact:
|
||||
return "cd34_count_1e6"
|
||||
return None
|
||||
|
||||
|
||||
def is_cd34_flow_item(item_name: Optional[str]) -> bool:
|
||||
compact = _compact(item_name)
|
||||
return "cd34" in compact and "流式" in compact
|
||||
|
||||
|
||||
def match_process_qc_field(item_name: Optional[str]) -> Optional[str]:
|
||||
name = item_name or ""
|
||||
if "内毒素" in name:
|
||||
return "endotoxin_test"
|
||||
if "真菌" in name:
|
||||
return "fungus_test"
|
||||
if "支原体" in name:
|
||||
return "mycoplasma_test"
|
||||
return None
|
||||
|
||||
|
||||
def qual_yin_yang(values: Iterable[str]) -> str:
|
||||
"""只接受明确的阴/阳。通过、未检出、阴阳性同时出现都留空。"""
|
||||
found = set()
|
||||
for raw in values:
|
||||
text = (raw or "").strip()
|
||||
if text in ("阴", "阴性"):
|
||||
found.add("阴")
|
||||
elif text in ("阳", "阳性"):
|
||||
found.add("阳")
|
||||
if len(found) == 1:
|
||||
return next(iter(found))
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_negative_positive(value: Optional[str]) -> str:
|
||||
"""储存证阴阳性下拉用「阴性 / 阳性」。"""
|
||||
text = (value or "").strip()
|
||||
if text in ("阳", "阳性"):
|
||||
return "阳性"
|
||||
if text in ("阴", "阴性"):
|
||||
return "阴性"
|
||||
return ""
|
||||
|
||||
|
||||
def _fold_name(name: Optional[str]) -> str:
|
||||
return (name or "").replace(" ", "").replace(" ", "").upper()
|
||||
|
||||
|
||||
def batch_kind_from_name(batch_name: Optional[str]) -> str:
|
||||
"""检测批次名含母血、脐血时区分储存证来源。母血优先,避免名称里同时出现时串项。"""
|
||||
folded = _fold_name(batch_name)
|
||||
if "母血" in folded:
|
||||
return "母血"
|
||||
if "脐血" in folded:
|
||||
return "脐血"
|
||||
return ""
|
||||
|
||||
|
||||
def _first_negative_positive(values: Iterable[str]) -> str:
|
||||
found = []
|
||||
for raw in values:
|
||||
qual = normalize_negative_positive(raw)
|
||||
if qual and qual not in found:
|
||||
found.append(qual)
|
||||
if "阳性" in found:
|
||||
return "阳性"
|
||||
if "阴性" in found:
|
||||
return "阴性"
|
||||
return ""
|
||||
|
||||
|
||||
def _item_has(item_name: Optional[str], *parts: str) -> bool:
|
||||
folded = _fold_name(item_name)
|
||||
return all(part.upper() in folded for part in parts)
|
||||
|
||||
|
||||
def _param_surface_antigen(param_name: Optional[str]) -> bool:
|
||||
folded = _fold_name(param_name)
|
||||
return "表面抗原" in folded and "抗体" not in folded
|
||||
|
||||
|
||||
# (字段, 批次, 项目是否命中, 参数是否命中;参数为空则取该项目里能识别的阴/阳)
|
||||
_HSC_SEROLOGY_RULES = (
|
||||
("hbsag", "脐血", lambda name: _item_has(name, "乙肝五项"), _param_surface_antigen),
|
||||
("anti_hcv", "脐血", lambda name: _item_has(name, "抗丙型肝炎病毒抗体"), None),
|
||||
("anti_tp", "脐血", lambda name: _item_has(name, "梅毒", "抗体"), None),
|
||||
("anti_hiv", "脐血", lambda name: _item_has(name, "抗人类免疫缺陷病毒抗体"), None),
|
||||
("cmv_igm", "脐血", lambda name: _item_has(name, "巨细胞") and _item_has(name, "IGM"), None),
|
||||
("mother_hbsag", "母血", lambda name: _item_has(name, "乙肝五项"), _param_surface_antigen),
|
||||
(
|
||||
"mother_hbv_dna",
|
||||
"母血",
|
||||
lambda name: _item_has(name, "乙型肝炎病毒DNA") or _item_has(name, "乙型肝炎病毒核酸"),
|
||||
None,
|
||||
),
|
||||
("mother_anti_hcv", "母血", lambda name: _item_has(name, "抗丙型肝炎病毒抗体"), None),
|
||||
(
|
||||
"mother_hcv_rna",
|
||||
"母血",
|
||||
lambda name: _item_has(name, "丙型肝炎病毒RNA") or _item_has(name, "丙型肝炎病毒核酸"),
|
||||
None,
|
||||
),
|
||||
("mother_anti_tp", "母血", lambda name: _item_has(name, "梅毒", "抗体"), None),
|
||||
("mother_cmv_igm", "母血", lambda name: _item_has(name, "巨细胞") and _item_has(name, "IGM"), None),
|
||||
("mother_anti_hiv", "母血", lambda name: _item_has(name, "抗人类免疫缺陷病毒抗体"), None),
|
||||
(
|
||||
"mother_hiv1_rna",
|
||||
"母血",
|
||||
lambda name: _item_has(name, "人类免疫缺陷病毒", "核酸") or _item_has(name, "HIV-1RNA") or _item_has(name, "HIV1RNA"),
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
_MOTHER_SEROLOGY_RULES = tuple(rule for rule in _HSC_SEROLOGY_RULES if str(rule[0]).startswith("mother_"))
|
||||
|
||||
|
||||
def _serology_from_tasks(
|
||||
tasks: List[Dict[str, Any]],
|
||||
rules=_HSC_SEROLOGY_RULES,
|
||||
) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
for field, kind, item_ok, param_ok in rules:
|
||||
matched = [
|
||||
task
|
||||
for task in tasks
|
||||
if task.get("batch_kind") == kind and item_ok(task.get("item_name") or "")
|
||||
]
|
||||
if not matched:
|
||||
continue
|
||||
chosen = max(matched, key=lambda task: task["stamp"])
|
||||
values = []
|
||||
for param_name, param_value in chosen.get("params") or []:
|
||||
if param_ok is not None and not param_ok(param_name):
|
||||
continue
|
||||
values.append(param_value)
|
||||
qual = _first_negative_positive(values)
|
||||
if qual:
|
||||
out[field] = qual
|
||||
return out
|
||||
|
||||
|
||||
def format_number(value: Any) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
number = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return ""
|
||||
text = format(number, "f")
|
||||
if "." in text:
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
return text
|
||||
|
||||
|
||||
def sum_cryo_volume(weight_g: Any, volume_ml: Any) -> str:
|
||||
"""富集后血量与冻存液用量都有才相加。"""
|
||||
if weight_g is None or volume_ml is None or weight_g == "" or volume_ml == "":
|
||||
return ""
|
||||
try:
|
||||
total = Decimal(str(weight_g)) + Decimal(str(volume_ml))
|
||||
except (InvalidOperation, ValueError):
|
||||
return ""
|
||||
return format_number(total)
|
||||
|
||||
|
||||
def format_datetime(value: Any) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M")
|
||||
return ""
|
||||
|
||||
|
||||
def format_date(value: Any) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d")
|
||||
return ""
|
||||
|
||||
|
||||
def merge_prefill(saved: Optional[Dict[str, Any]], suggested: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""已有非空内容保留,只补空格。"""
|
||||
out: Dict[str, Any] = dict(saved or {})
|
||||
for key, value in (suggested or {}).items():
|
||||
if value is None or str(value).strip() == "":
|
||||
continue
|
||||
current = out.get(key)
|
||||
if current is None or str(current).strip() == "":
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def _latest_task_params(tasks: List[Dict[str, Any]], predicate) -> List[str]:
|
||||
matched = [task for task in tasks if predicate(task)]
|
||||
if not matched:
|
||||
return []
|
||||
chosen = max(matched, key=lambda task: task["stamp"])
|
||||
return list(chosen["values"])
|
||||
|
||||
|
||||
def _cd34_from_tasks(tasks: List[Dict[str, Any]]) -> Dict[str, str]:
|
||||
flow_tasks = [task for task in tasks if is_cd34_flow_item(task["item_name"])]
|
||||
if not flow_tasks:
|
||||
return {}
|
||||
chosen = max(flow_tasks, key=lambda task: task["stamp"])
|
||||
out: Dict[str, str] = {}
|
||||
for param_name, param_value in chosen["params"]:
|
||||
field = match_cd34_param(param_name)
|
||||
text = (param_value or "").strip()
|
||||
if field and text and field not in out:
|
||||
out[field] = text
|
||||
return out
|
||||
|
||||
|
||||
def _process_qual_from_tasks(tasks: List[Dict[str, Any]]) -> Dict[str, str]:
|
||||
"""生产过程质检:内毒素、真菌、支原体。只认参数里的阴/阳,任务结论「通过」不算。"""
|
||||
out: Dict[str, str] = {}
|
||||
for field in ("endotoxin_test", "fungus_test", "mycoplasma_test"):
|
||||
values = _latest_task_params(
|
||||
tasks, lambda task, field=field: match_process_qc_field(task["item_name"]) == field
|
||||
)
|
||||
qual = _first_negative_positive(values)
|
||||
if qual:
|
||||
out[field] = qual
|
||||
return out
|
||||
|
||||
|
||||
def _fill_shared_mother(suggested: Dict[str, str], tasks: Optional[List[Dict[str, Any]]]) -> None:
|
||||
"""只补母亲血液空项。共享任务里的脐血不参与。"""
|
||||
shared = _serology_from_tasks(tasks or [], _MOTHER_SEROLOGY_RULES)
|
||||
for key, value in shared.items():
|
||||
suggested.setdefault(key, value)
|
||||
|
||||
|
||||
def build_suggested(
|
||||
*,
|
||||
sample_type_key: str,
|
||||
collection_time: Any = None,
|
||||
reception_time: Any = None,
|
||||
receive_temperature: Any = None,
|
||||
enriched_blood_weight_g: Any = None,
|
||||
cryopreservation_volume_ml: Any = None,
|
||||
storage_total_tubes: Any = None,
|
||||
available_cell_count_1e7: Any = None,
|
||||
gate_tasks: Optional[List[Dict[str, Any]]] = None,
|
||||
process_tasks: Optional[List[Dict[str, Any]]] = None,
|
||||
shared_mother_tasks: Optional[List[Dict[str, Any]]] = None,
|
||||
latest_freeze_date: Any = None,
|
||||
) -> Dict[str, str]:
|
||||
suggested: Dict[str, str] = {}
|
||||
collected = format_datetime(collection_time)
|
||||
if collected:
|
||||
suggested["collection_time"] = collected
|
||||
received = format_datetime(reception_time)
|
||||
if received:
|
||||
suggested["receive_time"] = received
|
||||
freeze = format_date(latest_freeze_date)
|
||||
if sample_type_key == "perinatal_hsc":
|
||||
volume = sum_cryo_volume(enriched_blood_weight_g, cryopreservation_volume_ml)
|
||||
if volume:
|
||||
suggested["cryo_volume_ml"] = volume
|
||||
suggested.update(_cd34_from_tasks(gate_tasks or []))
|
||||
suggested.update(_serology_from_tasks(gate_tasks or []))
|
||||
suggested.setdefault("bacteria_test", "阴性")
|
||||
suggested.setdefault("result_comment", "合格")
|
||||
if freeze:
|
||||
suggested["storage_date"] = freeze
|
||||
suggested.update(_FIXED_COMMON)
|
||||
elif sample_type_key == "uc_msc":
|
||||
temp = ("" if receive_temperature is None else str(receive_temperature)).strip()
|
||||
if temp:
|
||||
suggested["transport_temp_c"] = temp
|
||||
tubes = format_number(storage_total_tubes)
|
||||
if tubes:
|
||||
suggested["storage_tube_count"] = tubes
|
||||
count = format_number(available_cell_count_1e7)
|
||||
if count:
|
||||
suggested["available_cell_count_1e7"] = count
|
||||
suggested.update(_process_qual_from_tasks(process_tasks or []))
|
||||
suggested.update(_serology_from_tasks(gate_tasks or [], _MOTHER_SEROLOGY_RULES))
|
||||
suggested.setdefault("bacteria_test", "阴性")
|
||||
suggested.setdefault("result_judgment", "合格")
|
||||
if freeze:
|
||||
suggested["storage_date_p2"] = freeze
|
||||
suggested.update(_FIXED_COMMON)
|
||||
suggested.update(_FIXED_UC_MSC)
|
||||
if sample_type_key in ("perinatal_hsc", "uc_msc"):
|
||||
_fill_shared_mother(suggested, shared_mother_tasks)
|
||||
return {key: value for key, value in suggested.items() if str(value).strip()}
|
||||
|
||||
|
||||
def _stamp(completed: Any, updated: Any) -> datetime:
|
||||
if isinstance(completed, datetime):
|
||||
return completed
|
||||
if isinstance(updated, datetime):
|
||||
return updated
|
||||
return datetime.min
|
||||
|
||||
|
||||
def _load_qc_tasks(db: Session, tenant_no: str, sample_code: str, trigger_source: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(
|
||||
BizQcTask.id,
|
||||
BizQcTask.item_name,
|
||||
BizQcTask.completed_time,
|
||||
BizQcTask.update_time,
|
||||
BizQcWorkOrderInspectionBatch.batch_name,
|
||||
BizQcTaskResult.param_name,
|
||||
BizQcTaskResult.param_value,
|
||||
)
|
||||
.join(BizQcWorkOrder, BizQcWorkOrder.id == BizQcTask.work_order_id)
|
||||
.outerjoin(
|
||||
BizQcWorkOrderInspectionBatch,
|
||||
BizQcWorkOrderInspectionBatch.id == BizQcTask.batch_id,
|
||||
)
|
||||
.outerjoin(
|
||||
BizQcTaskResult,
|
||||
(BizQcTaskResult.task_id == BizQcTask.id) & (BizQcTaskResult.del_flag == 0),
|
||||
)
|
||||
.filter(
|
||||
BizQcWorkOrder.tenant_no == tenant_no,
|
||||
BizQcWorkOrder.sample_code == sample_code,
|
||||
BizQcWorkOrder.del_flag == 0,
|
||||
BizQcWorkOrder.trigger_source == trigger_source,
|
||||
BizQcTask.tenant_no == tenant_no,
|
||||
BizQcTask.del_flag == 0,
|
||||
BizQcTask.task_status == "ywc",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
grouped: Dict[str, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
task = grouped.setdefault(
|
||||
row.id,
|
||||
{
|
||||
"item_name": row.item_name or "",
|
||||
"batch_kind": batch_kind_from_name(row.batch_name),
|
||||
"stamp": _stamp(row.completed_time, row.update_time),
|
||||
"params": [],
|
||||
"values": [],
|
||||
},
|
||||
)
|
||||
if (row.param_name or "").strip() or (row.param_value or "").strip():
|
||||
task["params"].append((row.param_name or "", row.param_value or ""))
|
||||
if (row.param_value or "").strip():
|
||||
task["values"].append(row.param_value or "")
|
||||
return list(grouped.values())
|
||||
|
||||
|
||||
def _sibling_sample_codes(db: Session, tenant_no: str, sample_code: str) -> List[str]:
|
||||
"""同一采集协议下的其他样本。母血通常只做在其中一份上。"""
|
||||
owner = (
|
||||
db.query(BizProtocolSample.collection_protocol_id)
|
||||
.filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.sample_code == sample_code,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
protocol_id = (owner.collection_protocol_id or "").strip() if owner else ""
|
||||
if not protocol_id:
|
||||
return []
|
||||
rows = (
|
||||
db.query(BizProtocolSample.sample_code)
|
||||
.filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.collection_protocol_id == protocol_id,
|
||||
BizProtocolSample.sample_code != sample_code,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
codes = []
|
||||
for row in rows:
|
||||
code = (row.sample_code or "").strip()
|
||||
if code and code not in codes:
|
||||
codes.append(code)
|
||||
return codes
|
||||
|
||||
|
||||
def _load_shared_mother_tasks(db: Session, tenant_no: str, sample_code: str) -> List[Dict[str, Any]]:
|
||||
tasks: List[Dict[str, Any]] = []
|
||||
for code in _sibling_sample_codes(db, tenant_no, sample_code):
|
||||
tasks.extend(
|
||||
task
|
||||
for task in _load_qc_tasks(db, tenant_no, code, "reception")
|
||||
if task.get("batch_kind") == "母血"
|
||||
)
|
||||
return tasks
|
||||
|
||||
|
||||
def _latest_freeze_date(db: Session, tenant_no: str, sample_code: str) -> Optional[datetime]:
|
||||
output_dates = (
|
||||
db.query(BizCellProduct.freeze_date)
|
||||
.join(BizProductionTaskOutput, BizProductionTaskOutput.cell_product_id == BizCellProduct.id)
|
||||
.join(BizProductionTask, BizProductionTask.id == BizProductionTaskOutput.task_id)
|
||||
.join(BizProductionWorkOrder, BizProductionWorkOrder.id == BizProductionTask.work_order_id)
|
||||
.filter(
|
||||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||||
BizProductionWorkOrder.sample_code == sample_code,
|
||||
BizProductionWorkOrder.del_flag == 0,
|
||||
BizProductionWorkOrder.work_order_status != "yzf",
|
||||
BizProductionTask.tenant_no == tenant_no,
|
||||
BizProductionTask.del_flag == 0,
|
||||
BizProductionTaskOutput.tenant_no == tenant_no,
|
||||
BizProductionTaskOutput.del_flag == 0,
|
||||
BizCellProduct.tenant_no == tenant_no,
|
||||
BizCellProduct.del_flag == 0,
|
||||
BizCellProduct.product_status != "voided",
|
||||
BizCellProduct.freeze_date.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
linked_dates = (
|
||||
db.query(BizCellProduct.freeze_date)
|
||||
.join(BizProductionTask, BizProductionTask.id == BizCellProduct.production_task_id)
|
||||
.join(BizProductionWorkOrder, BizProductionWorkOrder.id == BizProductionTask.work_order_id)
|
||||
.filter(
|
||||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||||
BizProductionWorkOrder.sample_code == sample_code,
|
||||
BizProductionWorkOrder.del_flag == 0,
|
||||
BizProductionWorkOrder.work_order_status != "yzf",
|
||||
BizProductionTask.tenant_no == tenant_no,
|
||||
BizProductionTask.del_flag == 0,
|
||||
BizCellProduct.tenant_no == tenant_no,
|
||||
BizCellProduct.del_flag == 0,
|
||||
BizCellProduct.product_status != "voided",
|
||||
BizCellProduct.freeze_date.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
dates = [row.freeze_date for row in output_dates + linked_dates if isinstance(row.freeze_date, datetime)]
|
||||
if not dates:
|
||||
return None
|
||||
return max(dates)
|
||||
|
||||
|
||||
def collect_prefill(db: Session, tenant_no: str, sample_code: str, sample_type_key: Optional[str]) -> Dict[str, str]:
|
||||
if sample_type_key not in ("perinatal_hsc", "uc_msc"):
|
||||
return {}
|
||||
code = (sample_code or "").strip()
|
||||
if not code:
|
||||
return {}
|
||||
collection = (
|
||||
db.query(BizProtocolCollectionInfo.sample_collect_time)
|
||||
.join(
|
||||
BizProtocolSample,
|
||||
BizProtocolSample.collection_protocol_id == BizProtocolCollectionInfo.collection_protocol_id,
|
||||
)
|
||||
.filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.sample_code == code,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolCollectionInfo.tenant_no == tenant_no,
|
||||
BizProtocolCollectionInfo.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
collection_time = collection.sample_collect_time if collection else None
|
||||
|
||||
reception = (
|
||||
db.query(BizSampleReception)
|
||||
.filter(
|
||||
BizSampleReception.tenant_no == tenant_no,
|
||||
BizSampleReception.sample_code == code,
|
||||
BizSampleReception.del_flag == 0,
|
||||
BizSampleReception.reception_source == "cell_storage",
|
||||
BizSampleReception.accept_conclusion == "js",
|
||||
)
|
||||
.order_by(BizSampleReception.reception_time.desc())
|
||||
.first()
|
||||
)
|
||||
production = None
|
||||
if reception is not None:
|
||||
production = (
|
||||
db.query(BizSampleProductionReception)
|
||||
.filter(
|
||||
BizSampleProductionReception.tenant_no == tenant_no,
|
||||
BizSampleProductionReception.reception_id == reception.id,
|
||||
BizSampleProductionReception.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return build_suggested(
|
||||
sample_type_key=sample_type_key,
|
||||
collection_time=collection_time,
|
||||
reception_time=None if reception is None else reception.reception_time,
|
||||
receive_temperature=None if reception is None else reception.receive_temperature,
|
||||
enriched_blood_weight_g=None if production is None else production.enriched_blood_weight_g,
|
||||
cryopreservation_volume_ml=None if production is None else production.cryopreservation_volume_ml,
|
||||
storage_total_tubes=None if production is None else production.storage_total_tubes,
|
||||
available_cell_count_1e7=None if production is None else production.available_cell_count_1e7,
|
||||
gate_tasks=_load_qc_tasks(db, tenant_no, code, "reception"),
|
||||
process_tasks=_load_qc_tasks(db, tenant_no, code, "production"),
|
||||
shared_mother_tasks=_load_shared_mother_tasks(db, tenant_no, code),
|
||||
latest_freeze_date=_latest_freeze_date(db, tenant_no, code),
|
||||
)
|
||||
@@ -234,3 +234,77 @@ def send_oa_applicant_progress_notify(
|
||||
data=data,
|
||||
pagepath=pagepath,
|
||||
)
|
||||
|
||||
|
||||
def send_basic_experiment_delivery_remind(
|
||||
*,
|
||||
mp_openid: str,
|
||||
customer_name: str,
|
||||
product_name: str,
|
||||
quantity: str,
|
||||
contact_name: str,
|
||||
time_text: str,
|
||||
pagepath: str = "",
|
||||
) -> dict:
|
||||
"""基础实验:交付日前一天出库提醒。"""
|
||||
data: Dict[str, Dict[str, str]] = {}
|
||||
customer_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER or "").strip()
|
||||
product_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT or "").strip()
|
||||
qty_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_QTY or "").strip()
|
||||
contact_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT or "").strip()
|
||||
time_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_TIME or "").strip()
|
||||
if customer_key:
|
||||
data[customer_key] = {"value": _truncate_thing(customer_name)}
|
||||
if product_key:
|
||||
data[product_key] = {"value": _truncate_thing(product_name)}
|
||||
if qty_key:
|
||||
data[qty_key] = {"value": (quantity or "").strip()}
|
||||
if contact_key:
|
||||
data[contact_key] = {"value": _truncate_thing(contact_name)}
|
||||
if time_key:
|
||||
data[time_key] = {"value": (time_text or "").strip()}
|
||||
return _send_template(
|
||||
mp_openid=mp_openid,
|
||||
template_id=settings.WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY,
|
||||
data=data,
|
||||
pagepath=pagepath or "",
|
||||
)
|
||||
|
||||
|
||||
def send_sample_collect_notify(
|
||||
*,
|
||||
mp_openid: str,
|
||||
title: str,
|
||||
applicant: str,
|
||||
time_text: str,
|
||||
task_type: str,
|
||||
sample_type: str = "",
|
||||
pagepath: str = "",
|
||||
) -> dict:
|
||||
"""协议新增样本:样本采集工单通知。"""
|
||||
data: Dict[str, Dict[str, str]] = {}
|
||||
title_key = (settings.WX_MP_TPL_SC_FIELD_TITLE or "").strip()
|
||||
applicant_key = (settings.WX_MP_TPL_SC_FIELD_APPLICANT or "").strip()
|
||||
time_key = (settings.WX_MP_TPL_SC_FIELD_TIME or "").strip()
|
||||
type_key = (settings.WX_MP_TPL_SC_FIELD_TYPE or "").strip()
|
||||
sample_type_key = (settings.WX_MP_TPL_SC_FIELD_SAMPLE_TYPE or "").strip()
|
||||
if title_key:
|
||||
data[title_key] = {"value": _truncate_thing(title)}
|
||||
if applicant_key:
|
||||
data[applicant_key] = {"value": _truncate_thing(applicant)}
|
||||
if time_key:
|
||||
data[time_key] = {"value": (time_text or "").strip()}
|
||||
if type_key:
|
||||
# phrase:纯汉字短词,不做 thing 截断以外的改写
|
||||
data[type_key] = {"value": (task_type or "").strip()}
|
||||
if sample_type_key:
|
||||
# thing 至少 1 个字符;没有样本类型时不传该字段,避免空值被微信拒绝
|
||||
sample_type_value = _truncate_thing(sample_type)
|
||||
if sample_type_value:
|
||||
data[sample_type_key] = {"value": sample_type_value}
|
||||
return _send_template(
|
||||
mp_openid=mp_openid,
|
||||
template_id=settings.WX_MP_TEMPLATE_SAMPLE_COLLECT,
|
||||
data=data,
|
||||
pagepath=pagepath or "",
|
||||
)
|
||||
@@ -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
+46871
-34126
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-29)
|
||||
|
||||
## Corpus Check
|
||||
- Large corpus: 312 files · ~381,667 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
- Large corpus: 320 files · ~394,483 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
|
||||
## Summary
|
||||
- 6406 nodes · 35474 edges · 43 communities detected
|
||||
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 24253 edges (avg confidence: 0.6)
|
||||
- 6609 nodes · 36569 edges · 44 communities detected
|
||||
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 25020 edges (avg confidence: 0.6)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
@@ -52,18 +52,19 @@
|
||||
- [[_COMMUNITY_Community 42|Community 42]]
|
||||
- [[_COMMUNITY_Community 43|Community 43]]
|
||||
- [[_COMMUNITY_Community 44|Community 44]]
|
||||
- [[_COMMUNITY_Community 47|Community 47]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `success_response()` - 834 edges
|
||||
2. `SysUser` - 736 edges
|
||||
3. `error_response()` - 703 edges
|
||||
4. `BizQcTaskResult` - 388 edges
|
||||
5. `BizProductionMaterial` - 372 edges
|
||||
6. `BizProductionMaterialUsage` - 365 edges
|
||||
7. `BizQcMaterialUsage` - 363 edges
|
||||
8. `BizQcMaterial` - 353 edges
|
||||
9. `BizProductionMaterialStockBatch` - 352 edges
|
||||
10. `BizMaterialUsage` - 349 edges
|
||||
1. `success_response()` - 842 edges
|
||||
2. `SysUser` - 756 edges
|
||||
3. `error_response()` - 710 edges
|
||||
4. `BizQcTaskResult` - 396 edges
|
||||
5. `BizProductionMaterial` - 379 edges
|
||||
6. `BizProductionMaterialUsage` - 371 edges
|
||||
7. `BizQcMaterialUsage` - 369 edges
|
||||
8. `BizQcMaterial` - 359 edges
|
||||
9. `BizProductionMaterialStockBatch` - 358 edges
|
||||
10. `BizMaterialUsage` - 355 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `统一记录 API 请求/响应出入参的中间件` --uses--> `SysApiRequestLog` [INFERRED]
|
||||
@@ -81,132 +82,132 @@
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.01
|
||||
Nodes (1171): _assert_user_tenant_access(), attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu() (+1163 more)
|
||||
Nodes (1175): _assert_user_tenant_access(), attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu() (+1167 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.01
|
||||
Nodes (830): validate_ymd(), BaseSettings, Config, secret_key_must_be_strong(), Settings, generate_uuid(), ApiRequestLogCRUD, delete() (+822 more)
|
||||
Cohesion: 0.0
|
||||
Nodes (901): BaseSettings, Config, secret_key_must_be_strong(), Settings, generate_uuid(), allocate_next_biz_no(), _allocate_sqlite_sequence(), _format_biz_no() (+893 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.01
|
||||
Nodes (730): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+722 more)
|
||||
Nodes (735): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+727 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.04
|
||||
Nodes (754): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+746 more)
|
||||
Cohesion: 0.07
|
||||
Nodes (479): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+471 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.01
|
||||
Nodes (402): _normalize_date_bound(), assert_protocol_sensitive_fields(), _attach_handler_name(), BatchRefundFinanceSubmitBody, create_collection_protocol(), delete_collection_protocol(), get_collection_protocol_detail(), _get_current_user_id() (+394 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (427): AuditCreateBody, BatchDetailIdsBody, BatchRefundFinanceSubmitBody, FinanceConfirmBody, FullmoonHandleBody, FullmoonTodoInsuranceBody, InvoiceRequestCompleteBody, InvoiceRequestCreateBody (+419 more)
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.01
|
||||
Nodes (390): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), _load_lineage_qc_for_nodes(), batch_review() (+382 more)
|
||||
Nodes (365): _normalize_date_bound(), assert_protocol_sensitive_fields(), create_collection_protocol(), _persist_protocol_sub_resources(), _resolve_consent_form_urls(), _resolve_health_survey_form_urls(), _resolve_protocol_attachments(), update_collection_protocol() (+357 more)
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.02
|
||||
Nodes (285): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), _create_version() (+277 more)
|
||||
Cohesion: 0.01
|
||||
Nodes (328): _body_to_payload(), _get_current_user_id(), get_production_reception_by_sample_code(), get_production_reception_detail(), _get_tenant_no(), post_production_reception(), put_production_reception(), _resolve_reception_id() (+320 more)
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.02
|
||||
Nodes (135): 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。, 服务号网页授权 code 绑定当前用户(需 Bearer)。, 微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, web_qr_confirm(), web_qr_scanned(), 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线) (+127 more)
|
||||
Nodes (312): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _app_to_dict(), _copied_app_name(), copy_app(), create_app(), create_group() (+304 more)
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.02
|
||||
Nodes (181): _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes(), create_cell_product() (+173 more)
|
||||
Cohesion: 0.01
|
||||
Nodes (256): list_ethnics_for_select(), get_designer_data(), apply_data_scope(), _get_child_dept_ids(), _resolve_user_role_scopes(), delete(), get_multi_with_filters(), count_pending_protocol_invoice_requests() (+248 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.03
|
||||
Nodes (138): list_ethnics_for_select(), apply_data_scope(), _get_child_dept_ids(), _resolve_user_role_scopes(), list_ethnics(), 民族码表 CRUD(全局 sys_ethnic), _row_to_dict(), create_leave() (+130 more)
|
||||
Cohesion: 0.02
|
||||
Nodes (188): _register_attachment_routes(), _register_freight_routes(), _register_payment_routes(), _register_presign_routes(), _register_protocol_routes(), mp_bind_status(), 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。 (+180 more)
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 0.03
|
||||
Nodes (109): _register_attachment_routes(), _register_freight_routes(), _register_payment_routes(), _register_presign_routes(), _register_protocol_routes(), _collect_descendants(), _current_user(), _find_mobile_root() (+101 more)
|
||||
Cohesion: 0.02
|
||||
Nodes (173): _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes(), create_cell_product() (+165 more)
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.03
|
||||
Nodes (140): _save_sample_payments(), deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。 (+132 more)
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.04
|
||||
Nodes (97): get_customer_service_dict(), _get_tenant_no(), _get_current_user_id(), get_sample_insurance(), get_sample_storage_cert(), _get_tenant_no(), put_sample_insurance(), put_sample_storage_cert() (+89 more)
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.06
|
||||
Nodes (103): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+95 more)
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 0.05
|
||||
Nodes (99): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+91 more)
|
||||
Cohesion: 0.06
|
||||
Nodes (105): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+97 more)
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.04
|
||||
Nodes (78): get_designer_data(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, BaseHTTPMiddleware, ApiCryptoError, _b64url_decode(), _b64url_encode(), decrypt_json() (+70 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (58): _error_json(), lifespan(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware, ApiCryptoError (+50 more)
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.05
|
||||
Nodes (89): _save_sample_payments(), add_transfer(), apply_fq_to_fgd_merge(), _assert_locked_schedule_structure(), assert_sample_payment_structure_editable(), _build_list_summary_row(), _build_periods_array(), _compute_progress_for_payment() (+81 more)
|
||||
Nodes (52): 验证密码(哈希非 bcrypt 或损坏时返回 False,避免抛错), verify_password(), 按行同步子表。 带 ID 的行原地修改,没有 ID 时按业务键对上尚未占用的原行,其余才新增。 本次提交里没有的活动行才软删除。不要整表软删后再插入,否则外键, 按关联 id 更新关系行。重新加回已删除的关联时恢复原行,避免唯一索引冲突。, _sort_key(), sync_link_rows(), _bound_user_names_by_post_ids(), create_post() (+44 more)
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.08
|
||||
Nodes (61): add_transfer(), assert_presign_convertible(), confirm_receipt(), confirmed_deposit_amount(), confirmed_deposit_amount_map(), convert_to_protocol(), create_presign_order(), _dec() (+53 more)
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.09
|
||||
Nodes (50): complete_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products(), _format_yyyy_mm_dd(), _generate_application_no(), get_application() (+42 more)
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.1
|
||||
Nodes (38): get_detail(), _load_sample_context(), _row_to_dict(), upsert(), _with_prefill(), dumps_payload(), empty_detail(), loads_payload() (+30 more)
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 0.11
|
||||
Nodes (35): batch_create_sterilize_logs(), batch_create_wash_logs(), batch_discard_garments(), batch_soft_delete_garments(), _count_map(), create_garment(), create_sterilize_log(), create_wash_log() (+27 more)
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 0.12
|
||||
Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块 支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3) Args: out_trade_no: 商户订单号 total, 申请退款 Args: out_trade_no: 商户订单号 total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API) Args: out_trade_no: 商户订单号 tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 0.14
|
||||
Nodes (25): _assert_routes_exist(), create_set(), create_set_from_work_order(), create_tasks_from_set(), delete_set(), get_set(), _item_count_map(), _item_to_dict() (+17 more)
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.17
|
||||
Nodes (23): apply_overtime_kit_hours(), _build_rest_day_segments(), _build_workday_windows(), calc_overtime_hours(), _day_at_minutes(), _day_start_ms(), _extract_rules(), _format_ymd() (+15 more)
|
||||
Cohesion: 0.13
|
||||
Nodes (23): batch_create_checkups(), _checkup_project_fee(), _checkup_project_name(), create_checkup(), delete_actual_item(), delete_reserve_item(), get_checkup_by_id(), list_actual_items() (+15 more)
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.16
|
||||
Nodes (22): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+14 more)
|
||||
Cohesion: 0.19
|
||||
Nodes (21): apply_overtime_kit_hours(), _build_rest_day_segments(), _build_workday_windows(), calc_overtime_hours(), _day_at_minutes(), _day_start_ms(), _extract_rules(), _format_ymd() (+13 more)
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 0.16
|
||||
Nodes (20): batch_create_checkups(), create_checkup(), delete_actual_item(), delete_reserve_item(), ensure_checkup_for_patient(), get_checkup_by_application(), get_checkup_by_id(), list_actual_items() (+12 more)
|
||||
Cohesion: 0.17
|
||||
Nodes (12): BusinessException, InvalidStatusTransitionException, log_delivery_operation(), log_operation(), log_order_operation(), log_settlement_operation(), log_user_operation(), OrderLockedException (+4 more)
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 0.21
|
||||
Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more)
|
||||
Cohesion: 0.2
|
||||
Nodes (19): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+11 more)
|
||||
|
||||
### Community 24 - "Community 24"
|
||||
Cohesion: 0.19
|
||||
Nodes (17): apply_trip_kit_days(), calc_trip_leg_days(), _day_index(), _end_offset(), _extract_rules(), is_half_day_multiple(), _kit_trip_fields(), 出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐) (+9 more)
|
||||
Nodes (18): _field_label(), _is_non_empty_str(), _is_required(), _is_valid_receive_account_item(), normalize_account_fields(), _pick(), _pick_order_num(), _pick_str() (+10 more)
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 0.25
|
||||
Nodes (15): create_protocol_refund_record(), delete_protocol_refund_record(), _get_current_user_id(), _get_tenant_no(), list_protocol_refund_records(), _protocol_accessible(), update_protocol_refund_record(), create_refund_record() (+7 more)
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.22
|
||||
Nodes (15): apply_trip_kit_days(), calc_trip_leg_days(), _day_index(), _end_offset(), _extract_rules(), is_half_day_multiple(), 出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐), 遍历 tripKit:行程未手改则覆盖 duration;出差天数未手改则覆盖为各行程时长之和。 (+7 more)
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 0.32
|
||||
Nodes (12): _attach_owner_names(), create_account(), _filtered_query(), get_account(), list_accounts(), list_options(), _owner_name_map(), OA 收款账户 CRUD(oa_receive_account) (+4 more)
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.29
|
||||
Nodes (11): get_detail(), _load_sample_context(), _row_to_dict(), upsert(), dumps_payload(), empty_detail(), loads_payload(), normalize_payload() (+3 more)
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 0.24
|
||||
Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 试剂变动记录 CRUD(biz_reagent_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减试剂库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
### Community 29 - "Community 29"
|
||||
Cohesion: 0.29
|
||||
Nodes (2): BaseModel, SoftDeleteModel
|
||||
|
||||
### Community 29 - "Community 29"
|
||||
### Community 30 - "Community 30"
|
||||
Cohesion: 0.38
|
||||
Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。 约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断; 若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
|
||||
|
||||
### Community 30 - "Community 30"
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 0.4
|
||||
Nodes (5): batch_matches_stock_status(), normalize_batch_stock_status(), 质检物料入库批次:按剩余数量过滤的语义辅助。, 纯函数判定(供单测);list_batches 用 SQL 等价条件。, 返回 'available' | 'exhausted';全部 / 空 / 非法 → None(不加剩余数量条件)。
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 0.6
|
||||
Nodes (5): _dec(), _fmt_dt(), list_stock_logs(), _row_to_dict(), _user_nick_name_map()
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
Cohesion: 0.5
|
||||
Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
|
||||
@@ -251,10 +252,14 @@ Nodes (1): 发送退款结果通知 Args: openid:
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送催单通知给配送员 Args: deliver_openid: 配送员openid
|
||||
|
||||
### Community 47 - "Community 47"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 接受 YYYYMMDD / YYYY-MM-DD,统一规范为 CHAR(8) YYYYMMDD。
|
||||
|
||||
## Knowledge Gaps
|
||||
- **208 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `严格判断当前租户用户是否拥有指定移动市场权限码。`, `为移动市场 API 提供租户化、精确匹配的菜单权限依赖。` (+203 more)
|
||||
- **213 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `严格判断当前租户用户是否拥有指定移动市场权限码。`, `为移动市场 API 提供租户化、精确匹配的菜单权限依赖。` (+208 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Community 28`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
|
||||
- **Thin community `Community 29`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 32`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
@@ -278,21 +283,23 @@ Nodes (1): 发送催单通知给配送员 Args: del
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 44`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 47`** (1 nodes): `接受 YYYYMMDD / YYYY-MM-DD,统一规范为 CHAR(8) YYYYMMDD。`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 21`, `Community 25`, `Community 27`?**
|
||||
_High betweenness centrality (0.109) - this node is a cross-community bridge._
|
||||
- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 4`, `Community 7`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 14`?**
|
||||
_High betweenness centrality (0.038) - this node is a cross-community bridge._
|
||||
- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 19`, `Community 21`, `Community 23`, `Community 25`, `Community 26`, `Community 27`?**
|
||||
_High betweenness centrality (0.025) - this node is a cross-community bridge._
|
||||
- **Are the 1181 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
|
||||
_`str` has 1181 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 833 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
|
||||
_`success_response()` has 833 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 734 inferred relationships involving `SysUser` (e.g. with `菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms)` and `运营端角色守卫:admin / super_admin 可访问。`) actually correct?**
|
||||
_`SysUser` has 734 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 702 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
|
||||
_`error_response()` has 702 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 15`, `Community 27`, `Community 28`?**
|
||||
_High betweenness centrality (0.118) - this node is a cross-community bridge._
|
||||
- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 12`, `Community 13`, `Community 25`?**
|
||||
_High betweenness centrality (0.050) - this node is a cross-community bridge._
|
||||
- **Why does `OaEngineError` connect `Community 7` to `Community 0`, `Community 1`, `Community 2`?**
|
||||
_High betweenness centrality (0.035) - this node is a cross-community bridge._
|
||||
- **Are the 1209 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
|
||||
_`str` has 1209 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 841 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
|
||||
_`success_response()` has 841 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 754 inferred relationships involving `SysUser` (e.g. with `菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms)` and `运营端角色守卫:admin / super_admin 可访问。`) actually correct?**
|
||||
_`SysUser` has 754 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 708 inferred relationships involving `ValueError` (e.g. with `secret_key_must_be_strong()` and `assert_protocol_status_transition()`) actually correct?**
|
||||
_`ValueError` has 708 INFERRED edges - model-reasoned connections that need verification._
|
||||
+57041
-42074
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": 1793153767.2755127,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/wechat_pay.py": 1778578258.0461762,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/api_request_log_middleware.py": 1779882001.753267,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/avatar_url.py": 1780540753.5481932,
|
||||
@@ -20,7 +20,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/qc_material_amount_auth_constants.py": 1783919118.6811988,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/context.py": 1779242263.6923044,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/utils.py": 1780305790.0009735,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/protocol_status_display.py": 1792196253.8018239,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/protocol_status_display.py": 1792646839.6074612,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/storage.py": 1786527936.515132,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/order_status_compat.py": 1778578258.0426462,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/core/exceptions.py": 1778578258.0413945,
|
||||
@@ -34,7 +34,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/material.py": 1782894693.6389399,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/production_material.py": 1782404882.9262497,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/qc_param.py": 1782379593.174925,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1792576366.7658954,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1792722215.456125,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/cleanroom_garment.py": 1781347080.6165943,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/cn_holiday.py": 1786373798.242758,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/__init__.py": 1791386246.9181905,
|
||||
@@ -44,7 +44,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/models/clinical_payment.py": 1779834956.9588954,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/storage_release_review.py": 1787110411.5106792,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/qc_task.py": 1779597960.4337523,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/process_route.py": 1787621857.4239202,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/process_route.py": 1793096085.6087615,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/auth.py": 1780539434.444314,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/delivery.py": 1778578258.0949655,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/outbound_release_review.py": 1779968230.0408957,
|
||||
@@ -57,14 +57,14 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_parameter_value.py": 1778578258.0980055,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_review_media.py": 1778578258.0986822,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_parameter.py": 1778578258.0977957,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/production_task.py": 1787712460.1154053,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/production_task.py": 1793096082.4957078,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_detail_media.py": 1778578258.0973988,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/product_banner.py": 1778578258.097102,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/region.py": 1778578258.099429,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/inquiry.py": 1778578258.095357,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/order.py": 1778578258.0960286,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/__init__.py": 1781502992.2721803,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/market_mobile.py": 1792578205.3646982,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/market_mobile.py": 1793155336.902657,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/order_adjust.py": 1778578258.0963318,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/cell_product.py": 1782711015.11309,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/schemas/menu.py": 1778578258.0957444,
|
||||
@@ -84,19 +84,19 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_release_review.py": 1787110718.4798927,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/equipment.py": 1782717312.9816003,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task.py": 1786958454.8959644,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/process_route.py": 1788231203.61016,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/process_route.py": 1793095936.5547593,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sf_express_settlement.py": 1792580020.6752806,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_code.py": 1782709076.5173395,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task_result.py": 1785289887.2915215,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_review.py": 1785314186.7271576,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_stock_check.py": 1781423988.9720724,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_code.py": 1793065268.5905912,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task_result.py": 1793097690.7125766,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_review.py": 1792646414.7740386,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_stock_check.py": 1793098731.3404455,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/material_stock_batch.py": 1782894693.7810533,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/post.py": 1785911428.7739174,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_result.py": 1779255909.8554008,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/post.py": 1793097928.8808393,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_result.py": 1793097696.0818002,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_inspection_item_template.py": 1786941802.105201,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_stock_batch.py": 1782404955.550011,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1791693092.544366,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_work_order.py": 1789366011.0738122,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1793097925.7885866,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_work_order.py": 1792646376.0415618,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/material.py": 1781522283.6438622,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/dict_type.py": 1779265621.5212462,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_occupancy.py": 1785237078.9580858,
|
||||
@@ -110,83 +110,86 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/material_usage.py": 1782894693.7808309,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_usage.py": 1781513732.7796721,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sop_document.py": 1779265721.9928973,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task.py": 1788158255.5391397,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task.py": 1793096185.7513871,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/inbound_sample_special.py": 1782890475.769446,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_third_party_item.py": 1779265661.482352,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_production_reception.py": 1788159570.8985534,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/reagent.py": 1779512669.9847593,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_inbound_lab_storage_sort.py": 1786959444.2857325,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1792199580.9285016,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1793097712.881502,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/cleanroom_garment.py": 1782264452.4901433,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/child_row_sync.py": 1793097946.2135875,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/temp_qc_target.py": 1787193516.0555077,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_usage.py": 1783428516.0973594,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_temporary_production_cluster_sort.py": 1792636895.7513638,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/biz_no_generator.py": 1791892839.5616264,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/presign_order.py": 1792223982.1589236,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/presign_order.py": 1793095962.5634658,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_month_template.py": 1787115871.8303692,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_info_quick_register.py": 1792377880.3840506,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_confirm.py": 1779828818.7646794,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_confirm.py": 1793065268.0816917,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/reagent_stock_log.py": 1779515479.6599197,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_schedule_mgmt.py": 1781604486.4315972,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_schedule_mgmt.py": 1793098096.204029,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/clean_room.py": 1781403189.1288755,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/__init__.py": 1778605691.3816392,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_stock_check.py": 1781427225.7574296,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_stock_check.py": 1793098717.924901,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/equipment_snapshot.py": 1781524050.998141,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_entry_sms_notification.py": 1781063498.05542,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/express_company.py": 1781402091.5987046,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/customer_service_dict.py": 1791859621.720187,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_application.py": 1781079895.8304482,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_application.py": 1792646362.7074053,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_lab_test_item.py": 1779265673.3888667,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pre_transit_sample.py": 1783921714.0966582,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/role.py": 1785912868.6779847,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/role.py": 1793097935.9497454,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_stock_batch.py": 1787017176.7612765,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/material_stock_check.py": 1781427194.5434632,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task_step_assignee.py": 1785909952.2598927,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/material_stock_check.py": 1793098703.7762063,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task_step_assignee.py": 1793065267.4658027,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_sms_notification.py": 1781590614.9654794,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_product_type.py": 1785145511.4485562,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task_assignee.py": 1781233180.3766735,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/research_donation_sample_collection.py": 1791187401.410944,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_amount_auth.py": 1785905319.9222975,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/oa_approval.py": 1786414220.5298004,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/cell_product.py": 1785289892.8170507,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/cell_product.py": 1792638856.8515265,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_refund_record.py": 1790996674.1676536,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/menu.py": 1779265729.0381103,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_region.py": 1791436619.5126424,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_delivery.py": 1779935673.6808827,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_delivery.py": 1793098096.2724218,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_item.py": 1779007909.2933013,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_storage_cert.py": 1791860094.4119813,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/site.py": 1781401290.6845422,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/api_request_log.py": 1778578258.0614743,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/dict_data.py": 1782893595.9312406,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/research_donation.py": 1792199678.6192756,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/research_donation.py": 1793097746.9649897,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_stock_in_no.py": 1787114586.3799527,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/ethnic.py": 1779265849.547566,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_freight.py": 1792579247.5842276,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_formula.py": 1781344175.5871844,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_checkup.py": 1779935785.233911,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_freight.py": 1793097752.573005,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_formula.py": 1793095965.716362,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_checkup.py": 1793097846.6982708,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/env_detect_item.py": 1781145689.9525824,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/process_category.py": 1779265700.0606413,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/item_unit.py": 1779265653.7504313,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_work_order.py": 1787193578.7385066,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_work_order.py": 1793094741.0946975,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_handover_inventory.py": 1781598316.476379,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/env_detect_record.py": 1786945487.5517714,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task_set.py": 1788233133.196318,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_material.py": 1781612877.0137436,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/process.py": 1787621642.2171764,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_service.py": 1781599314.0124536,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task_set.py": 1793097810.1320748,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_material.py": 1793097901.0378923,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/process.py": 1793095920.843898,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_service.py": 1793097887.8013425,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/customer.py": 1791648050.9604611,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/workflow.py": 1779265993.677624,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_customer_service.py": 1791860094.4207802,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/oa_receive_account.py": 1792579491.9561448,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_sms_notification.py": 1781063495.0317714,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception.py": 1791777806.124801,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/consumable_pack.py": 1779265715.416005,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_inspection_batch.py": 1786011049.96145,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_prep.py": 1781514953.3551314,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception.py": 1793097696.6803784,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/consumable_pack.py": 1793097793.9003072,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_inspection_batch.py": 1793065284.8892066,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_prep.py": 1793097985.9659176,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material.py": 1787114725.8477683,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol_no.py": 1779882001.753651,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_storage_cert_qc.py": 1791520567.195576,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/user_display.py": 1793065264.1457374,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol_no.py": 1793065269.7817523,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_storage_cert_qc.py": 1793009250.2649956,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_handover.py": 1780455574.0993364,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_facility.py": 1786945250.4117062,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/storage_facility.py": 1792638802.7313325,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/oa_instance_draft.py": 1792462713.458171,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_lab_code.py": 1783308340.5854337,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/cell_sample_type.py": 1781747645.1762104,
|
||||
@@ -197,8 +200,8 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_donation_convert.py": 1791786447.2823255,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_insurance.py": 1791860094.3744645,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_sample_collection.py": 1791860264.2204025,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_sample_payment.py": 1792202468.4610422,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_item_param.py": 1782380067.962216,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/protocol_sample_payment.py": 1793097761.4878137,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_item_param.py": 1793097679.9535193,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/leave.py": 1779265839.500242,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/output_type.py": 1779265703.7363207,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/exhibition_appointment.py": 1778801075.5211918,
|
||||
@@ -206,54 +209,56 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/storage_release_review.py": 1787110947.8175044,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/outbound_release_review.py": 1785303263.2759058,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/public.py": 1791692287.6797194,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/material.py": 1782894693.6582327,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/production_material.py": 1782404982.2474263,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/material.py": 1793098686.1687553,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/production_material.py": 1793098686.8677888,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/mini_menu.py": 1791861013.3286748,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/qc.py": 1787193588.7157009,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/qc.py": 1793098687.6486495,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/market.py": 1781522283.6823432,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_production_reception.py": 1788159565.164049,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/collection_protocol.py": 1792199682.804543,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/collection_protocol.py": 1793065269.7831242,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/cleanroom_garment.py": 1781347256.2326367,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/basic_info.py": 1791454077.2373106,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/presign_order.py": 1792079421.3688679,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/presign_order.py": 1793001629.236552,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_infusion_confirm.py": 1779657043.1389203,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_infusion_schedule_mgmt.py": 1779940938.18027,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/__init__.py": 1791860598.0884578,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/__init__.py": 1792634972.2299473,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/market_mobile.py": 1792578366.9390223,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/storage_entry_sms_notification.py": 1780325524.9170487,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/customer_service_dict.py": 1791859621.7209744,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_application.py": 1781079904.0516074,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_application.py": 1792653386.9872339,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pre_transit_sample.py": 1786938943.6929982,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/qc_sms_notification.py": 1780326875.7886882,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation_sample_collection.py": 1791187409.0427368,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/oa_approval.py": 1792579495.6813116,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/oa_approval.py": 1792722294.1293957,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_refund_record.py": 1792197256.603858,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/operations.py": 1785213144.2881503,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1792580899.7326677,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_delivery.py": 1779655584.820777,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_cert_insurance.py": 1791912978.5275548,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/admin.py": 1790217215.444356,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_cert_insurance.py": 1793001635.2987237,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/admin.py": 1793240569.7858844,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/env_detect.py": 1786945487.513935,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1792199703.3429806,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1793001640.0574386,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/ethnic.py": 1779266243.9511197,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_freight.py": 1792580031.1094093,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_checkup.py": 1779935751.2658868,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/mini_auth.py": 1789710909.065258,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_service.py": 1781599314.0221174,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_service.py": 1792641868.3677006,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_code_correction.py": 1792635822.5685863,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/workflow.py": 1790217176.7514315,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_sms_notification.py": 1780325524.907156,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_reception.py": 1782719108.3741593,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_storage_cert_qc.py": 1791912978.5283322,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_storage_cert_qc.py": 1792662212.518068,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/insurance_company.py": 1781410837.9443986,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/production.py": 1788234236.5398064,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/production.py": 1793096091.2640226,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_collection.py": 1792377889.4818213,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_payment.py": 1791913783.0750406,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_payment.py": 1793001632.8994496,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/leave.py": 1790217143.1910717,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/clinical_payment.py": 1781750841.074568,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1792580527.7126133,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1793065275.172498,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sf_express_settlement.py": 1792575921.313689,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_info_list_enrich.py": 1792376536.1772416,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_info_list_enrich.py": 1793065267.798019,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_period_summary.py": 1791799261.7547023,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_collect_wx_notify.py": 1793153978.1273012,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_datetime_normalize.py": 1792462649.0641665,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_overtime_hours.py": 1786373766.5113294,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_callback.py": 1790217147.1955774,
|
||||
@@ -264,6 +269,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/party_customer_sync.py": 1791648050.9613898,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_dept_select.py": 1786000695.5043027,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mini_auth.py": 1789710434.6386433,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_storage_cert_qc_prefill.py": 1793091264.6031697,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_publish.py": 1786001976.196836,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/cn_holiday_sync.py": 1786412469.104896,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_engine.py": 1790217176.6912422,
|
||||
@@ -279,7 +285,7 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_payment_installment.py": 1791921039.0872521,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_sample_list_summary.py": 1791187322.7863488,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_related_approval.py": 1787892816.3154016,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mp_message.py": 1786689383.1677845,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mp_message.py": 1793153773.5584576,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_leave_hours.py": 1785397787.0493405,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_transfer_status.py": 1791799767.0946853,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/payment_chase.py": 1791903365.3346775,
|
||||
@@ -287,25 +293,27 @@
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_sample.py": 1791712921.0372562,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_schedule_validate.py": 1791539753.4546175,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_info_progress.py": 1791187232.4075856,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_instance_comment.py": 1792134136.443889,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_instance_comment.py": 1792722268.265355,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_progress.py": 1792202352.3417196,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_resignation_kit_sync.py": 1785405020.285242,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mp_auth.py": 1786615330.0733685,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_support.py": 1792574711.417624,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_assignee.py": 1790665524.6969597,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_login_service.py": 1786615952.6840453,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_protocol.py": 1792392055.0493736,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_protocol.py": 1793154276.9704335,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_field_scope.py": 1791651783.1531875,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_code_correction.py": 1792634957.0385876,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/customer_relations.py": 1791383150.5354238,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_receive_account.py": 1792579495.1077259,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_presign.py": 1791713904.5883873,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_owner_fields.py": 1785401151.5200977,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_money_field.py": 1785380625.3526123,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_storage_cert_qc.py": 1791520419.7485287,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_storage_cert_qc.py": 1792722939.9336097,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_pay_mode_switch.py": 1791922115.8003666,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/basic_experiment_delivery_remind.py": 1792649506.2005453,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/market_freight.py": 1792578248.775886,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_post_select.py": 1785394392.5716882,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_wechat_notify.py": 1789108034.959046,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_wechat_notify.py": 1793065275.6481574,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_confirmation_kit_sync.py": 1785403667.4270058,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_fgd_convert.py": 1791525433.8399634,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_formula.py": 1785145511.513383,
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""基础实验交付日前一天服务号提醒单测。"""
|
||||
from datetime import date, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.services.basic_experiment_delivery_remind import (
|
||||
format_delivery_product_name,
|
||||
run_delivery_remind_for_date,
|
||||
)
|
||||
|
||||
|
||||
def test_format_delivery_product_name_full():
|
||||
assert (
|
||||
format_delivery_product_name(
|
||||
project_name="NK扩增",
|
||||
spec_text="1e8",
|
||||
package_text="冻存管",
|
||||
)
|
||||
== "NK扩增(1e8、冻存管)"
|
||||
)
|
||||
|
||||
|
||||
def test_format_delivery_product_name_name_and_spec_only():
|
||||
assert (
|
||||
format_delivery_product_name(
|
||||
project_name="NK扩增",
|
||||
spec_text="1e8",
|
||||
package_text="",
|
||||
)
|
||||
== "NK扩增(1e8)"
|
||||
)
|
||||
|
||||
|
||||
def test_format_delivery_product_name_name_and_package_only():
|
||||
assert (
|
||||
format_delivery_product_name(
|
||||
project_name="NK扩增",
|
||||
spec_text="",
|
||||
package_text="冻存管",
|
||||
)
|
||||
== "NK扩增(冻存管)"
|
||||
)
|
||||
|
||||
|
||||
def test_format_delivery_product_name_name_only():
|
||||
assert (
|
||||
format_delivery_product_name(
|
||||
project_name="NK扩增",
|
||||
spec_text="",
|
||||
package_text="",
|
||||
)
|
||||
== "NK扩增"
|
||||
)
|
||||
|
||||
|
||||
def test_format_delivery_product_name_empty():
|
||||
assert (
|
||||
format_delivery_product_name(
|
||||
project_name="",
|
||||
spec_text="",
|
||||
package_text="",
|
||||
)
|
||||
== ""
|
||||
)
|
||||
|
||||
|
||||
def test_send_basic_experiment_delivery_remind_builds_data():
|
||||
with patch(
|
||||
"app.services.wechat_mp_message._send_template"
|
||||
) as send_tpl, patch("app.services.wechat_mp_message.settings") as st:
|
||||
st.WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY = "tpl-be"
|
||||
st.WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER = "thing6"
|
||||
st.WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT = "thing10"
|
||||
st.WX_MP_TPL_BE_DELIVERY_FIELD_QTY = "character_string8"
|
||||
st.WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT = "thing7"
|
||||
st.WX_MP_TPL_BE_DELIVERY_FIELD_TIME = "time14"
|
||||
send_tpl.return_value = {"success": True, "msgid": "1"}
|
||||
|
||||
from app.services.wechat_mp_message import send_basic_experiment_delivery_remind
|
||||
|
||||
long_name = "这是一个超过二十个汉字的产品名称规格包装拼接"
|
||||
send_basic_experiment_delivery_remind(
|
||||
mp_openid="oid",
|
||||
customer_name="张三客户",
|
||||
product_name=long_name,
|
||||
quantity="3",
|
||||
contact_name="李联系人",
|
||||
time_text="2026-10-23 14:00",
|
||||
)
|
||||
assert send_tpl.called
|
||||
kwargs = send_tpl.call_args.kwargs
|
||||
assert kwargs["template_id"] == "tpl-be"
|
||||
assert kwargs["mp_openid"] == "oid"
|
||||
data = kwargs["data"]
|
||||
assert data["thing6"]["value"] == "张三客户"
|
||||
assert len(data["thing10"]["value"]) <= 20
|
||||
assert data["character_string8"]["value"] == "3"
|
||||
assert data["thing7"]["value"] == "李联系人"
|
||||
assert data["time14"]["value"] == "2026-10-23 14:00"
|
||||
|
||||
|
||||
def _make_db_for_remind(
|
||||
*,
|
||||
schedules,
|
||||
already_success=False,
|
||||
user_openid="mp-oid",
|
||||
):
|
||||
"""构造轻量 MagicMock Session,覆盖 remind 扫描路径。"""
|
||||
db = MagicMock()
|
||||
|
||||
candidate_q = MagicMock()
|
||||
candidate_q.join.return_value = candidate_q
|
||||
candidate_q.outerjoin.return_value = candidate_q
|
||||
candidate_q.filter.return_value = candidate_q
|
||||
candidate_q.order_by.return_value = candidate_q
|
||||
candidate_q.all.return_value = schedules
|
||||
|
||||
success_q = MagicMock()
|
||||
success_q.filter.return_value = success_q
|
||||
success_q.first.return_value = MagicMock() if already_success else None
|
||||
|
||||
user = MagicMock()
|
||||
user.wx_mp_openid = user_openid
|
||||
user_q = MagicMock()
|
||||
user_q.filter.return_value = user_q
|
||||
user_q.first.return_value = user
|
||||
|
||||
def query_side_effect(*models):
|
||||
from app.models.models import BizWxNotifyLog, SysUser
|
||||
|
||||
if len(models) > 1:
|
||||
return candidate_q
|
||||
model = models[0]
|
||||
if model is BizWxNotifyLog:
|
||||
return success_q
|
||||
if model is SysUser:
|
||||
return user_q
|
||||
return candidate_q
|
||||
|
||||
db.query.side_effect = query_side_effect
|
||||
return db
|
||||
|
||||
|
||||
def test_run_delivery_remind_sends_for_tomorrow_schedule():
|
||||
sched = MagicMock()
|
||||
sched.id = "sch-1"
|
||||
sched.tenant_no = "zkxw"
|
||||
sched.infusion_time = datetime(2026, 10, 23, 14, 0, 0)
|
||||
sched.spec_text = "1e8"
|
||||
sched.package_text = "冻存管"
|
||||
sched.schedule_status = "dsc"
|
||||
|
||||
app = MagicMock()
|
||||
app.id = "app-1"
|
||||
app.tenant_no = "zkxw"
|
||||
app.create_by = "user-1"
|
||||
app.customer_name = "客户甲"
|
||||
app.customer_contact = "联系人乙"
|
||||
|
||||
prod = MagicMock()
|
||||
prod.project_name = "NK扩增"
|
||||
prod.spec_text = "1e8"
|
||||
prod.package_text = "冻存管"
|
||||
prod.quantity = 2
|
||||
|
||||
db = _make_db_for_remind(schedules=[(sched, app, prod)])
|
||||
|
||||
with patch(
|
||||
"app.services.basic_experiment_delivery_remind._notify_enabled",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"app.services.wechat_mp_message.send_basic_experiment_delivery_remind"
|
||||
) as send_fn:
|
||||
send_fn.return_value = {"success": True, "msgid": "m1"}
|
||||
stats = run_delivery_remind_for_date(db, today=date(2026, 10, 22))
|
||||
|
||||
assert stats["candidates"] == 1
|
||||
assert stats["sent"] == 1
|
||||
assert send_fn.called
|
||||
kwargs = send_fn.call_args.kwargs
|
||||
assert kwargs["customer_name"] == "客户甲"
|
||||
assert kwargs["product_name"] == "NK扩增(1e8、冻存管)"
|
||||
assert kwargs["quantity"] == "2"
|
||||
assert kwargs["contact_name"] == "联系人乙"
|
||||
assert kwargs["time_text"] == "2026-10-23 14:00"
|
||||
assert db.add.called
|
||||
assert db.commit.called
|
||||
|
||||
|
||||
def test_run_delivery_remind_skips_when_already_success():
|
||||
sched = MagicMock()
|
||||
sched.id = "sch-1"
|
||||
sched.tenant_no = "zkxw"
|
||||
sched.infusion_time = datetime(2026, 10, 23, 14, 0, 0)
|
||||
sched.spec_text = ""
|
||||
sched.package_text = ""
|
||||
|
||||
app = MagicMock()
|
||||
app.id = "app-1"
|
||||
app.tenant_no = "zkxw"
|
||||
app.create_by = "user-1"
|
||||
app.customer_name = "客户甲"
|
||||
app.customer_contact = "联系人乙"
|
||||
|
||||
prod = MagicMock()
|
||||
prod.project_name = "NK"
|
||||
prod.spec_text = ""
|
||||
prod.package_text = ""
|
||||
prod.quantity = 1
|
||||
|
||||
db = _make_db_for_remind(
|
||||
schedules=[(sched, app, prod)], already_success=True
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.basic_experiment_delivery_remind._notify_enabled",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"app.services.wechat_mp_message.send_basic_experiment_delivery_remind"
|
||||
) as send_fn:
|
||||
send_fn.return_value = {"success": True}
|
||||
stats = run_delivery_remind_for_date(db, today=date(2026, 10, 22))
|
||||
|
||||
assert stats["skipped"] == 1
|
||||
assert stats["sent"] == 0
|
||||
assert not send_fn.called
|
||||
|
||||
|
||||
def test_run_delivery_remind_skips_without_openid():
|
||||
sched = MagicMock()
|
||||
sched.id = "sch-1"
|
||||
sched.tenant_no = "zkxw"
|
||||
sched.infusion_time = datetime(2026, 10, 23, 9, 0, 0)
|
||||
sched.spec_text = ""
|
||||
sched.package_text = ""
|
||||
|
||||
app = MagicMock()
|
||||
app.id = "app-1"
|
||||
app.tenant_no = "zkxw"
|
||||
app.create_by = "user-1"
|
||||
app.customer_name = "客户甲"
|
||||
app.customer_contact = "联系人乙"
|
||||
|
||||
prod = MagicMock()
|
||||
prod.project_name = "NK"
|
||||
prod.spec_text = ""
|
||||
prod.package_text = ""
|
||||
prod.quantity = 1
|
||||
|
||||
db = _make_db_for_remind(
|
||||
schedules=[(sched, app, prod)], user_openid=""
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.basic_experiment_delivery_remind._notify_enabled",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"app.services.wechat_mp_message.send_basic_experiment_delivery_remind"
|
||||
) as send_fn:
|
||||
stats = run_delivery_remind_for_date(db, today=date(2026, 10, 22))
|
||||
|
||||
assert stats["skipped"] == 1
|
||||
assert not send_fn.called
|
||||
|
||||
|
||||
def test_run_delivery_remind_noop_when_disabled():
|
||||
db = MagicMock()
|
||||
with patch(
|
||||
"app.services.basic_experiment_delivery_remind._notify_enabled",
|
||||
return_value=False,
|
||||
):
|
||||
stats = run_delivery_remind_for_date(db, today=date(2026, 10, 22))
|
||||
assert stats == {"candidates": 0, "sent": 0, "skipped": 0, "failed": 0}
|
||||
assert not db.query.called
|
||||
@@ -0,0 +1,51 @@
|
||||
"""细胞产品列表 keyword:支持储物格路径匹配。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.crud.storage_facility import search_slot_ids_by_keyword
|
||||
|
||||
|
||||
def test_search_slot_ids_by_keyword_blank_returns_empty():
|
||||
assert search_slot_ids_by_keyword(MagicMock(), "zkxw", "") == []
|
||||
assert search_slot_ids_by_keyword(MagicMock(), "zkxw", " ") == []
|
||||
|
||||
|
||||
@patch(
|
||||
"app.crud.storage_facility._search_slot_select_matched_ids",
|
||||
return_value=["slot-1", "slot-2"],
|
||||
)
|
||||
def test_search_slot_ids_by_keyword_delegates(mock_search):
|
||||
db = MagicMock()
|
||||
q = MagicMock()
|
||||
db.query.return_value = q
|
||||
q.filter.return_value = q
|
||||
|
||||
assert search_slot_ids_by_keyword(db, "zkxw", "2-3-7-15") == ["slot-1", "slot-2"]
|
||||
mock_search.assert_called_once()
|
||||
assert mock_search.call_args[0][2] == "zkxw"
|
||||
assert mock_search.call_args[0][3] == "2-3-7-15"
|
||||
|
||||
|
||||
@patch("app.crud.cell_product.search_slot_ids_by_keyword", return_value=["slot-a"])
|
||||
@patch("app.crud.cell_product._enrich_product_dicts", side_effect=lambda db, tenant, items: items)
|
||||
@patch("app.crud.cell_product._row_to_dict", side_effect=lambda x: {"id": x.id})
|
||||
def test_list_cell_products_keyword_ors_matched_slot_ids(
|
||||
_row_to_dict, _enrich, mock_slots
|
||||
):
|
||||
from app.crud.cell_product import list_cell_products
|
||||
|
||||
db = MagicMock()
|
||||
q = MagicMock()
|
||||
db.query.return_value = q
|
||||
q.filter.return_value = q
|
||||
q.order_by.return_value = q
|
||||
q.with_entities.return_value = q
|
||||
q.scalar.return_value = 0
|
||||
q.offset.return_value = q
|
||||
q.limit.return_value = q
|
||||
q.all.return_value = []
|
||||
|
||||
list_cell_products(db, tenant_no="zkxw", page=1, page_size=20, keyword="2-3-7")
|
||||
|
||||
mock_slots.assert_called_once_with(db, "zkxw", "2-3-7")
|
||||
# filter 至少调用两次:基础 del/tenant + keyword or_
|
||||
assert q.filter.call_count >= 2
|
||||
@@ -0,0 +1,208 @@
|
||||
"""子表按行同步:更新原行、新增缺行、只软删去掉的行。"""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, SmallInteger, String, create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.database import Base
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
|
||||
|
||||
class DemoChild(Base):
|
||||
__tablename__ = "demo_child_row"
|
||||
|
||||
id = Column(String(64), primary_key=True)
|
||||
parent_id = Column(String(64), nullable=False)
|
||||
tenant_no = Column(String(64), nullable=False)
|
||||
item_code = Column(String(64))
|
||||
item_name = Column(String(128))
|
||||
order_num = Column(Integer)
|
||||
create_time = Column(DateTime)
|
||||
update_by = Column(String(64))
|
||||
update_time = Column(DateTime)
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0)
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
DemoChild.__table__.create(bind=engine)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _row(**kwargs):
|
||||
now = datetime(2026, 10, 27, 9, 0, 0)
|
||||
data = dict(
|
||||
parent_id="parent-1",
|
||||
tenant_no="tenant-a",
|
||||
item_code="R-1",
|
||||
item_name="试剂",
|
||||
order_num=0,
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
data.update(kwargs)
|
||||
return DemoChild(**data)
|
||||
|
||||
|
||||
def _sync(db, items):
|
||||
now = datetime(2026, 10, 27, 10, 0, 0)
|
||||
|
||||
def build_row(row_id):
|
||||
return DemoChild(
|
||||
id=row_id,
|
||||
parent_id="parent-1",
|
||||
tenant_no="tenant-a",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row, item, idx, _now):
|
||||
row.item_code = item.get("item_code")
|
||||
row.item_name = item.get("item_name")
|
||||
row.order_num = idx
|
||||
|
||||
return sync_child_rows(
|
||||
db,
|
||||
DemoChild,
|
||||
parent_attr="parent_id",
|
||||
parent_id="parent-1",
|
||||
tenant_no="tenant-a",
|
||||
items=items,
|
||||
user_id="user-1",
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("item_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.item_code or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
class TestChildRowSync(unittest.TestCase):
|
||||
def test_id_keeps_row_when_fields_change(self):
|
||||
db = _session()
|
||||
db.add(_row(id="row-keep", item_name="旧名称"))
|
||||
db.commit()
|
||||
|
||||
saved = _sync(db, [{"id": "row-keep", "item_code": "R-2", "item_name": "新名称"}])
|
||||
|
||||
self.assertEqual(saved[0].id, "row-keep")
|
||||
self.assertEqual(saved[0].item_name, "新名称")
|
||||
self.assertEqual(db.query(DemoChild).filter_by(del_flag=0).count(), 1)
|
||||
|
||||
def test_natural_key_keeps_id_when_payload_omits_id(self):
|
||||
db = _session()
|
||||
db.add(_row(id="row-keep", item_code="R-1"))
|
||||
db.commit()
|
||||
|
||||
saved = _sync(db, [{"item_code": "R-1", "item_name": "改名"}])
|
||||
|
||||
self.assertEqual([row.id for row in saved], ["row-keep"])
|
||||
self.assertEqual(saved[0].item_name, "改名")
|
||||
|
||||
def test_insert_update_and_soft_delete_removed(self):
|
||||
db = _session()
|
||||
db.add(_row(id="row-keep", item_code="R-1"))
|
||||
db.add(_row(id="row-drop", item_code="R-9", item_name="去掉"))
|
||||
db.commit()
|
||||
|
||||
saved = _sync(
|
||||
db,
|
||||
[
|
||||
{"id": "row-keep", "item_code": "R-1", "item_name": "留下"},
|
||||
{"item_code": "R-NEW", "item_name": "新增"},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(saved[0].id, "row-keep")
|
||||
self.assertNotEqual(saved[1].id, "row-drop")
|
||||
rows = {row.id: row for row in db.query(DemoChild).all()}
|
||||
self.assertEqual(rows["row-keep"].del_flag, 0)
|
||||
self.assertEqual(rows["row-drop"].del_flag, 1)
|
||||
self.assertEqual(rows[saved[1].id].del_flag, 0)
|
||||
|
||||
def test_id_from_another_parent_is_rejected(self):
|
||||
db = _session()
|
||||
db.add(_row(id="row-other", parent_id="parent-2"))
|
||||
db.commit()
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
_sync(db, [{"id": "row-other", "item_code": "R-1", "item_name": "占用"}])
|
||||
other = db.query(DemoChild).filter_by(id="row-other").one()
|
||||
self.assertEqual(other.parent_id, "parent-2")
|
||||
self.assertEqual(other.del_flag, 0)
|
||||
|
||||
def test_readded_business_key_restores_deleted_row(self):
|
||||
db = _session()
|
||||
db.add(_row(id="row-old", item_code="R-1", del_flag=1))
|
||||
db.commit()
|
||||
now = datetime(2026, 10, 27, 10, 0, 0)
|
||||
|
||||
def build_row(row_id):
|
||||
return DemoChild(
|
||||
id=row_id,
|
||||
parent_id="parent-1",
|
||||
tenant_no="tenant-a",
|
||||
create_time=now,
|
||||
)
|
||||
|
||||
def apply_row(row, item, idx, _now):
|
||||
row.item_code = item.get("item_code")
|
||||
row.item_name = item.get("item_name")
|
||||
row.order_num = idx
|
||||
|
||||
saved = sync_child_rows(
|
||||
db,
|
||||
DemoChild,
|
||||
parent_attr="parent_id",
|
||||
parent_id="parent-1",
|
||||
tenant_no="tenant-a",
|
||||
items=[{"item_code": "R-1", "item_name": "恢复"}],
|
||||
user_id="user-1",
|
||||
now=now,
|
||||
build_row=build_row,
|
||||
apply_fields=apply_row,
|
||||
natural_key=lambda item: (item.get("item_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.item_code or "").strip(),
|
||||
reuse_deleted=True,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
self.assertEqual(saved[0].id, "row-old")
|
||||
self.assertEqual(saved[0].del_flag, 0)
|
||||
self.assertEqual(db.query(DemoChild).count(), 1)
|
||||
|
||||
def test_removing_live_row_clears_older_tombstone(self):
|
||||
db = _session()
|
||||
db.add(_row(id="row-live", item_code="R-1"))
|
||||
db.add(_row(id="row-tomb", item_code="R-1", del_flag=1))
|
||||
db.commit()
|
||||
now = datetime(2026, 10, 27, 10, 0, 0)
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
DemoChild,
|
||||
parent_attr="parent_id",
|
||||
parent_id="parent-1",
|
||||
tenant_no="tenant-a",
|
||||
items=[],
|
||||
user_id="user-1",
|
||||
now=now,
|
||||
build_row=lambda row_id: DemoChild(
|
||||
id=row_id, parent_id="parent-1", tenant_no="tenant-a", create_time=now
|
||||
),
|
||||
apply_fields=lambda row, item, idx, _now: None,
|
||||
natural_key=lambda item: (item.get("item_code") or "").strip(),
|
||||
row_natural_key=lambda row: (row.item_code or "").strip(),
|
||||
reuse_deleted=True,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
self.assertIsNone(db.query(DemoChild).filter_by(id="row-tomb").first())
|
||||
live = db.query(DemoChild).filter_by(id="row-live").one()
|
||||
self.assertEqual(live.del_flag, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""离职(软删除)后,历史单据仍应展示人员姓名,而不是人员 id。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.crud.production_task import _enrich_assignee_names
|
||||
from app.crud.production_task_step_assignee import enrich_step_completed_by_names
|
||||
from app.crud.qc_inspection_batch import _assignee_names
|
||||
from app.models.base import Base
|
||||
from app.models.models import SysUser
|
||||
|
||||
|
||||
def _user(user_id: str, nick_name: str, *, deleted: bool = False) -> SysUser:
|
||||
now = datetime(2026, 7, 1, 10, 0, 0)
|
||||
return SysUser(
|
||||
id=user_id,
|
||||
user_no="u-" + user_id[:6],
|
||||
nick_name=nick_name,
|
||||
email="",
|
||||
phone_no="",
|
||||
password="",
|
||||
status="0" if deleted else "1",
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=1 if deleted else 0,
|
||||
)
|
||||
|
||||
|
||||
class TestDepartedUserDisplayName(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine, tables=[SysUser.__table__])
|
||||
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
|
||||
self.db = Session()
|
||||
self.db.add_all(
|
||||
[
|
||||
_user("GONEID0001", "张倩文", deleted=True),
|
||||
_user("STAYID0001", "侯以婷"),
|
||||
]
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
|
||||
def test_qc_batch_assignee_names_keep_departed_nick(self):
|
||||
text = _assignee_names(self.db, ["GONEID0001", "STAYID0001"])
|
||||
self.assertEqual(text, "张倩文、侯以婷")
|
||||
|
||||
def test_qc_batch_assignee_unknown_id_stays_id(self):
|
||||
self.assertEqual(_assignee_names(self.db, ["NO_SUCH_USER"]), "NO_SUCH_USER")
|
||||
|
||||
def test_production_current_assignee_keeps_departed_nick(self):
|
||||
items = [{"currentAssigneeUserId": "GONEID0001"}]
|
||||
_enrich_assignee_names(self.db, items)
|
||||
self.assertEqual(items[0]["currentAssigneeUserName"], "张倩文")
|
||||
|
||||
def test_step_completed_by_keeps_departed_nick(self):
|
||||
steps = [{"completedBy": "GONEID0001"}]
|
||||
enrich_step_completed_by_names(self.db, steps)
|
||||
self.assertIn("张倩文", steps[0]["completedByName"])
|
||||
self.assertNotIn("GONEID0001", steps[0]["completedByName"])
|
||||
@@ -578,6 +578,64 @@ def test_specialist_cell_create_forces_current_collection_and_collector_users():
|
||||
assert sample.sample_code == 'S-001'
|
||||
|
||||
|
||||
def test_mobile_protocol_status_follows_web_options_and_transitions():
|
||||
from app.services.market_protocol import create_protocol, update_protocol
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
created = create_protocol(
|
||||
db,
|
||||
body=_body(protocolStatus='dsx'),
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
protocol_id = created['basic']['id']
|
||||
assert db.get(BizCollectionProtocol, protocol_id).protocol_status == 'dsx'
|
||||
|
||||
updated = update_protocol(
|
||||
db,
|
||||
source='cell_storage',
|
||||
protocol_id=protocol_id,
|
||||
body=_body(protocolStatus='yzf'),
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
assert updated['basic']['protocolStatus'] == 'yzf'
|
||||
|
||||
with pytest.raises(ValueError, match='不可转为'):
|
||||
update_protocol(
|
||||
db,
|
||||
source='cell_storage',
|
||||
protocol_id=protocol_id,
|
||||
body=_body(protocolStatus='sxz'),
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
|
||||
donation = create_protocol(
|
||||
db,
|
||||
body=_body(
|
||||
'research_donation',
|
||||
donationNo='D-STATUS',
|
||||
protocolNo=None,
|
||||
donationStatus='sdfq',
|
||||
attachments={},
|
||||
samples=[
|
||||
{
|
||||
'sampleTypeId': 'type-a',
|
||||
'sampleCodeId': 'code-b',
|
||||
'sampleCode': 'S-002',
|
||||
'unitId': 'unit-a',
|
||||
'collectAmount': 10,
|
||||
}
|
||||
],
|
||||
),
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
assert db.get(BizResearchDonation, donation['basic']['id']).donation_status == 'sdfq'
|
||||
|
||||
|
||||
def test_specialist_donation_create_forces_current_users_and_has_no_payment_section():
|
||||
from app.services.market_protocol import create_protocol
|
||||
|
||||
@@ -608,6 +666,115 @@ def test_specialist_donation_create_forces_current_users_and_has_no_payment_sect
|
||||
assert 'payment' not in result
|
||||
|
||||
|
||||
def test_mobile_create_schedules_sample_collect_wx_notify_for_cell_and_donation():
|
||||
from app.services.market_protocol import create_protocol
|
||||
|
||||
db_cell = _session()
|
||||
_masters(db_cell)
|
||||
with patch(
|
||||
'app.services.market_protocol.schedule_sample_collect_notify'
|
||||
) as schedule_cell:
|
||||
cell = create_protocol(
|
||||
db_cell, body=_body(), ctx=_ctx('specialist'), user_info=_user()
|
||||
)
|
||||
cell_kw = schedule_cell.call_args.kwargs
|
||||
assert cell_kw['tenant_no'] == 'tenant-a'
|
||||
assert cell_kw['protocol_id'] == cell['basic']['id']
|
||||
assert cell_kw['protocol_kind'] == 'cell_storage'
|
||||
assert cell_kw['before_codes'] == []
|
||||
assert 'S-001' in cell_kw['submitted_codes']
|
||||
|
||||
db_don = _session()
|
||||
_masters(db_don)
|
||||
with patch(
|
||||
'app.services.market_protocol.schedule_sample_collect_notify'
|
||||
) as schedule_don:
|
||||
donation = create_protocol(
|
||||
db_don,
|
||||
body=_body(
|
||||
'research_donation',
|
||||
donationNo='D-NOTIFY',
|
||||
protocolNo=None,
|
||||
samples=[
|
||||
{
|
||||
'sampleTypeId': 'type-a',
|
||||
'unitId': 'unit-a',
|
||||
'collectAmount': 10,
|
||||
}
|
||||
],
|
||||
),
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
don_kw = schedule_don.call_args.kwargs
|
||||
assert don_kw['protocol_id'] == donation['basic']['id']
|
||||
assert don_kw['protocol_kind'] == 'research_donation'
|
||||
assert don_kw['before_codes'] == []
|
||||
assert don_kw['submitted_codes']
|
||||
|
||||
|
||||
def test_mobile_update_schedules_notify_only_when_samples_submitted():
|
||||
from app.schemas.market_mobile import MarketProtocolBody
|
||||
from app.services.market_protocol import create_protocol, update_protocol
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
|
||||
with patch(
|
||||
'app.services.market_protocol.schedule_sample_collect_notify'
|
||||
):
|
||||
created = create_protocol(
|
||||
db, body=_body(), ctx=_ctx('specialist'), user_info=_user()
|
||||
)
|
||||
protocol_id = created['basic']['id']
|
||||
|
||||
with patch(
|
||||
'app.services.market_protocol.schedule_sample_collect_notify'
|
||||
) as schedule:
|
||||
update_protocol(
|
||||
db,
|
||||
source='cell_storage',
|
||||
protocol_id=protocol_id,
|
||||
body=MarketProtocolBody.model_validate(
|
||||
{'source': 'cell_storage', 'remark': '仅改备注'}
|
||||
),
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
update_protocol(
|
||||
db,
|
||||
source='cell_storage',
|
||||
protocol_id=protocol_id,
|
||||
body=_body(
|
||||
samples=[
|
||||
{
|
||||
'sampleTypeId': 'type-a',
|
||||
'sampleCodeId': 'code-a',
|
||||
'sampleCode': 'S-001',
|
||||
'unitId': 'unit-a',
|
||||
'collectAmount': 10,
|
||||
},
|
||||
{
|
||||
'sampleTypeId': 'type-a',
|
||||
'sampleCodeId': 'code-b',
|
||||
'sampleCode': 'S-002',
|
||||
'unitId': 'unit-a',
|
||||
'collectAmount': 5,
|
||||
},
|
||||
]
|
||||
),
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
|
||||
assert schedule.call_count == 2
|
||||
assert schedule.call_args_list[0].kwargs['submitted_codes'] is None
|
||||
second = schedule.call_args_list[1].kwargs
|
||||
assert 'S-001' in second['before_codes']
|
||||
assert set(second['submitted_codes']) >= {'S-001', 'S-002'}
|
||||
assert second['protocol_kind'] == 'cell_storage'
|
||||
|
||||
|
||||
def test_manager_update_cannot_move_document_out_of_scope_and_rolls_back():
|
||||
from app.services.market_protocol import update_protocol
|
||||
|
||||
@@ -959,6 +1126,57 @@ def test_protocol_body_legacy_payment_accepts_month_end_and_leap_dates():
|
||||
assert body.payment_details[0].installment_due_date == '20260228'
|
||||
|
||||
|
||||
def test_protocol_body_blank_customer_numbers_are_unset():
|
||||
from app.schemas.market_mobile import MarketProtocolBody
|
||||
|
||||
body = MarketProtocolBody.model_validate(
|
||||
{
|
||||
'source': 'cell_storage',
|
||||
'customer': {
|
||||
'motherName': '张敏',
|
||||
'birthOrder': '',
|
||||
'newbornWeightGram': ' ',
|
||||
'apgar1min': '',
|
||||
'apgar5min': ' 9 ',
|
||||
},
|
||||
}
|
||||
)
|
||||
assert body.customer.birth_order is None
|
||||
assert body.customer.newborn_weight_gram is None
|
||||
assert body.customer.apgar_1min is None
|
||||
assert body.customer.apgar_5min == 9
|
||||
|
||||
whole = MarketProtocolBody.model_validate(
|
||||
{
|
||||
'source': 'research_donation',
|
||||
'customer': {'birthOrder': '2.0', 'newbornWeightGram': '3200'},
|
||||
}
|
||||
)
|
||||
assert whole.customer.birth_order == 2
|
||||
assert whole.customer.newborn_weight_gram == 3200
|
||||
|
||||
with pytest.raises(ValidationError, match='新生儿体重须为整数'):
|
||||
MarketProtocolBody.model_validate(
|
||||
{
|
||||
'source': 'cell_storage',
|
||||
'customer': {'birthOrder': '1', 'newbornWeightGram': '3200.5'},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_protocol_body_normalizes_dashed_register_and_donation_dates():
|
||||
from app.schemas.market_mobile import MarketProtocolBody
|
||||
|
||||
donation = MarketProtocolBody.model_validate(
|
||||
{'source': 'research_donation', 'donationDate': '2026-06-17'}
|
||||
)
|
||||
assert donation.donation_date == '20260617'
|
||||
cell = MarketProtocolBody.model_validate(
|
||||
{'source': 'cell_storage', 'registerDate': '2026/06/17'}
|
||||
)
|
||||
assert cell.register_date == '20260617'
|
||||
|
||||
|
||||
def test_protocol_payment_models_are_mutually_exclusive_by_stored_version():
|
||||
from app.schemas.market_mobile import MarketProtocolBody
|
||||
from app.services.market_protocol import update_protocol
|
||||
@@ -2930,7 +3148,8 @@ def test_protocol_body_rejects_status_finance_and_conversion_fields():
|
||||
|
||||
forbidden = [
|
||||
{'auditStatus': 'shtg'},
|
||||
{'protocolStatus': 'yzf'},
|
||||
{'protocolStatus': 'zckc'},
|
||||
{'donationStatus': 'zky'},
|
||||
{'delFlag': 1},
|
||||
{'refundAmount': 100},
|
||||
{'financeRemark': '通过'},
|
||||
@@ -2939,6 +3158,11 @@ def test_protocol_body_rejects_status_finance_and_conversion_fields():
|
||||
for field in forbidden:
|
||||
with pytest.raises(ValidationError):
|
||||
MarketProtocolBody.model_validate({'source': 'cell_storage', **field})
|
||||
allowed = MarketProtocolBody.model_validate(
|
||||
{'source': 'cell_storage', 'protocolStatus': 'sxz', 'donationStatus': 'dsx'}
|
||||
)
|
||||
assert allowed.protocol_status == 'sxz'
|
||||
assert allowed.donation_status == 'dsx'
|
||||
|
||||
|
||||
def test_cross_tenant_or_disabled_referenced_user_is_rejected():
|
||||
@@ -3608,7 +3832,7 @@ def test_cell_protocol_detail_includes_storage_insurance_and_audits():
|
||||
assert detail['audits'][0]['auditStatus'] == 'shtg'
|
||||
|
||||
|
||||
def test_cell_protocol_detail_hides_cross_tenant_and_deleted_handler_names():
|
||||
def test_cell_protocol_detail_hides_cross_tenant_handler_and_keeps_deleted_name():
|
||||
from app.services.market_protocol import get_protocol
|
||||
|
||||
db = _session()
|
||||
@@ -3663,7 +3887,7 @@ def test_cell_protocol_detail_hides_cross_tenant_and_deleted_handler_names():
|
||||
)
|
||||
|
||||
assert detail['storageCert']['handlerUserName'] == ''
|
||||
assert detail['fullmoonTodo']['handlerUserName'] == ''
|
||||
assert detail['fullmoonTodo']['handlerUserName'] == '已删除经办人'
|
||||
|
||||
|
||||
def test_cell_protocol_detail_includes_presign_deposit_hint_and_transfers():
|
||||
|
||||
@@ -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,136 @@
|
||||
"""生产派发质检:工单若仍指着已软删产出物,回填时改用同一步骤上的有效产出物。"""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import app.crud.qc_task # noqa: F401 先加载任务模块,避免与工单模块循环导入
|
||||
from app.crud.qc_work_order import _enrich_work_order_step_outputs
|
||||
from app.models.models import BizProductionTaskStepOutput
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
BizProductionTaskStepOutput.__table__.create(bind=engine)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _output(**kwargs):
|
||||
now = datetime(2026, 9, 2, 9, 11, 9)
|
||||
data = dict(
|
||||
step_id="step-1",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
data.update(kwargs)
|
||||
return BizProductionTaskStepOutput(**data)
|
||||
|
||||
|
||||
class TestEnrichWorkOrderStepOutputs(unittest.TestCase):
|
||||
def test_deleted_output_uses_the_live_output_on_the_same_step(self):
|
||||
db = _session()
|
||||
db.add(
|
||||
_output(
|
||||
id="out-old",
|
||||
output_name="细胞悬液",
|
||||
output_barcode="NK-UBMC202603009",
|
||||
output_type_id="type-valid",
|
||||
del_flag=1,
|
||||
update_time=datetime(2026, 9, 2, 14, 8, 45),
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
_output(
|
||||
id="out-live",
|
||||
output_name="细胞悬液",
|
||||
output_barcode="NK-UBMC202603009",
|
||||
output_type_id="type-valid",
|
||||
del_flag=0,
|
||||
create_time=datetime(2026, 9, 2, 14, 8, 45),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
items = _enrich_work_order_step_outputs(
|
||||
db,
|
||||
"tenant-a",
|
||||
[
|
||||
{
|
||||
"cellProductId": "",
|
||||
"cellProductCode": "",
|
||||
"cellProductName": "",
|
||||
"productionStepOutputId": "out-old",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(items[0]["cellProductCode"], "NK-UBMC202603009")
|
||||
self.assertEqual(items[0]["cellProductName"], "细胞悬液")
|
||||
self.assertEqual(items[0]["productionStepOutputId"], "out-live")
|
||||
|
||||
def test_deleted_output_without_live_match_is_not_used(self):
|
||||
db = _session()
|
||||
db.add(
|
||||
_output(
|
||||
id="out-old",
|
||||
output_name="细胞悬液",
|
||||
output_barcode="NK-UBMC202603009",
|
||||
del_flag=1,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
items = _enrich_work_order_step_outputs(
|
||||
db,
|
||||
"tenant-a",
|
||||
[
|
||||
{
|
||||
"cellProductId": "",
|
||||
"cellProductCode": "",
|
||||
"cellProductName": "",
|
||||
"productionStepOutputId": "out-old",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(items[0]["cellProductCode"], "")
|
||||
self.assertEqual(items[0]["cellProductName"], "")
|
||||
self.assertEqual(items[0]["productionStepOutputId"], "out-old")
|
||||
|
||||
def test_other_tenant_output_is_ignored(self):
|
||||
db = _session()
|
||||
db.add(
|
||||
_output(
|
||||
id="out-other",
|
||||
tenant_no="tenant-b",
|
||||
output_name="细胞悬液",
|
||||
output_barcode="NK-UBMC202603004",
|
||||
del_flag=1,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
items = _enrich_work_order_step_outputs(
|
||||
db,
|
||||
"tenant-a",
|
||||
[
|
||||
{
|
||||
"cellProductId": "",
|
||||
"cellProductCode": "",
|
||||
"cellProductName": "",
|
||||
"productionStepOutputId": "out-other",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(items[0]["cellProductCode"], "")
|
||||
self.assertEqual(items[0]["cellProductName"], "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""保存步骤产出物时保留原行 ID,避免质检工单等外键被整表替换打断。"""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.crud.production_task import replace_step_outputs
|
||||
from app.models.models import BizProductionTaskStepOutput
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
BizProductionTaskStepOutput.__table__.create(bind=engine)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _output(**kwargs):
|
||||
now = datetime(2026, 9, 2, 9, 1, 8)
|
||||
data = dict(
|
||||
step_id="step-1",
|
||||
tenant_no="tenant-a",
|
||||
output_type_id="type-valid",
|
||||
output_name="细胞悬液",
|
||||
output_barcode="NK-UBMC202603009",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
data.update(kwargs)
|
||||
return BizProductionTaskStepOutput(**data)
|
||||
|
||||
|
||||
class TestReplaceStepOutputsKeepsIdentity(unittest.TestCase):
|
||||
def test_existing_row_keeps_id_when_fields_change(self):
|
||||
db = _session()
|
||||
db.add(_output(id="out-keep"))
|
||||
db.commit()
|
||||
|
||||
saved = replace_step_outputs(
|
||||
db,
|
||||
"step-1",
|
||||
"tenant-a",
|
||||
"user-1",
|
||||
[
|
||||
{
|
||||
"id": "out-keep",
|
||||
"output_type_id": "type-valid",
|
||||
"output_name": "细胞悬液",
|
||||
"output_barcode": "NK-UBMC202603009-NEW",
|
||||
"remark": "",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(saved[0]["id"], "out-keep")
|
||||
self.assertEqual(saved[0]["outputBarcode"], "NK-UBMC202603009-NEW")
|
||||
alive = (
|
||||
db.query(BizProductionTaskStepOutput)
|
||||
.filter(BizProductionTaskStepOutput.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
self.assertEqual([row.id for row in alive], ["out-keep"])
|
||||
|
||||
def test_insert_new_update_old_and_soft_delete_only_removed(self):
|
||||
db = _session()
|
||||
db.add(_output(id="out-keep", output_name="细胞悬液", output_barcode="NK-OLD"))
|
||||
db.add(_output(id="out-drop", output_name="细胞上清", output_barcode="NK-DROP"))
|
||||
db.commit()
|
||||
|
||||
saved = replace_step_outputs(
|
||||
db,
|
||||
"step-1",
|
||||
"tenant-a",
|
||||
"user-1",
|
||||
[
|
||||
{
|
||||
"id": "out-keep",
|
||||
"output_type_id": "type-valid",
|
||||
"output_name": "细胞悬液",
|
||||
"output_barcode": "NK-UPDATED",
|
||||
"remark": "改条码",
|
||||
},
|
||||
{
|
||||
"output_type_id": "type-valid",
|
||||
"output_name": "废液",
|
||||
"output_barcode": "NK-NEW",
|
||||
"remark": "",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(saved[0]["id"], "out-keep")
|
||||
self.assertEqual(saved[0]["outputBarcode"], "NK-UPDATED")
|
||||
self.assertTrue(saved[1]["id"])
|
||||
self.assertNotEqual(saved[1]["id"], "out-keep")
|
||||
self.assertEqual(saved[1]["outputName"], "废液")
|
||||
|
||||
rows = {row.id: row for row in db.query(BizProductionTaskStepOutput).all()}
|
||||
self.assertEqual(rows["out-keep"].del_flag, 0)
|
||||
self.assertEqual(rows["out-keep"].output_barcode, "NK-UPDATED")
|
||||
self.assertEqual(rows["out-drop"].del_flag, 1)
|
||||
self.assertEqual(rows[saved[1]["id"]].del_flag, 0)
|
||||
self.assertEqual(rows[saved[1]["id"]].output_barcode, "NK-NEW")
|
||||
|
||||
def test_removed_row_is_soft_deleted_and_kept_row_stays(self):
|
||||
db = _session()
|
||||
db.add(_output(id="out-keep", output_name="细胞悬液"))
|
||||
db.add(_output(id="out-drop", output_name="细胞上清"))
|
||||
db.commit()
|
||||
|
||||
replace_step_outputs(
|
||||
db,
|
||||
"step-1",
|
||||
"tenant-a",
|
||||
"user-1",
|
||||
[
|
||||
{
|
||||
"id": "out-keep",
|
||||
"output_type_id": "type-valid",
|
||||
"output_name": "细胞悬液",
|
||||
"output_barcode": "NK-UBMC202603009",
|
||||
"remark": "",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
rows = {row.id: row for row in db.query(BizProductionTaskStepOutput).all()}
|
||||
self.assertEqual(rows["out-keep"].del_flag, 0)
|
||||
self.assertEqual(rows["out-drop"].del_flag, 1)
|
||||
|
||||
def test_id_from_another_step_is_rejected(self):
|
||||
db = _session()
|
||||
db.add(_output(id="out-other", step_id="step-2"))
|
||||
db.commit()
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
replace_step_outputs(
|
||||
db,
|
||||
"step-1",
|
||||
"tenant-a",
|
||||
"user-1",
|
||||
[
|
||||
{
|
||||
"id": "out-other",
|
||||
"output_type_id": "type-valid",
|
||||
"output_name": "细胞悬液",
|
||||
"output_barcode": "NK-UBMC202603009",
|
||||
"remark": "",
|
||||
}
|
||||
],
|
||||
)
|
||||
other = db.query(BizProductionTaskStepOutput).filter_by(id="out-other").one()
|
||||
self.assertEqual(other.step_id, "step-2")
|
||||
self.assertEqual(other.del_flag, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user