Compare commits
4
Commits
20261026
...
202610281050
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4efd423d2d | ||
|
|
5e563a6669 | ||
|
|
d66e55af65 | ||
|
|
045ee8d0e3 |
No files matched your search
@@ -35,6 +35,8 @@ steps:
|
||||
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
|
||||
# 固定 v1.1.0;$${ } 防止 Drone 预处理把自定义变量掏空
|
||||
- export ZKXW_DEPLOY_VERSION="$${ZKXW_DEPLOY_VERSION:-v1.1.0}"
|
||||
|
||||
@@ -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` 服务。
|
||||
@@ -94,9 +94,10 @@ SECRET_KEY=
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT=thing7
|
||||
# WX_MP_TPL_BE_DELIVERY_FIELD_TIME=time14
|
||||
# 协议新增样本采集通知(工单类模板)
|
||||
# WX_MP_TEMPLATE_SAMPLE_COLLECT=iOnQ9k2mavAOwFbp_6yH4lD-RCNmM_OiwyTgrJfU-_A
|
||||
# WX_MP_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
|
||||
@@ -1228,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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2452,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,11 +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 (
|
||||
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
|
||||
@@ -24,9 +20,6 @@ from app.services.sample_collect_wx_notify import (
|
||||
|
||||
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")
|
||||
@@ -139,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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -266,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)
|
||||
@@ -311,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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -145,8 +145,9 @@ class Settings(BaseSettings):
|
||||
"oQNU3t0Q5kwc4KPJ8Z6yPA-k6qlsH9pikSCIb4eAwgM"
|
||||
)
|
||||
# 协议新增样本采集通知(工单类模板)
|
||||
# 工单标题 thing2 / 发起人 thing8 / 发起时间 time3 / 任务类型 phrase10 / 工单类型 thing17(样本类型名称)
|
||||
WX_MP_TEMPLATE_SAMPLE_COLLECT: str = (
|
||||
"iOnQ9k2mavAOwFbp_6yH4lD-RCNmM_OiwyTgrJfU-_A"
|
||||
"iOnQ9k2mavAOwFbp_6yH4t1SHqpEkaiwNWKueY0kpwE"
|
||||
)
|
||||
WX_MP_SAMPLE_COLLECT_ROLE_NAME: str = "样本采集通知人员"
|
||||
# 审批模板:申请事项/操作人(申请人)/当前环节/操作时间/申请类型(流程名)
|
||||
@@ -173,11 +174,12 @@ class Settings(BaseSettings):
|
||||
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"
|
||||
|
||||
@@ -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,
|
||||
@@ -2580,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,
|
||||
@@ -2684,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:
|
||||
@@ -2973,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,
|
||||
*,
|
||||
@@ -2981,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(
|
||||
@@ -3019,77 +3117,102 @@ def save_samples(
|
||||
) -> List[BizProtocolSample]:
|
||||
existing = list_samples(db, collection_protocol_id, tenant_no)
|
||||
validate_persisted_sample_codes_immutable(existing, samples)
|
||||
old_id_to_code = {
|
||||
s.id: (s.sample_code or "").strip()
|
||||
for s in existing
|
||||
if (s.sample_code or "").strip()
|
||||
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,
|
||||
)
|
||||
@@ -3172,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
|
||||
@@ -3181,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")
|
||||
|
||||
@@ -3191,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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
@@ -1142,46 +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 "",
|
||||
package_id=item.get("packageId") or item.get("package_id") or "",
|
||||
package_text=item.get("packageText") or item.get("package_text") or "",
|
||||
agreement_price=item.get("agreementPrice") if item.get("agreementPrice") is not None else (
|
||||
item.get("agreement_price") if item.get("agreement_price") is not None else None
|
||||
),
|
||||
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,6 +10,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.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
BizCellProductType,
|
||||
@@ -2287,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()
|
||||
@@ -2296,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):
|
||||
@@ -2455,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,
|
||||
@@ -3212,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,6 +8,7 @@ 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,
|
||||
@@ -256,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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -227,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(
|
||||
@@ -248,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():
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 (
|
||||
@@ -23,6 +24,9 @@ from app.crud.collection_protocol import (
|
||||
_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,
|
||||
@@ -45,6 +49,8 @@ from app.models.models import (
|
||||
BizProtocolPartyCustomer,
|
||||
BizProtocolSample,
|
||||
BizProtocolSampleTransport,
|
||||
BizProductionWorkOrder,
|
||||
BizQcSmsNotification,
|
||||
BizQcWorkOrder,
|
||||
BizSampleReception,
|
||||
SysUser,
|
||||
@@ -1157,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,
|
||||
@@ -1248,7 +1254,7 @@ def update_donation(
|
||||
commit: bool = True,
|
||||
sync_customers: bool = True,
|
||||
) -> BizResearchDonation:
|
||||
"""更新科研捐赠单:主表字段 + 子资源 upsert(软删旧 + 创建新)。"""
|
||||
"""更新科研捐赠单:主表字段就地更新,子资源按行同步。"""
|
||||
obj = (
|
||||
db.query(BizResearchDonation)
|
||||
.filter(
|
||||
@@ -1288,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]
|
||||
@@ -1301,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(
|
||||
@@ -1321,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,
|
||||
@@ -1340,12 +1345,11 @@ def update_donation(
|
||||
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)
|
||||
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
|
||||
@@ -1354,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,
|
||||
)
|
||||
@@ -2222,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,
|
||||
@@ -2231,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 = [
|
||||
@@ -2286,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 = [
|
||||
@@ -2377,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,
|
||||
@@ -2387,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,
|
||||
@@ -2408,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")}
|
||||
@@ -2453,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,
|
||||
@@ -2464,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
|
||||
|
||||
|
||||
@@ -2494,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(
|
||||
|
||||
@@ -22,6 +22,7 @@ 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(
|
||||
@@ -109,8 +110,18 @@ def _load_sample_context(db: Session, tenant_no: str, sample_code: str):
|
||||
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
|
||||
@@ -126,7 +137,7 @@ 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,
|
||||
@@ -135,13 +146,15 @@ def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[s
|
||||
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(
|
||||
|
||||
+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
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
@@ -1417,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()
|
||||
)
|
||||
@@ -1438,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()
|
||||
)
|
||||
@@ -1863,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(
|
||||
@@ -1931,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'
|
||||
@@ -1978,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()
|
||||
@@ -2052,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(
|
||||
@@ -2134,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(
|
||||
@@ -2182,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,
|
||||
@@ -2195,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:
|
||||
@@ -2215,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}%"))
|
||||
|
||||
@@ -206,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,
|
||||
@@ -273,11 +269,7 @@ def _send_mention_one(
|
||||
commenter_name = ""
|
||||
cid = (commenter_user_id or "").strip()
|
||||
if cid:
|
||||
commenter = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == cid, SysUser.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
commenter = db.query(SysUser).filter(SysUser.id == cid).first()
|
||||
commenter_name = (commenter.nick_name if commenter else "") or ""
|
||||
|
||||
pagepath = build_oa_detail_pagepath(
|
||||
|
||||
@@ -11,7 +11,9 @@ 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,
|
||||
@@ -35,6 +37,7 @@ 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]:
|
||||
@@ -98,12 +101,66 @@ def _role_name() -> str:
|
||||
).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()}"
|
||||
|
||||
@@ -252,6 +309,11 @@ def _load_cell_context(
|
||||
"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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -316,6 +378,11 @@ def _load_donation_context(
|
||||
"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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -356,6 +423,17 @@ def run_sample_collect_notify(
|
||||
)
|
||||
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(
|
||||
@@ -371,9 +449,20 @@ def run_sample_collect_notify(
|
||||
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()
|
||||
@@ -389,6 +478,7 @@ def run_sample_collect_notify(
|
||||
applicant=applicant,
|
||||
time_text=time_text,
|
||||
task_type=task_type,
|
||||
sample_type=sample_type,
|
||||
pagepath="",
|
||||
)
|
||||
if result.get("success"):
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -278,6 +278,7 @@ def send_sample_collect_notify(
|
||||
applicant: str,
|
||||
time_text: str,
|
||||
task_type: str,
|
||||
sample_type: str = "",
|
||||
pagepath: str = "",
|
||||
) -> dict:
|
||||
"""协议新增样本:样本采集工单通知。"""
|
||||
@@ -286,6 +287,7 @@ def send_sample_collect_notify(
|
||||
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:
|
||||
@@ -295,6 +297,11 @@ def send_sample_collect_notify(
|
||||
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,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+21361
-16566
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,11 @@
|
||||
# Graph Report - app (2026-10-26)
|
||||
# Graph Report - app (2026-10-28)
|
||||
|
||||
## Corpus Check
|
||||
- Large corpus: 317 files · ~388,611 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
- Large corpus: 320 files · ~394,363 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
|
||||
|
||||
## Summary
|
||||
- 6520 nodes · 36151 edges · 46 communities detected
|
||||
- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 24733 edges (avg confidence: 0.6)
|
||||
- 6604 nodes · 36562 edges · 46 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)
|
||||
@@ -48,20 +48,20 @@
|
||||
- [[_COMMUNITY_Community 36|Community 36]]
|
||||
- [[_COMMUNITY_Community 37|Community 37]]
|
||||
- [[_COMMUNITY_Community 38|Community 38]]
|
||||
- [[_COMMUNITY_Community 39|Community 39]]
|
||||
- [[_COMMUNITY_Community 41|Community 41]]
|
||||
- [[_COMMUNITY_Community 42|Community 42]]
|
||||
- [[_COMMUNITY_Community 43|Community 43]]
|
||||
- [[_COMMUNITY_Community 44|Community 44]]
|
||||
- [[_COMMUNITY_Community 45|Community 45]]
|
||||
- [[_COMMUNITY_Community 46|Community 46]]
|
||||
- [[_COMMUNITY_Community 47|Community 47]]
|
||||
- [[_COMMUNITY_Community 49|Community 49]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `success_response()` - 842 edges
|
||||
2. `SysUser` - 753 edges
|
||||
2. `SysUser` - 756 edges
|
||||
3. `error_response()` - 710 edges
|
||||
4. `BizQcTaskResult` - 396 edges
|
||||
5. `BizProductionMaterial` - 378 edges
|
||||
5. `BizProductionMaterial` - 379 edges
|
||||
6. `BizProductionMaterialUsage` - 371 edges
|
||||
7. `BizQcMaterialUsage` - 369 edges
|
||||
8. `BizQcMaterial` - 359 edges
|
||||
@@ -84,232 +84,234 @@
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.01
|
||||
Nodes (1216): create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post(), create_role_management() (+1208 more)
|
||||
Nodes (1198): _assert_user_tenant_access(), attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu() (+1190 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.01
|
||||
Nodes (783): validate_ymd(), BaseSettings, Config, secret_key_must_be_strong(), Settings, format_date(), generate_uuid(), allocate_next_biz_no() (+775 more)
|
||||
Nodes (860): _save_sample_payments(), BaseSettings, Config, secret_key_must_be_strong(), Settings, generate_uuid(), allocate_next_biz_no(), _allocate_sqlite_sequence() (+852 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.01
|
||||
Nodes (678): GarmentBatchDeleteBody, GarmentBatchDiscardBody, GarmentCreateBody, GarmentDiscardBody, GarmentUpdateBody, SterilizeLogBatchBody, SterilizeLogCreateBody, SterilizeLogUpdateBody (+670 more)
|
||||
Nodes (615): GarmentBatchDeleteBody, GarmentBatchDiscardBody, GarmentCreateBody, GarmentDiscardBody, GarmentUpdateBody, SterilizeLogBatchBody, SterilizeLogCreateBody, SterilizeLogUpdateBody (+607 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.05
|
||||
Nodes (549): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+541 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (495): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+487 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.01
|
||||
Nodes (440): _assert_user_tenant_access(), attachment_download_url(), AttachmentDownloadUrlBody, delete_user(), DeptCreateBody, DeptUpdateBody, dict_data_options(), DictDataCreateBody (+432 more)
|
||||
Nodes (428): _normalize_date_bound(), assert_protocol_sensitive_fields(), _attach_handler_name(), create_collection_protocol(), delete_collection_protocol(), get_collection_protocol_detail(), _get_current_user_id(), get_invoice_eligible_payments() (+420 more)
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.01
|
||||
Nodes (382): _normalize_date_bound(), _persist_protocol_sub_resources(), list_ethnics_for_select(), _leave_local_callback(), apply_data_scope(), _get_child_dept_ids(), 数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤) data_scope 值: 1 = 全部数据 2 = 本部门数据 3, 根据用户角色的数据权限过滤查询。 Args: query: SQLAlchemy query 对象 model: 查询 (+374 more)
|
||||
Cohesion: 0.08
|
||||
Nodes (412): 环境检测 — 检测项维护 + 登记管理 API, InsuranceCompanyCreateBody, InsuranceCompanyInvoiceCreateBody, InsuranceCompanyInvoiceUpdateBody, InsuranceCompanyUpdateBody, InsuranceProjectPriceCreateBody, InsuranceProjectPriceUpdateBody, 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格) (+404 more)
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.01
|
||||
Nodes (376): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), _lookup_attachment_by_url(), lookup_attachments_by_urls() (+368 more)
|
||||
Cohesion: 0.02
|
||||
Nodes (359): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+351 more)
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.06
|
||||
Nodes (322): AuditCreateBody, BatchDetailIdsBody, BatchRefundFinanceSubmitBody, FinanceConfirmBody, FullmoonHandleBody, FullmoonTodoInsuranceBody, InvoiceRequestCompleteBody, InvoiceRequestCreateBody (+314 more)
|
||||
Cohesion: 0.01
|
||||
Nodes (349): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), get_cell_product(), _load_lineage_qc_for_nodes() (+341 more)
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.01
|
||||
Nodes (201): 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。, 服务号网页授权 code 绑定当前用户(需 Bearer)。, 微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, web_qr_confirm(), web_qr_scanned(), 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线) (+193 more)
|
||||
Cohesion: 0.02
|
||||
Nodes (237): list_ethnics_for_select(), _leave_local_callback(), apply_data_scope(), _get_child_dept_ids(), _resolve_user_role_scopes(), delete(), get_multi_with_filters(), count_pending_protocol_invoice_requests() (+229 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.03
|
||||
Nodes (146): assert_protocol_status_transition(), format_protocol_status_label(), 解析科研捐赠质检列表的协议状态筛选语义。 Returns: ("in", statuses) — 按所选状态 IN 过滤, 状态流转被拒绝时的提示,状态码展示为中文名称。, resolve_research_donation_protocol_status_filter(), resolve_void_type_label(), status_transition_denied_message(), _ancestor_link_from_cell_product_input() (+138 more)
|
||||
Cohesion: 0.02
|
||||
Nodes (178): _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() (+170 more)
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 0.03
|
||||
Nodes (124): _save_sample_payments(), get_protocol_nos_used_in_protocols(), _cell_row_to_dict(), _enrich_cell_dicts_with_live_status(), _find_outbound_cell_row(), _find_outbound_qc_material_row(), _fmt_dt(), get_outbound_material() (+116 more)
|
||||
Cohesion: 0.02
|
||||
Nodes (154): _register_attachment_routes(), _register_freight_routes(), _register_payment_routes(), _register_presign_routes(), _register_protocol_routes(), mp_bind_status(), 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。 (+146 more)
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.05
|
||||
Nodes (114): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, OccupancyCreateBody, 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约) (+106 more)
|
||||
Cohesion: 0.03
|
||||
Nodes (122): 验证密码(哈希非 bcrypt 或损坏时返回 False,避免抛错), verify_password(), 按行同步子表。 带 ID 的行原地修改,没有 ID 时按业务键对上尚未占用的原行,其余才新增。 本次提交里没有的活动行才软删除。不要整表软删后再插入,否则外键, 按关联 id 更新关系行。重新加回已删除的关联时恢复原行,避免唯一索引冲突。, _sort_key(), sync_child_rows(), sync_link_rows(), batch_create_checkups() (+114 more)
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.04
|
||||
Nodes (104): _apply_submit_receipt(), auto_create_payment(), batch_submit_receipt(), batch_submit_refund(), confirm_refund(), _enrich_payment(), _get_or_raise(), get_payment_by_schedule() (+96 more)
|
||||
Cohesion: 0.03
|
||||
Nodes (98): get_designer_data(), _error_json(), lifespan(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware (+90 more)
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 0.05
|
||||
Nodes (98): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+90 more)
|
||||
Nodes (114): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+106 more)
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.04
|
||||
Nodes (80): _register_attachment_routes(), _register_freight_routes(), _register_payment_routes(), _register_presign_routes(), _register_protocol_routes(), _collect_descendants(), _find_mobile_root(), list_mobile_menu_items() (+72 more)
|
||||
Nodes (96): 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() (+88 more)
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.06
|
||||
Nodes (67): _get_current_user_id(), get_sample_insurance(), get_sample_storage_cert(), _get_tenant_no(), put_sample_insurance(), put_sample_storage_cert(), _resolve_protocol_id(), _fmt_dt() (+59 more)
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.08
|
||||
Nodes (64): add_transfer(), assert_presign_convertible(), confirm_receipt(), confirmed_deposit_amount(), confirmed_deposit_amount_map(), convert_to_protocol(), create_presign_order(), _dec() (+56 more)
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.08
|
||||
Nodes (57): api_save_application(), auto_generate_schedules(), complete_application(), create_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products() (+49 more)
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.06
|
||||
Nodes (45): get_designer_data(), list_results_by_task(), _list_saved_results_by_task(), list_saved_results_by_task_ids(), _merge_saved_into_template(), _resolve_task_item_ref(), _row_to_dict(), save_results_for_task() (+37 more)
|
||||
Nodes (22): extract_avatar_storage_key(), 用户头像 URL 解析(sys_user.avatar 可能存 MinIO 相对路径或完整 URL), 从 upload 返回值或历史完整 URL 中提取 avatar/ 相对路径。, 服务端内部拼完整 URL(邮件等);Web API 应返回库中原始路径。, resolve_avatar_display_url(), BaseStorage, convert_heic_to_jpeg(), get_storage() (+14 more)
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 0.1
|
||||
Nodes (39): get_detail(), _load_sample_context(), _row_to_dict(), upsert(), _with_prefill(), dumps_payload(), empty_detail(), loads_payload() (+31 more)
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 0.12
|
||||
Nodes (31): _assert_routes_exist(), collect_missing_route_ids(), collect_route_ids_without_steps(), create_set(), create_set_from_work_order(), create_tasks_from_set(), delete_set(), get_set() (+23 more)
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
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.19
|
||||
Nodes (27): _amount_for_row(), _batch_map(), deduct_for_task(), _deduct_task_usage_legacy_batch(), _display_remark(), _fmt_date_val(), _linked_qc_task_no_map(), list_linked_task_usages() (+19 more)
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.17
|
||||
Nodes (23): apply_overtime_kit_hours(), _build_rest_day_segments(), _build_workday_windows(), calc_overtime_hours(), _day_at_minutes(), _day_start_ms(), _extract_rules(), _format_ymd() (+15 more)
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.12
|
||||
Nodes (18): get_customer_service_dict(), _get_tenant_no(), ensure_dict_value(), list_dict_values(), _format_date(), _format_datetime(), get_by_sample_code(), _row_field_dict() (+10 more)
|
||||
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.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.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.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.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)
|
||||
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 25 - "Community 25"
|
||||
Cohesion: 0.24
|
||||
Nodes (9): 微信消息通知服务 支持微信小程序模板消息推送 使用方式: from app.core.wechat_message import Wech, 发送模板消息 Args: openid: 用户openid templ, send_delivery_notice(), send_delivery_warning(), send_order_status(), send_payment_success(), send_refund_result(), send_urge_notification() (+1 more)
|
||||
Cohesion: 0.19
|
||||
Nodes (17): apply_trip_kit_days(), calc_trip_leg_days(), _day_index(), _end_offset(), _extract_rules(), is_half_day_multiple(), _kit_trip_fields(), 出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐) (+9 more)
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.29
|
||||
Nodes (11): get_detail(), _load_sample_context(), _row_to_dict(), upsert(), dumps_payload(), empty_detail(), loads_payload(), normalize_payload() (+3 more)
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 0.32
|
||||
Nodes (12): _attach_owner_names(), create_account(), _filtered_query(), get_account(), list_accounts(), list_options(), _owner_name_map(), OA 收款账户 CRUD(oa_receive_account) (+4 more)
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 0.27
|
||||
Nodes (12): _field_label(), _is_empty(), _is_required(), _parse_money(), OA 表单设计器:金额控件 form_data 校验, 校验 form_data 中金额控件的值格式,返回中文错误列表(空表示通过)。, 解析金额值。成功返回 (number, None);失败返回 (None, error_key)。, _rule_props() (+4 more)
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 0.24
|
||||
Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 试剂变动记录 CRUD(biz_reagent_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减试剂库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
|
||||
|
||||
### Community 29 - "Community 29"
|
||||
Cohesion: 0.29
|
||||
Nodes (11): _is_empty(), _is_required(), _parse_money(), OA 表单设计器:金额控件 form_data 校验, 校验 form_data 中金额控件的值格式,返回中文错误列表(空表示通过)。, 解析金额值。成功返回 (number, None);失败返回 (None, error_key)。, _rule_props(), _schema_rules() (+3 more)
|
||||
|
||||
### Community 30 - "Community 30"
|
||||
Cohesion: 0.31
|
||||
Nodes (10): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。, _row_to_dict() (+2 more)
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
### Community 29 - "Community 29"
|
||||
Cohesion: 0.29
|
||||
Nodes (2): BaseModel, SoftDeleteModel
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
### Community 30 - "Community 30"
|
||||
Cohesion: 0.46
|
||||
Nodes (7): _dec(), _due_date(), 未作废期:应缴金额与应缴时间必填(分期场景由调用方 require_fields=True)。, 未作废期 dueAmount 合计须等于合同金额。 require_schedule_fields=True 时先校验每期金额与应缴日必填。 返, schedule_dues_match_contract(), schedule_required_fields_ok(), _status()
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 0.38
|
||||
Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。 约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断; 若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
|
||||
|
||||
### Community 33 - "Community 33"
|
||||
### Community 32 - "Community 32"
|
||||
Cohesion: 0.4
|
||||
Nodes (5): batch_matches_stock_status(), normalize_batch_stock_status(), 质检物料入库批次:按剩余数量过滤的语义辅助。, 纯函数判定(供单测);list_batches 用 SQL 等价条件。, 返回 'available' | 'exhausted';全部 / 空 / 非法 → None(不加剩余数量条件)。
|
||||
|
||||
### Community 34 - "Community 34"
|
||||
### Community 33 - "Community 33"
|
||||
Cohesion: 0.5
|
||||
Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
### Community 34 - "Community 34"
|
||||
Cohesion: 0.67
|
||||
Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 0.67
|
||||
Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。
|
||||
|
||||
### Community 37 - "Community 37"
|
||||
### Community 36 - "Community 36"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。
|
||||
|
||||
### Community 38 - "Community 38"
|
||||
### Community 37 - "Community 37"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
|
||||
|
||||
### Community 39 - "Community 39"
|
||||
### Community 38 - "Community 38"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
|
||||
|
||||
### Community 42 - "Community 42"
|
||||
### Community 41 - "Community 41"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送订单状态通知 Args: openid: 用户openid ord
|
||||
|
||||
### Community 43 - "Community 43"
|
||||
### Community 42 - "Community 42"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送支付成功通知 Args: openid: 用户openid ord
|
||||
|
||||
### Community 44 - "Community 44"
|
||||
### Community 43 - "Community 43"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送配送通知 Args: openid: 配送员openid task
|
||||
|
||||
### Community 45 - "Community 45"
|
||||
### Community 44 - "Community 44"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送配送时效预警 Args: openid: 配送员openid ta
|
||||
|
||||
### Community 46 - "Community 46"
|
||||
### Community 45 - "Community 45"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送退款结果通知 Args: openid: 用户openid ord
|
||||
|
||||
### Community 47 - "Community 47"
|
||||
### Community 46 - "Community 46"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 发送催单通知给配送员 Args: deliver_openid: 配送员openid
|
||||
|
||||
### Community 49 - "Community 49"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): 接受 YYYYMMDD / YYYY-MM-DD,统一规范为 CHAR(8) YYYYMMDD。
|
||||
|
||||
## Knowledge Gaps
|
||||
- **209 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `严格判断当前租户用户是否拥有指定移动市场权限码。`, `为移动市场 API 提供租户化、精确匹配的菜单权限依赖。` (+204 more)
|
||||
- **212 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `严格判断当前租户用户是否拥有指定移动市场权限码。`, `为移动市场 API 提供租户化、精确匹配的菜单权限依赖。` (+207 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Community 31`** (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 34`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
|
||||
- **Thin community `Community 33`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 35`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
|
||||
- **Thin community `Community 34`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 36`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
|
||||
- **Thin community `Community 35`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 37`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
|
||||
- **Thin community `Community 36`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 38`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
|
||||
- **Thin community `Community 37`** (2 nodes): `context.py`, `请求级别的用户上下文变量 通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 39`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
|
||||
- **Thin community `Community 38`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 42`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
|
||||
- **Thin community `Community 41`** (1 nodes): `发送订单状态通知 Args: openid: 用户openid ord`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 43`** (1 nodes): `发送支付成功通知 Args: openid: 用户openid ord`
|
||||
- **Thin community `Community 42`** (1 nodes): `发送支付成功通知 Args: openid: 用户openid ord`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 44`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
|
||||
- **Thin community `Community 43`** (1 nodes): `发送配送通知 Args: openid: 配送员openid task`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 45`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
|
||||
- **Thin community `Community 44`** (1 nodes): `发送配送时效预警 Args: openid: 配送员openid ta`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 46`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
|
||||
- **Thin community `Community 45`** (1 nodes): `发送退款结果通知 Args: openid: 用户openid ord`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 47`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid`
|
||||
- **Thin community `Community 46`** (1 nodes): `发送催单通知给配送员 Args: deliver_openid: 配送员openid`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 49`** (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 1`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 17`, `Community 27`, `Community 28`?**
|
||||
_High betweenness centrality (0.118) - this node is a cross-community bridge._
|
||||
- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 17`, `Community 19`, `Community 21`, `Community 22`, `Community 23`, `Community 26`, `Community 27`, `Community 28`?**
|
||||
_High betweenness centrality (0.031) - this node is a cross-community bridge._
|
||||
- **Why does `OaEngineError` connect `Community 4` to `Community 0`, `Community 1`, `Community 2`?**
|
||||
_High betweenness centrality (0.031) - this node is a cross-community bridge._
|
||||
- **Are the 1201 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
|
||||
_`str` has 1201 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Why does `SysUser` connect `Community 3` to `Community 0`, `Community 1`, `Community 2`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 26`?**
|
||||
_High betweenness centrality (0.128) - this node is a cross-community bridge._
|
||||
- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 34`, `Community 4`, `Community 8`, `Community 10`, `Community 12`, `Community 13`, `Community 14`, `Community 16`, `Community 24`?**
|
||||
_High betweenness centrality (0.043) - this node is a cross-community bridge._
|
||||
- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 3`, `Community 4`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 18`, `Community 19`, `Community 23`, `Community 24`, `Community 26`?**
|
||||
_High betweenness centrality (0.030) - 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 751 inferred relationships involving `SysUser` (e.g. with `菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms)` and `运营端角色守卫:admin / super_admin 可访问。`) actually correct?**
|
||||
_`SysUser` has 751 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 709 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
|
||||
_`error_response()` has 709 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **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 707 inferred relationships involving `ValueError` (e.g. with `secret_key_must_be_strong()` and `assert_protocol_status_transition()`) actually correct?**
|
||||
_`ValueError` has 707 INFERRED edges - model-reasoned connections that need verification._
|
||||
+29249
-23561
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
||||
"/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": 1792721833.766789,
|
||||
"/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,
|
||||
@@ -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": 1793154270.7000167,
|
||||
"/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,18 +84,18 @@
|
||||
"/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/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": 1781423988.9720724,
|
||||
"/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/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,
|
||||
@@ -110,27 +110,28 @@
|
||||
"/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": 1792983363.240947,
|
||||
"/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": 1792646454.611255,
|
||||
"/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,
|
||||
@@ -138,10 +139,10 @@
|
||||
"/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,
|
||||
@@ -152,40 +153,41 @@
|
||||
"/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": 1792981510.4123862,
|
||||
"/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": 1792646845.2075555,
|
||||
"/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": 1792640195.5268464,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_material.py": 1781612877.0137436,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/process.py": 1787621642.2171764,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_service.py": 1792641856.3293736,
|
||||
"/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": 1792722971.2429159,
|
||||
"/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": 1792638802.7313325,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/crud/oa_instance_draft.py": 1792462713.458171,
|
||||
@@ -198,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": 1792983355.6709538,
|
||||
"/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,
|
||||
@@ -207,16 +209,16 @@
|
||||
"/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": 1792641617.3771758,
|
||||
"/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": 1792983395.8769655,
|
||||
"/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": 1792634972.2299473,
|
||||
@@ -232,10 +234,10 @@
|
||||
"/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/sample_cert_insurance.py": 1793001635.2987237,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/admin.py": 1790217215.444356,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/env_detect.py": 1786945487.513935,
|
||||
"/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1792720970.884106,
|
||||
"/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,
|
||||
@@ -247,16 +249,16 @@
|
||||
"/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": 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": 1792722281.2525048,
|
||||
"/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": 1792980974.5445201,
|
||||
"/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,
|
||||
@@ -267,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,
|
||||
@@ -282,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": 1792720843.8847578,
|
||||
"/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,
|
||||
@@ -297,7 +300,7 @@
|
||||
"/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": 1792983397.087853,
|
||||
"/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,
|
||||
@@ -310,7 +313,7 @@
|
||||
"/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": 1792721823.419619,
|
||||
"/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,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():
|
||||
|
||||
@@ -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()
|
||||
@@ -10,7 +10,9 @@ from app.services.sample_collect_wx_notify import (
|
||||
diff_new_sample_codes,
|
||||
format_protocol_create_time,
|
||||
format_sample_collect_title,
|
||||
protocol_status_blocks_notify,
|
||||
run_sample_collect_notify,
|
||||
sample_status_blocks_notify,
|
||||
sample_codes_from_orm_rows,
|
||||
sample_codes_from_payload,
|
||||
schedule_sample_collect_notify,
|
||||
@@ -56,6 +58,25 @@ def test_format_sample_collect_title_with_and_without_eta():
|
||||
assert len(format_sample_collect_title(None)) <= 20
|
||||
|
||||
|
||||
def test_sample_type_name_by_code_uses_type_name_or_blank():
|
||||
from app.services.sample_collect_wx_notify import _sample_type_name_by_code
|
||||
|
||||
db = MagicMock()
|
||||
type_row = MagicMock()
|
||||
type_row.id = "t1"
|
||||
type_row.type_name = " 胎盘亚全能干细胞 "
|
||||
db.query.return_value.filter.return_value.all.return_value = [type_row]
|
||||
|
||||
named = MagicMock(sample_code="A", sample_type_id="t1")
|
||||
blank = MagicMock(sample_code="B", sample_type_id="")
|
||||
missing = MagicMock(sample_code="C", sample_type_id="gone")
|
||||
|
||||
out = _sample_type_name_by_code(
|
||||
db, tenant_no="zkxw", samples=[named, blank, missing]
|
||||
)
|
||||
assert out == {"A": "胎盘亚全能干细胞", "B": "", "C": ""}
|
||||
|
||||
|
||||
def test_format_protocol_create_time():
|
||||
assert (
|
||||
format_protocol_create_time(datetime(2022, 11, 22, 16, 17))
|
||||
@@ -73,6 +94,7 @@ def test_send_sample_collect_notify_builds_data_and_truncates_title():
|
||||
st.WX_MP_TPL_SC_FIELD_APPLICANT = "thing8"
|
||||
st.WX_MP_TPL_SC_FIELD_TIME = "time3"
|
||||
st.WX_MP_TPL_SC_FIELD_TYPE = "phrase10"
|
||||
st.WX_MP_TPL_SC_FIELD_SAMPLE_TYPE = "thing17"
|
||||
send_tpl.return_value = {"success": True, "msgid": "1"}
|
||||
|
||||
from app.services.wechat_mp_message import send_sample_collect_notify
|
||||
@@ -84,6 +106,7 @@ def test_send_sample_collect_notify_builds_data_and_truncates_title():
|
||||
applicant="彭某某",
|
||||
time_text="2022年11月22日 16:17",
|
||||
task_type=TASK_TYPE_CELL,
|
||||
sample_type="脐带间充质干细胞" + "甲" * 20,
|
||||
)
|
||||
assert result["success"] is True
|
||||
kwargs = send_tpl.call_args.kwargs
|
||||
@@ -93,6 +116,20 @@ def test_send_sample_collect_notify_builds_data_and_truncates_title():
|
||||
assert data["thing8"]["value"] == "彭某某"
|
||||
assert data["time3"]["value"] == "2022年11月22日 16:17"
|
||||
assert data["phrase10"]["value"] == "细胞存储"
|
||||
assert data["thing17"]["value"] == ("脐带间充质干细胞" + "甲" * 20)[:20]
|
||||
assert len(data["thing17"]["value"]) == 20
|
||||
|
||||
send_sample_collect_notify(
|
||||
mp_openid="oid",
|
||||
title="样本采集(到达待定)",
|
||||
applicant="彭某某",
|
||||
time_text="2022年11月22日 16:17",
|
||||
task_type=TASK_TYPE_DONATION,
|
||||
sample_type=" ",
|
||||
)
|
||||
empty_data = send_tpl.call_args.kwargs["data"]
|
||||
assert empty_data["phrase10"]["value"] == "科研捐赠"
|
||||
assert "thing17" not in empty_data
|
||||
|
||||
|
||||
def test_schedule_skips_when_samples_not_in_payload():
|
||||
@@ -163,6 +200,7 @@ def test_run_notify_sends_per_code_per_user_and_idempotent():
|
||||
"create_time": datetime(2022, 11, 22, 16, 17),
|
||||
"applicant": "彭某某",
|
||||
"eta_by_code": {"S1": datetime(2022, 11, 22, 16, 17), "S2": None},
|
||||
"sample_type_by_code": {"S1": "围产期造血干细胞", "S2": ""},
|
||||
},
|
||||
), patch(
|
||||
"app.services.sample_collect_wx_notify.list_notify_recipient_users",
|
||||
@@ -188,6 +226,7 @@ def test_run_notify_sends_per_code_per_user_and_idempotent():
|
||||
assert stats["skipped"] == 1
|
||||
assert send.call_count == 1
|
||||
assert send.call_args.kwargs["task_type"] == TASK_TYPE_CELL
|
||||
assert send.call_args.kwargs["sample_type"] == "围产期造血干细胞"
|
||||
assert send.call_args.kwargs["title"] == "样本采集(预计到达 11月22日)"
|
||||
assert write_log.call_count == 1
|
||||
assert already.call_count == 2
|
||||
@@ -208,6 +247,7 @@ def test_run_notify_donation_task_type():
|
||||
"create_time": datetime(2022, 11, 22, 16, 17),
|
||||
"applicant": "李某",
|
||||
"eta_by_code": {"D1": None},
|
||||
"sample_type_by_code": {"D1": "脐带间充质干细胞"},
|
||||
},
|
||||
), patch(
|
||||
"app.services.sample_collect_wx_notify.list_notify_recipient_users",
|
||||
@@ -229,9 +269,97 @@ def test_run_notify_donation_task_type():
|
||||
new_sample_codes=["D1"],
|
||||
)
|
||||
assert send.call_args.kwargs["task_type"] == TASK_TYPE_DONATION
|
||||
assert send.call_args.kwargs["sample_type"] == "脐带间充质干细胞"
|
||||
assert send.call_args.kwargs["title"] == "样本采集(到达待定)"
|
||||
|
||||
|
||||
def test_protocol_and_sample_void_status_block_notify():
|
||||
assert protocol_status_blocks_notify("yzf") is True
|
||||
assert protocol_status_blocks_notify("sdfq") is True
|
||||
assert protocol_status_blocks_notify("sxz") is False
|
||||
assert protocol_status_blocks_notify("") is False
|
||||
assert sample_status_blocks_notify("yzf") is True
|
||||
assert sample_status_blocks_notify("ycj") is False
|
||||
assert sample_status_blocks_notify(None) is False
|
||||
|
||||
|
||||
def test_run_notify_skips_inactive_protocol():
|
||||
db = MagicMock()
|
||||
with patch(
|
||||
"app.services.sample_collect_wx_notify._notify_enabled", return_value=True
|
||||
), patch(
|
||||
"app.services.sample_collect_wx_notify._load_cell_context",
|
||||
return_value={
|
||||
"create_time": datetime(2022, 11, 22, 16, 17),
|
||||
"applicant": "彭某某",
|
||||
"protocol_status": "sdfq",
|
||||
"eta_by_code": {"S1": None},
|
||||
"sample_status_by_code": {"S1": "ycj"},
|
||||
"sample_type_by_code": {"S1": "围产期造血干细胞"},
|
||||
},
|
||||
), patch(
|
||||
"app.services.sample_collect_wx_notify.list_notify_recipient_users"
|
||||
) as recipients, patch(
|
||||
"app.services.wechat_mp_message.send_sample_collect_notify"
|
||||
) as send:
|
||||
stats = run_sample_collect_notify(
|
||||
db,
|
||||
tenant_no="zkxw",
|
||||
protocol_id="p1",
|
||||
protocol_kind=PROTOCOL_KIND_CELL,
|
||||
new_sample_codes=["S1"],
|
||||
)
|
||||
assert stats["sent"] == 0
|
||||
assert stats["skipped"] == 1
|
||||
recipients.assert_not_called()
|
||||
send.assert_not_called()
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_run_notify_skips_voided_sample_and_sends_the_rest():
|
||||
db = MagicMock()
|
||||
user = MagicMock()
|
||||
user.id = "u1"
|
||||
user.wx_mp_openid = "mp1"
|
||||
|
||||
with patch(
|
||||
"app.services.sample_collect_wx_notify._notify_enabled", return_value=True
|
||||
), patch(
|
||||
"app.services.sample_collect_wx_notify._load_donation_context",
|
||||
return_value={
|
||||
"create_time": datetime(2022, 11, 22, 16, 17),
|
||||
"applicant": "李某",
|
||||
"protocol_status": "sxz",
|
||||
"eta_by_code": {"D1": None, "D2": None},
|
||||
"sample_status_by_code": {"D1": "yzf", "D2": "ycj"},
|
||||
"sample_type_by_code": {"D1": "脐带间充质干细胞", "D2": "胎盘亚全能干细胞"},
|
||||
},
|
||||
), patch(
|
||||
"app.services.sample_collect_wx_notify.list_notify_recipient_users",
|
||||
return_value=[user],
|
||||
), patch(
|
||||
"app.services.sample_collect_wx_notify._already_success",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"app.services.wechat_mp_message.send_sample_collect_notify",
|
||||
return_value={"success": True},
|
||||
) as send, patch(
|
||||
"app.services.sample_collect_wx_notify._write_success_log"
|
||||
):
|
||||
stats = run_sample_collect_notify(
|
||||
db,
|
||||
tenant_no="zkxw",
|
||||
protocol_id="d1",
|
||||
protocol_kind=PROTOCOL_KIND_DONATION,
|
||||
new_sample_codes=["D1", "D2"],
|
||||
)
|
||||
assert stats["sent"] == 1
|
||||
assert stats["skipped"] == 1
|
||||
assert send.call_count == 1
|
||||
assert send.call_args.kwargs["sample_type"] == "胎盘亚全能干细胞"
|
||||
assert send.call_args.kwargs["task_type"] == TASK_TYPE_DONATION
|
||||
|
||||
|
||||
def test_list_notify_recipient_users_uses_effective_role_including_post():
|
||||
"""接收人须走有效角色(含职位继承),不能只查 sys_user_role。"""
|
||||
db = MagicMock()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""储存证质检信息:类型键与 payload 规范化。"""
|
||||
"""储存证质检信息:类型键、payload 与自动补空。"""
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.crud.sample_storage_cert_qc import _row_to_dict
|
||||
@@ -11,6 +12,14 @@ from app.services.sample_storage_cert_qc import (
|
||||
resolve_sample_type_key,
|
||||
supports_storage_cert_qc,
|
||||
)
|
||||
from app.services.sample_storage_cert_qc_prefill import (
|
||||
batch_kind_from_name,
|
||||
build_suggested,
|
||||
match_cd34_param,
|
||||
merge_prefill,
|
||||
qual_yin_yang,
|
||||
sum_cryo_volume,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveSampleTypeKey(unittest.TestCase):
|
||||
@@ -89,5 +98,246 @@ class TestRowToDict(unittest.TestCase):
|
||||
self.assertEqual(d["protocolCode"], "C1")
|
||||
|
||||
|
||||
def _task(item_name, stamp, params, batch_kind=""):
|
||||
values = [value for _name, value in params if str(value).strip()]
|
||||
return {
|
||||
"item_name": item_name,
|
||||
"stamp": stamp,
|
||||
"params": params,
|
||||
"values": values,
|
||||
"batch_kind": batch_kind,
|
||||
}
|
||||
|
||||
|
||||
class TestStorageCertPrefill(unittest.TestCase):
|
||||
def test_cd34_param_names(self):
|
||||
self.assertEqual(match_cd34_param("有核细胞数TNC/*108(个)"), "tnc_1e8")
|
||||
self.assertEqual(match_cd34_param("CD34+细胞数/*106(个)"), "cd34_count_1e6")
|
||||
self.assertEqual(match_cd34_param('CD34+ CD45dim 细胞数/*105(个)'), "cd34_cd45dim_1e5")
|
||||
|
||||
def test_volume_needs_both_numbers(self):
|
||||
self.assertEqual(sum_cryo_volume(10.5, 2), "12.5")
|
||||
self.assertEqual(sum_cryo_volume(10, None), "")
|
||||
self.assertEqual(sum_cryo_volume(None, 2), "")
|
||||
|
||||
def test_qual_only_explicit_yin_yang(self):
|
||||
self.assertEqual(qual_yin_yang(["阴性"]), "阴")
|
||||
self.assertEqual(qual_yin_yang(["通过"]), "")
|
||||
self.assertEqual(qual_yin_yang(["阴", "阳"]), "")
|
||||
self.assertEqual(qual_yin_yang(["未检出"]), "")
|
||||
|
||||
def test_hsc_fills_known_values_and_skips_unconfirmed(self):
|
||||
older = datetime(2026, 1, 1, 8, 0)
|
||||
newer = datetime(2026, 2, 2, 9, 0)
|
||||
suggested = build_suggested(
|
||||
sample_type_key="perinatal_hsc",
|
||||
collection_time=datetime(2026, 1, 1, 13, 45),
|
||||
reception_time=datetime(2026, 1, 2, 9, 0),
|
||||
enriched_blood_weight_g=20,
|
||||
cryopreservation_volume_ml=9.1,
|
||||
gate_tasks=[
|
||||
_task(
|
||||
"CD34+细胞流式",
|
||||
older,
|
||||
[("有核细胞数TNC/*108(个)", "1.1"), ("CD34+细胞数/*106(个)", "2.2")],
|
||||
),
|
||||
_task(
|
||||
"CD34+细胞流式",
|
||||
newer,
|
||||
[
|
||||
("有核细胞数TNC/*10E8", "8.27"),
|
||||
("CD34+细胞数/*106(个)", "55.5"),
|
||||
("CD34+ CD45dim 细胞数/*105(个)", "1.14"),
|
||||
],
|
||||
),
|
||||
],
|
||||
latest_freeze_date=datetime(2026, 3, 3, 16, 0),
|
||||
)
|
||||
self.assertEqual(suggested["collection_time"], "2026-01-01 13:45")
|
||||
self.assertEqual(suggested["receive_time"], "2026-01-02 09:00")
|
||||
self.assertEqual(suggested["cryo_volume_ml"], "29.1")
|
||||
self.assertEqual(suggested["tnc_1e8"], "8.27")
|
||||
self.assertEqual(suggested["cd34_count_1e6"], "55.5")
|
||||
self.assertEqual(suggested["cd34_cd45dim_1e5"], "1.14")
|
||||
self.assertEqual(suggested["storage_date"], "2026-03-03")
|
||||
self.assertEqual(suggested["storage_years"], "20年")
|
||||
self.assertEqual(suggested["bacteria_test"], "阴性")
|
||||
self.assertEqual(suggested["result_comment"], "合格")
|
||||
self.assertNotIn("hbsag", suggested)
|
||||
self.assertNotIn("mother_hbsag", suggested)
|
||||
self.assertNotIn("viability_pct", suggested)
|
||||
self.assertNotIn("hla_typing", suggested)
|
||||
self.assertNotIn("report_issue_date", suggested)
|
||||
|
||||
def test_hsc_serology_reads_batch_item_param(self):
|
||||
self.assertEqual(batch_kind_from_name("B0000075-脐血"), "脐血")
|
||||
self.assertEqual(batch_kind_from_name("B0000075-母血"), "母血")
|
||||
stamp = datetime(2026, 7, 4, 9, 0)
|
||||
suggested = build_suggested(
|
||||
sample_type_key="perinatal_hsc",
|
||||
gate_tasks=[
|
||||
_task(
|
||||
"乙肝五项定性",
|
||||
stamp,
|
||||
[
|
||||
("乙型肝炎病毒表面抗原", "阴性"),
|
||||
("抗乙型肝炎病毒表面抗体", "阳性"),
|
||||
],
|
||||
"脐血",
|
||||
),
|
||||
_task("抗丙型肝炎病毒抗体", stamp, [("抗丙型肝炎病毒抗体", "阳性")], "脐血"),
|
||||
_task("抗梅毒螺旋体特异抗体", stamp, [("抗梅毒螺旋体特异抗体", "阴性")], "脐血"),
|
||||
_task("抗人类免疫缺陷病毒抗体", stamp, [("抗人类免疫缺陷病毒抗体", "阴性")], "脐血"),
|
||||
_task("乙肝五项定性", stamp, [("乙型肝炎病毒表面抗原", "阳性")], "母血"),
|
||||
_task("乙型肝炎病毒DNA", stamp, [("乙型肝炎病毒DNA", "阴性")], "母血"),
|
||||
_task(
|
||||
"人类免疫缺陷病毒I型核酸(HIV-1 RNA)定量,实时荧光定量PCR",
|
||||
stamp,
|
||||
[],
|
||||
"母血",
|
||||
),
|
||||
_task("丙型肝炎病毒RNA", stamp, [("丙型肝炎病毒RNA", "通过")], "母血"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(suggested["hbsag"], "阴性")
|
||||
self.assertEqual(suggested["anti_hcv"], "阳性")
|
||||
self.assertEqual(suggested["anti_tp"], "阴性")
|
||||
self.assertEqual(suggested["anti_hiv"], "阴性")
|
||||
self.assertNotIn("cmv_igm", suggested)
|
||||
self.assertEqual(suggested["mother_hbsag"], "阳性")
|
||||
self.assertEqual(suggested["mother_hbv_dna"], "阴性")
|
||||
self.assertNotIn("mother_hiv1_rna", suggested)
|
||||
self.assertNotIn("mother_hcv_rna", suggested)
|
||||
self.assertEqual(suggested["bacteria_test"], "阴性")
|
||||
self.assertNotIn("viability_pct", suggested)
|
||||
|
||||
def test_uc_msc_fixed_values_and_process_qual(self):
|
||||
suggested = build_suggested(
|
||||
sample_type_key="uc_msc",
|
||||
receive_temperature="常温",
|
||||
storage_total_tubes=17,
|
||||
available_cell_count_1e7=8.5,
|
||||
process_tasks=[
|
||||
_task("内毒素(透析液)检测", datetime(2026, 1, 1), [("结果", "阴性")]),
|
||||
_task("真菌培养及鉴定", datetime(2026, 1, 2), [("结论", "通过")]),
|
||||
_task("支原体培养+药敏", datetime(2026, 1, 3), [("结果", "阳")]),
|
||||
],
|
||||
latest_freeze_date=datetime(2026, 4, 4),
|
||||
)
|
||||
self.assertEqual(suggested["transport_temp_c"], "常温")
|
||||
self.assertEqual(suggested["storage_tube_count"], "17")
|
||||
self.assertEqual(suggested["available_cell_count_1e7"], "8.5")
|
||||
self.assertEqual(suggested["endotoxin_test"], "阴性")
|
||||
self.assertNotIn("fungus_test", suggested)
|
||||
self.assertEqual(suggested["mycoplasma_test"], "阳性")
|
||||
self.assertEqual(suggested["bacteria_test"], "阴性")
|
||||
self.assertEqual(suggested["result_judgment"], "合格")
|
||||
self.assertNotIn("mother_hbsag", suggested)
|
||||
self.assertEqual(suggested["available_cell_type"], "间充质干细胞")
|
||||
self.assertEqual(suggested["available_cell_morphology"], "短梭/贴壁型")
|
||||
self.assertEqual(suggested["available_immunophenotype"], "符合间充质干细胞表型特性")
|
||||
self.assertEqual(suggested["freeze_method"], "程序降温盒降温")
|
||||
self.assertEqual(suggested["storage_temp"], "-196℃")
|
||||
self.assertEqual(suggested["storage_years"], "20年")
|
||||
self.assertEqual(suggested["storage_date_p2"], "2026-04-04")
|
||||
self.assertNotIn("available_viability_pct", suggested)
|
||||
self.assertNotIn("report_issue_date", suggested)
|
||||
|
||||
def test_uc_msc_mother_blood_and_ignores_bacteria_culture(self):
|
||||
stamp = datetime(2026, 5, 5, 8, 0)
|
||||
suggested = build_suggested(
|
||||
sample_type_key="uc_msc",
|
||||
gate_tasks=[
|
||||
_task(
|
||||
"乙肝五项定性",
|
||||
stamp,
|
||||
[
|
||||
("乙型肝炎病毒表面抗原", "阳性"),
|
||||
("抗乙型肝炎病毒表面抗体", "阴性"),
|
||||
],
|
||||
"母血",
|
||||
),
|
||||
_task("乙型肝炎病毒DNA", stamp, [], "母血"),
|
||||
],
|
||||
process_tasks=[
|
||||
_task("需氧菌培养及鉴定(相关体液)", stamp, [("需氧菌培养及鉴定(相关体液)", "阳性")]),
|
||||
_task(
|
||||
"支原体培养+药敏",
|
||||
stamp,
|
||||
[("解脲(Uu)支原体培养", "阴性"), ("人型(Mh)支原体培养", "阴性")],
|
||||
),
|
||||
],
|
||||
)
|
||||
self.assertEqual(suggested["mother_hbsag"], "阳性")
|
||||
self.assertNotIn("mother_hbv_dna", suggested)
|
||||
self.assertEqual(suggested["mycoplasma_test"], "阴性")
|
||||
self.assertEqual(suggested["bacteria_test"], "阴性")
|
||||
self.assertNotIn("hbsag", suggested)
|
||||
|
||||
def test_shared_mother_blood_fills_sample_without_its_own(self):
|
||||
stamp = datetime(2026, 7, 4, 9, 0)
|
||||
shared = [
|
||||
_task(
|
||||
"乙肝五项定性",
|
||||
stamp,
|
||||
[
|
||||
("乙型肝炎病毒表面抗原", "阴性"),
|
||||
("抗乙型肝炎病毒表面抗体", "阳性"),
|
||||
],
|
||||
"母血",
|
||||
),
|
||||
_task("乙型肝炎病毒DNA", stamp, [("乙型肝炎病毒DNA", "阴性")], "母血"),
|
||||
_task("抗丙型肝炎病毒抗体", stamp, [("抗丙型肝炎病毒抗体", "阴性")], "母血"),
|
||||
_task("丙型肝炎病毒RNA", stamp, [("丙型肝炎病毒RNA", "阴性")], "母血"),
|
||||
_task("抗梅毒螺旋体特异抗体", stamp, [("抗梅毒螺旋体特异抗体", "阴性")], "母血"),
|
||||
_task("抗巨细胞病毒抗体IgM定量", stamp, [("抗巨细胞病毒抗体IgM定量", "阴性")], "母血"),
|
||||
_task("抗人类免疫缺陷病毒抗体", stamp, [("抗人类免疫缺陷病毒抗体", "阴性")], "母血"),
|
||||
_task(
|
||||
"人类免疫缺陷病毒I型核酸(HIV-1 RNA)定量,实时荧光定量PCR",
|
||||
stamp,
|
||||
[("人类免疫缺陷病毒核酸(1型) (HIV-1RNA)", "阴性")],
|
||||
"母血",
|
||||
),
|
||||
_task("乙肝五项定性", stamp, [("乙型肝炎病毒表面抗原", "阳性")], "脐血"),
|
||||
]
|
||||
suggested = build_suggested(sample_type_key="uc_msc", shared_mother_tasks=shared)
|
||||
self.assertEqual(suggested["mother_hbsag"], "阴性")
|
||||
self.assertEqual(suggested["mother_hbv_dna"], "阴性")
|
||||
self.assertEqual(suggested["mother_anti_hcv"], "阴性")
|
||||
self.assertEqual(suggested["mother_hcv_rna"], "阴性")
|
||||
self.assertEqual(suggested["mother_anti_tp"], "阴性")
|
||||
self.assertEqual(suggested["mother_cmv_igm"], "阴性")
|
||||
self.assertEqual(suggested["mother_anti_hiv"], "阴性")
|
||||
self.assertEqual(suggested["mother_hiv1_rna"], "阴性")
|
||||
self.assertNotIn("hbsag", suggested)
|
||||
|
||||
def test_own_mother_blood_wins_and_shared_only_fills_gaps(self):
|
||||
stamp = datetime(2026, 7, 4, 9, 0)
|
||||
suggested = build_suggested(
|
||||
sample_type_key="perinatal_hsc",
|
||||
gate_tasks=[
|
||||
_task("乙肝五项定性", stamp, [("乙型肝炎病毒表面抗原", "阳性")], "脐血"),
|
||||
_task("乙肝五项定性", stamp, [("乙型肝炎病毒表面抗原", "阳性")], "母血"),
|
||||
],
|
||||
shared_mother_tasks=[
|
||||
_task("乙肝五项定性", stamp, [("乙型肝炎病毒表面抗原", "阴性")], "母血"),
|
||||
_task("乙型肝炎病毒DNA", stamp, [("乙型肝炎病毒DNA", "阴性")], "母血"),
|
||||
_task("乙肝五项定性", stamp, [("乙型肝炎病毒表面抗原", "阴性")], "脐血"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(suggested["hbsag"], "阳性")
|
||||
self.assertEqual(suggested["mother_hbsag"], "阳性")
|
||||
self.assertEqual(suggested["mother_hbv_dna"], "阴性")
|
||||
|
||||
def test_merge_keeps_saved_values(self):
|
||||
merged = merge_prefill(
|
||||
{"storage_years": "30年", "collection_time": ""},
|
||||
{"storage_years": "20年", "collection_time": "2026-01-01 13:45", "hbsag": ""},
|
||||
)
|
||||
self.assertEqual(merged["storage_years"], "30年")
|
||||
self.assertEqual(merged["collection_time"], "2026-01-01 13:45")
|
||||
self.assertNotIn("hbsag", merged)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""协议样本保存保留原行 ID,避免下游外键被整表替换打断。"""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.crud.collection_protocol import save_samples
|
||||
from app.crud.research_donation import _create_samples
|
||||
from app.models.models import BizProtocolSample
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonationSample,
|
||||
BizResearchDonationSampleTransport,
|
||||
)
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
BizProtocolSample.__table__.create(bind=engine)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _sample(**kwargs):
|
||||
now = datetime(2026, 10, 27, 9, 0, 0)
|
||||
data = dict(
|
||||
collection_protocol_id="proto-1",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
data.update(kwargs)
|
||||
return BizProtocolSample(**data)
|
||||
|
||||
|
||||
class TestSaveSamplesKeepsIdentity(unittest.TestCase):
|
||||
def test_existing_sample_keeps_id_and_removed_sample_is_soft_deleted(self):
|
||||
db = _session()
|
||||
db.add(_sample(id="sample-keep", sample_code="SC001"))
|
||||
db.add(_sample(id="sample-drop", sample_code="SC002"))
|
||||
db.commit()
|
||||
|
||||
with patch("app.crud.sample_freight.apply_freight_on_protocol_save"), patch(
|
||||
"app.crud.collection_protocol.save_sample_transports"
|
||||
), patch("app.crud.collection_protocol._migrate_sample_child_refs_on_replace"):
|
||||
saved = save_samples(
|
||||
db,
|
||||
"proto-1",
|
||||
"tenant-a",
|
||||
"user-1",
|
||||
[{"id": "sample-keep", "sample_code": "SC001", "remark": "改备注"}],
|
||||
)
|
||||
|
||||
self.assertEqual(saved[0].id, "sample-keep")
|
||||
self.assertEqual(saved[0].remark, "改备注")
|
||||
rows = {row.id: row for row in db.query(BizProtocolSample).all()}
|
||||
self.assertEqual(rows["sample-keep"].del_flag, 0)
|
||||
self.assertEqual(rows["sample-drop"].del_flag, 1)
|
||||
|
||||
def test_same_sample_code_without_id_still_keeps_row(self):
|
||||
db = _session()
|
||||
db.add(_sample(id="sample-keep", sample_code="SC001", remark="旧"))
|
||||
db.commit()
|
||||
|
||||
with patch("app.crud.sample_freight.apply_freight_on_protocol_save"), patch(
|
||||
"app.crud.collection_protocol.save_sample_transports"
|
||||
), patch("app.crud.collection_protocol._migrate_sample_child_refs_on_replace"):
|
||||
saved = save_samples(
|
||||
db,
|
||||
"proto-1",
|
||||
"tenant-a",
|
||||
"user-1",
|
||||
[{"sample_code": "SC001", "remark": "新"}],
|
||||
)
|
||||
|
||||
self.assertEqual(saved[0].id, "sample-keep")
|
||||
self.assertEqual(saved[0].remark, "新")
|
||||
self.assertEqual(db.query(BizProtocolSample).filter_by(del_flag=0).count(), 1)
|
||||
|
||||
|
||||
class TestDonationSamplesKeepIdentity(unittest.TestCase):
|
||||
def test_existing_donation_sample_keeps_id(self):
|
||||
engine = create_engine("sqlite://")
|
||||
BizResearchDonationSample.__table__.create(bind=engine)
|
||||
BizResearchDonationSampleTransport.__table__.create(bind=engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
now = datetime(2026, 10, 27, 9, 0, 0)
|
||||
db.add(
|
||||
BizResearchDonationSample(
|
||||
id="don-keep",
|
||||
research_donation_id="don-1",
|
||||
tenant_no="tenant-a",
|
||||
sample_code="DN001",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
BizResearchDonationSample(
|
||||
id="don-drop",
|
||||
research_donation_id="don-1",
|
||||
tenant_no="tenant-a",
|
||||
sample_code="DN002",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
saved = _create_samples(
|
||||
db,
|
||||
"don-1",
|
||||
"tenant-a",
|
||||
"user-1",
|
||||
[{"sample_code": "DN001", "remark": "留下"}],
|
||||
now,
|
||||
)
|
||||
|
||||
self.assertEqual(saved[0].id, "don-keep")
|
||||
self.assertEqual(saved[0].remark, "留下")
|
||||
rows = {row.id: row for row in db.query(BizResearchDonationSample).all()}
|
||||
self.assertEqual(rows["don-keep"].del_flag, 0)
|
||||
self.assertEqual(rows["don-drop"].del_flag, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""盘点未用完明细按行保存,再次登记同一批次时保留原行 id。"""
|
||||
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.crud.material_stock_check import save_unused_lines
|
||||
from app.models.material import (
|
||||
BizMaterialStockBatch,
|
||||
BizMaterialStockCheck,
|
||||
BizMaterialStockCheckUnusedLine,
|
||||
)
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
for model in (
|
||||
BizMaterialStockCheck,
|
||||
BizMaterialStockBatch,
|
||||
BizMaterialStockCheckUnusedLine,
|
||||
):
|
||||
model.__table__.create(bind=engine)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
class StockCheckUnusedLinesKeepIdentity(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.db = _session()
|
||||
now = datetime(2026, 10, 27, 10, 0, 0)
|
||||
self.db.add(
|
||||
BizMaterialStockCheck(
|
||||
id="check-1",
|
||||
title="盘点",
|
||||
check_time=now,
|
||||
status="draft",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
BizMaterialStockBatch(
|
||||
id="batch-1",
|
||||
material_id="mat-1",
|
||||
material_code="M-1",
|
||||
material_name="培养基",
|
||||
stock_in_no="RK-1",
|
||||
stock_in_date=date(2026, 10, 1),
|
||||
expire_date=date(2027, 10, 1),
|
||||
catalog_no="C-1",
|
||||
batch_no="B-1",
|
||||
manufacturer="厂家",
|
||||
unit_price=Decimal("10.00"),
|
||||
amount=Decimal("100.00"),
|
||||
in_qty=Decimal("10"),
|
||||
remaining_qty=Decimal("4"),
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
|
||||
def test_resave_same_batch_keeps_id_and_drops_removed_line(self):
|
||||
save_unused_lines(
|
||||
self.db,
|
||||
"check-1",
|
||||
items=[
|
||||
{"batch_id": "batch-1", "material_name": "培养基", "unused_qty": 2},
|
||||
{"material_name": "手工行", "unused_qty": 1},
|
||||
],
|
||||
tenant_no="tenant-a",
|
||||
user_id="user-1",
|
||||
)
|
||||
rows = (
|
||||
self.db.query(BizMaterialStockCheckUnusedLine)
|
||||
.filter(BizMaterialStockCheckUnusedLine.del_flag == 0)
|
||||
.order_by(BizMaterialStockCheckUnusedLine.order_num.asc())
|
||||
.all()
|
||||
)
|
||||
self.assertEqual(len(rows), 2)
|
||||
kept_id = rows[0].id
|
||||
manual_id = rows[1].id
|
||||
|
||||
save_unused_lines(
|
||||
self.db,
|
||||
"check-1",
|
||||
items=[{"batch_id": "batch-1", "material_name": "培养基", "unused_qty": 3}],
|
||||
tenant_no="tenant-a",
|
||||
user_id="user-1",
|
||||
)
|
||||
live = (
|
||||
self.db.query(BizMaterialStockCheckUnusedLine)
|
||||
.filter(BizMaterialStockCheckUnusedLine.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
self.assertEqual([row.id for row in live], [kept_id])
|
||||
self.assertEqual(live[0].unused_qty, Decimal("3"))
|
||||
removed = (
|
||||
self.db.query(BizMaterialStockCheckUnusedLine)
|
||||
.filter(BizMaterialStockCheckUnusedLine.id == manual_id)
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(removed.del_flag, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -345,22 +345,22 @@ def _route_strict_codes(router, endpoint_name):
|
||||
"create_research_donation",
|
||||
"update_research_donation",
|
||||
],
|
||||
{PROTOCOL_LIST},
|
||||
set(),
|
||||
),
|
||||
(
|
||||
"protocol_sample_payment",
|
||||
["confirm_payment_transfer", "reject_payment_transfer"],
|
||||
{PAYMENT_CONFIRM},
|
||||
set(),
|
||||
),
|
||||
(
|
||||
"presign_order",
|
||||
["confirm_presign_transfer", "reject_presign_transfer"],
|
||||
{PAYMENT_CONFIRM},
|
||||
set(),
|
||||
),
|
||||
(
|
||||
"sample_cert_insurance",
|
||||
["put_sample_storage_cert", "put_sample_insurance"],
|
||||
{STORAGE_CERT},
|
||||
set(),
|
||||
),
|
||||
(
|
||||
"sample_storage_cert_qc",
|
||||
@@ -432,7 +432,8 @@ def test_collection_protocol_write_paths_keep_payment_detail_guard_only():
|
||||
def test_research_donation_write_paths_assert_sensitive_field_permissions():
|
||||
source = Path("app/api/research_donation.py").read_text()
|
||||
assert "assert_donation_sensitive_fields" in source
|
||||
assert PROTOCOL_AUDIT in source
|
||||
assert "require_strict_menu_code" not in source
|
||||
assert "assert_strict_menu_code" not in source
|
||||
|
||||
|
||||
def _permission_user(db, *codes):
|
||||
@@ -497,7 +498,7 @@ def test_protocol_sensitive_fields_no_longer_require_menu_permissions():
|
||||
)
|
||||
|
||||
|
||||
def test_donation_sensitive_fields_require_audit_permission():
|
||||
def test_donation_sensitive_fields_no_longer_require_menu_permissions():
|
||||
from app.api.research_donation import (
|
||||
DonationCreateBody,
|
||||
DonationUpdateBody,
|
||||
@@ -507,27 +508,17 @@ def test_donation_sensitive_fields_require_audit_permission():
|
||||
db = _session()
|
||||
user = _permission_user(db)
|
||||
assert_donation_sensitive_fields(db, user, DonationCreateBody(), creating=True)
|
||||
|
||||
for body, creating in (
|
||||
(DonationCreateBody(donationStatus="sxz"), True),
|
||||
(DonationCreateBody(sourceType="cell_storage"), True),
|
||||
(DonationUpdateBody(sourceProtocolId="p1"), False),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
assert_donation_sensitive_fields(db, user, body, creating=creating)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert_donation_sensitive_fields(db, user, body, creating=creating)
|
||||
|
||||
|
||||
def test_market_only_user_is_stopped_before_sensitive_crud(monkeypatch):
|
||||
"""其它仍挂菜单码的写接口:仅有小程序市场权限时仍应 403。
|
||||
|
||||
采集协议单接口已按方案去掉菜单码,不在本用例断言范围内。
|
||||
"""
|
||||
from app.api import (
|
||||
protocol_sample_payment,
|
||||
sample_cert_insurance,
|
||||
sample_storage_cert_qc,
|
||||
)
|
||||
def test_market_only_user_is_stopped_before_storage_cert_qc_write(monkeypatch):
|
||||
"""储存证质控写接口仍挂严格菜单码:无对应菜单时应 403。"""
|
||||
from app.api import sample_storage_cert_qc
|
||||
|
||||
db = _thread_session()
|
||||
_add_user(db)
|
||||
@@ -550,21 +541,10 @@ def test_market_only_user_is_stopped_before_sensitive_crud(monkeypatch):
|
||||
called.append((args, kwargs))
|
||||
raise AssertionError("sensitive CRUD must not be called")
|
||||
|
||||
monkeypatch.setattr(
|
||||
protocol_sample_payment.psp_svc, "confirm_receipt", forbidden_call
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sample_cert_insurance.cert_crud, "upsert_handle", forbidden_call
|
||||
)
|
||||
monkeypatch.setattr(sample_storage_cert_qc.svc, "upsert", forbidden_call)
|
||||
|
||||
app = FastAPI()
|
||||
for router in (
|
||||
protocol_sample_payment.router,
|
||||
sample_cert_insurance.router,
|
||||
sample_storage_cert_qc.router,
|
||||
):
|
||||
app.include_router(router)
|
||||
app.include_router(sample_storage_cert_qc.router)
|
||||
|
||||
user = {"user_id": "u1", "tenant_no": "tenant-a", "role": "admin"}
|
||||
|
||||
@@ -574,16 +554,11 @@ def test_market_only_user_is_stopped_before_sensitive_crud(monkeypatch):
|
||||
app.dependency_overrides[get_current_user] = lambda: user
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
|
||||
requests = [
|
||||
("post", "/web/admin/payment-transfers/t1/receipt-confirm", {}),
|
||||
("put", "/web/admin/sample-storage-certs/S1", {}),
|
||||
("put", "/web/admin/sample-storage-cert-qc/S1", {"payload": {}}),
|
||||
]
|
||||
client = TestClient(app)
|
||||
for method, url, body in requests:
|
||||
response = client.request(method, url, json=body)
|
||||
assert response.status_code == 403, (method, url, response.text)
|
||||
|
||||
response = client.request(
|
||||
"put", "/web/admin/sample-storage-cert-qc/S1", json={"payload": {}}
|
||||
)
|
||||
assert response.status_code == 403, response.text
|
||||
assert called == []
|
||||
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 预签约订单菜单:插入 sys_menu,并授权给已有「采集协议单」的角色
|
||||
-- 测试库 zkxw-sc 已确认原先没有该菜单行。
|
||||
-- 菜单位置:采集协议单 之下、缴费信息/样本信息管理 之上
|
||||
-- id: 50000000000000000000000000000020
|
||||
-- perms: cell-storage:presign-order:list
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
'50000000000000000000000000000020',
|
||||
'预签约订单',
|
||||
'CEL00000000000000000000000000001',
|
||||
'C',
|
||||
'presign-order',
|
||||
'/cell-storage/presign-order/index',
|
||||
'cell-storage:presign-order:list',
|
||||
1,
|
||||
'1',
|
||||
'1',
|
||||
'zkxw',
|
||||
3,
|
||||
'细胞存储:预签约订单',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu m
|
||||
WHERE m.del_flag = 0
|
||||
AND (
|
||||
m.id = '50000000000000000000000000000020'
|
||||
OR m.perms = 'cell-storage:presign-order:list'
|
||||
)
|
||||
);
|
||||
|
||||
-- 已有采集协议单权限的角色补授权(幂等)
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(r.id, '|50000000000000000000000000000020')),
|
||||
r.id,
|
||||
'50000000000000000000000000000020',
|
||||
IFNULL(NULLIF(r.tenant_no, ''), 'zkxw'),
|
||||
0,
|
||||
'grant presign-order to protocol-audit roles',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role r
|
||||
INNER JOIN sys_role_menu rm
|
||||
ON rm.role_id = r.id
|
||||
AND rm.menu_id = '50000000000000000000000000000003'
|
||||
AND rm.del_flag = 0
|
||||
WHERE r.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu x
|
||||
WHERE x.role_id = r.id
|
||||
AND x.menu_id = '50000000000000000000000000000020'
|
||||
AND x.del_flag = 0
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
-- 储存证 / 满月保险轻量办理字段(2026-10-05)
|
||||
ALTER TABLE `biz_protocol_storage_cert`
|
||||
ADD COLUMN `handle_time` datetime DEFAULT NULL COMMENT '办理时间' AFTER `make_date`,
|
||||
ADD COLUMN `handler_user_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '办理人用户ID' AFTER `handle_time`;
|
||||
|
||||
ALTER TABLE `biz_protocol_fullmoon_todo`
|
||||
ADD COLUMN `handle_time` datetime DEFAULT NULL COMMENT '办理时间' AFTER `handle_date`,
|
||||
ADD COLUMN `handler_user_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '办理人用户ID' AFTER `handle_time`;
|
||||
@@ -1,119 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 缴费信息独立菜单:显示与排序(2026-10-05)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- 【主路径 — 前端固定菜单】
|
||||
-- 当前 Web 管理端使用 VITE_ACCESS_MODE=frontend,菜单树由以下两处维护,Task 4 已更新:
|
||||
-- - backend/app/api/menu.py → CellStoragePaymentInfo(id 904b5173519411f1944300163e3bdd96)
|
||||
-- - web-admin/src/router/modules/sqxp.ts → 预签约订单与样本信息管理之间,已取消 isHide
|
||||
-- 前端模式环境 **无需执行** 本脚本;保留本文件供 DB 菜单模式或角色授权同步参考。
|
||||
--
|
||||
-- 【可选 — DB 菜单模式】
|
||||
-- sys_menu 列名以 backend/app/models/models.py :: SysMenu 为准(docs/sql/zkxw.sql 无 sys_menu 段)。
|
||||
-- visible: '1'=显示, '0'=隐藏(与 menu.py meta.isHide 映射一致,见 reorder_web_menus.py)
|
||||
--
|
||||
-- 细胞存储目录 parent_id: CEL00000000000000000000000000001
|
||||
-- 预签约订单 id: 50000000000000000000000000000020
|
||||
-- 缴费信息 id: 904b5173519411f1944300163e3bdd96 path=payment-info perms=cellStorage:paymentInfo:list
|
||||
-- 样本信息 id: 50000000000000000000000000000013
|
||||
-- =============================================================================
|
||||
|
||||
-- 1) 若库中已有该菜单(按 id 或 perms 匹配):取消隐藏并调整排序至预签约与样本信息之间
|
||||
UPDATE sys_menu
|
||||
SET del_flag = 0,
|
||||
visible = '1',
|
||||
status = '1',
|
||||
order_num = (
|
||||
SELECT mid_order FROM (
|
||||
SELECT COALESCE(MAX(m2.order_num), 0) AS mid_order
|
||||
FROM sys_menu m2
|
||||
WHERE m2.del_flag = 0
|
||||
AND (
|
||||
m2.id = '50000000000000000000000000000020'
|
||||
OR m2.perms = 'cell-storage:presign-order:list'
|
||||
OR m2.path = 'presign-order'
|
||||
)
|
||||
) t
|
||||
) + 1,
|
||||
update_time = NOW()
|
||||
WHERE del_flag = 0
|
||||
AND (
|
||||
id = '904b5173519411f1944300163e3bdd96'
|
||||
OR perms = 'cellStorage:paymentInfo:list'
|
||||
OR (path = 'payment-info' AND parent_id = 'CEL00000000000000000000000000001')
|
||||
);
|
||||
|
||||
-- 2) 若不存在则幂等插入(仅 DB 菜单模式需要;前端模式可跳过)
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
'904b5173519411f1944300163e3bdd96',
|
||||
'缴费信息',
|
||||
'CEL00000000000000000000000000001',
|
||||
'C',
|
||||
'payment-info',
|
||||
'/cell-storage/payment-info/index',
|
||||
'cellStorage:paymentInfo:list',
|
||||
1,
|
||||
'1',
|
||||
'1',
|
||||
'zkxw',
|
||||
COALESCE((
|
||||
SELECT MAX(m2.order_num) + 1
|
||||
FROM sys_menu m2
|
||||
WHERE m2.del_flag = 0
|
||||
AND (
|
||||
m2.id = '50000000000000000000000000000020'
|
||||
OR m2.perms = 'cell-storage:presign-order:list'
|
||||
)
|
||||
), 1),
|
||||
'细胞存储:缴费信息(流程看板 + 按样本),独立于采集协议单',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu m
|
||||
WHERE m.del_flag = 0
|
||||
AND (
|
||||
m.id = '904b5173519411f1944300163e3bdd96'
|
||||
OR m.perms = 'cellStorage:paymentInfo:list'
|
||||
)
|
||||
);
|
||||
|
||||
-- 3) 已有「采集协议单」权限的角色补授权(幂等)
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(r.id, '|904b5173519411f1944300163e3bdd96')),
|
||||
r.id,
|
||||
'904b5173519411f1944300163e3bdd96',
|
||||
IFNULL(NULLIF(r.tenant_no, ''), 'zkxw'),
|
||||
0,
|
||||
'grant payment-info to protocol-audit roles',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role r
|
||||
INNER JOIN sys_role_menu rm
|
||||
ON rm.role_id = r.id
|
||||
AND rm.menu_id = '50000000000000000000000000000003'
|
||||
AND rm.del_flag = 0
|
||||
WHERE r.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu x
|
||||
WHERE x.role_id = r.id
|
||||
AND x.menu_id = '904b5173519411f1944300163e3bdd96'
|
||||
AND x.del_flag = 0
|
||||
);
|
||||
@@ -1,29 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 下线「科研捐赠 → 样本信息管理」侧栏菜单(2026-10-06)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- 【主路径 — 前端固定菜单】
|
||||
-- 当前 Web 管理端使用 VITE_ACCESS_MODE=frontend,菜单树已从以下两处移除该子项:
|
||||
-- - web-admin/src/router/modules/sqxp.ts
|
||||
-- - backend/app/api/menu.py
|
||||
-- 样本信息仍从「细胞存储 → 样本信息管理」进入(统一页含科研捐赠 Tab)。
|
||||
-- 前端模式环境 **无需执行** 本脚本;保留供 DB 菜单模式同步。
|
||||
--
|
||||
-- 科研捐赠目录 parent_id: RSC00000000000000000000000000001
|
||||
-- 样本信息 id: 60000000000000000000000000000004
|
||||
-- perms: research-donation:sample-info:list
|
||||
-- visible: '0'=隐藏(权限码保留,统一页 Tab / 接口授权仍可用)
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE sys_menu
|
||||
SET visible = '0',
|
||||
update_time = NOW()
|
||||
WHERE del_flag = 0
|
||||
AND (
|
||||
id = '60000000000000000000000000000004'
|
||||
OR perms = 'research-donation:sample-info:list'
|
||||
OR (
|
||||
path = 'sample-info'
|
||||
AND parent_id = 'RSC00000000000000000000000000001'
|
||||
)
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 下线「科研捐赠」协议单侧栏菜单(含一级空目录)(2026-10-07)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- 【主路径 — 前端固定菜单】
|
||||
-- VITE_ACCESS_MODE=frontend 时已在以下文件隐藏:
|
||||
-- - web-admin/src/router/modules/sqxp.ts
|
||||
-- - backend/app/api/menu.py
|
||||
-- 协议单从「细胞存储 → 采集协议单」进入(统一页含科研捐赠 Tab)。
|
||||
-- 前端模式环境 **无需执行** 本脚本;保留供 DB 菜单模式 / 生产环境同步。
|
||||
--
|
||||
-- 科研捐赠目录 parent_id / id: RSC00000000000000000000000000001
|
||||
-- 捐赠协议单录入 id: 60000000000000000000000000000002
|
||||
-- perms: biz:researchDonation:list
|
||||
-- 捐赠协议单 id: 60000000000000000000000000000003
|
||||
-- perms: biz:researchDonationAudit:list
|
||||
-- visible: '0'=隐藏(权限码可保留;统一页不依赖捐赠菜单控 Tab)
|
||||
-- 注意:不要隐藏样本信息菜单 id 6000...0004(已由 2026-10-06 脚本单独处理)
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE sys_menu
|
||||
SET visible = '0',
|
||||
update_time = NOW()
|
||||
WHERE del_flag = 0
|
||||
AND (
|
||||
id IN (
|
||||
'RSC00000000000000000000000000001',
|
||||
'60000000000000000000000000000002',
|
||||
'60000000000000000000000000000003'
|
||||
)
|
||||
OR perms IN (
|
||||
'biz:researchDonation:list',
|
||||
'biz:researchDonationAudit:list'
|
||||
)
|
||||
OR (
|
||||
parent_id = 'RSC00000000000000000000000000001'
|
||||
AND path IN ('list', 'audit')
|
||||
)
|
||||
);
|
||||
@@ -1,9 +0,0 @@
|
||||
-- 采集协议当事人:父亲/子女客户 ID(母亲沿用 customer_id)
|
||||
ALTER TABLE biz_protocol_party_customer
|
||||
ADD COLUMN father_customer_id VARCHAR(64) DEFAULT NULL COMMENT '父亲客户ID' AFTER customer_id,
|
||||
ADD COLUMN newborn_customer_id VARCHAR(64) DEFAULT NULL COMMENT '子女客户ID' AFTER father_customer_id;
|
||||
|
||||
-- 科研捐赠当事人:同上
|
||||
ALTER TABLE biz_research_donation_party
|
||||
ADD COLUMN father_customer_id VARCHAR(64) DEFAULT NULL COMMENT '父亲客户ID' AFTER customer_id,
|
||||
ADD COLUMN newborn_customer_id VARCHAR(64) DEFAULT NULL COMMENT '子女客户ID' AFTER father_customer_id;
|
||||
@@ -1,77 +0,0 @@
|
||||
-- 样本运费批次 + 关联 + 运输方式字典预置(租户 zkxw;其他租户首次用时由后端按需补齐预置也可只插 zkxw)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_sample_freight (
|
||||
id VARCHAR(64) NOT NULL COMMENT '主键ID',
|
||||
transport_mode VARCHAR(100) NOT NULL DEFAULT '' COMMENT '运输方式字典值 sample_transport_mode',
|
||||
freight_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '运费(元)',
|
||||
tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
order_num INT DEFAULT NULL COMMENT '排序号',
|
||||
remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
|
||||
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
|
||||
create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间',
|
||||
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间',
|
||||
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_sample_freight_tenant_ctime (tenant_no, create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本运费批次';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_sample_freight_item (
|
||||
id VARCHAR(64) NOT NULL COMMENT '主键ID',
|
||||
freight_id VARCHAR(64) NOT NULL COMMENT '运费批次ID',
|
||||
sample_source VARCHAR(32) NOT NULL COMMENT 'cell_storage|research_donation',
|
||||
sample_id VARCHAR(64) NOT NULL COMMENT '样本ID',
|
||||
sample_code VARCHAR(32) DEFAULT NULL COMMENT '样本编码快照',
|
||||
protocol_id VARCHAR(64) DEFAULT NULL COMMENT '协议单ID',
|
||||
tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
order_num INT DEFAULT NULL COMMENT '排序号',
|
||||
remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
|
||||
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
|
||||
create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间',
|
||||
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间',
|
||||
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_freight_item_freight (freight_id, del_flag),
|
||||
KEY idx_freight_item_sample (tenant_no, sample_source, sample_id, del_flag)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本运费关联';
|
||||
|
||||
-- 字典类型(若不存在)
|
||||
INSERT INTO sys_dict_type (
|
||||
id, dict_name, dict_type, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
REPLACE(UUID(),'-',''), '样本运输方式', 'sample_transport_mode', 'zkxw', 0, '协议单运费批次运输方式',
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_dict_type
|
||||
WHERE dict_type = 'sample_transport_mode' AND tenant_no = 'zkxw' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO sys_dict_data (
|
||||
id, dict_label, dict_value, dict_type, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(),'-',''), '顺丰快递', 'sfkd', 'sample_transport_mode', 'zkxw', 1, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_dict_data
|
||||
WHERE dict_type = 'sample_transport_mode' AND tenant_no = 'zkxw' AND dict_value = 'sfkd' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO sys_dict_data (
|
||||
id, dict_label, dict_value, dict_type, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(),'-',''), '专线车', 'zxc', 'sample_transport_mode', 'zkxw', 2, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_dict_data
|
||||
WHERE dict_type = 'sample_transport_mode' AND tenant_no = 'zkxw' AND dict_value = 'zxc' AND del_flag = 0
|
||||
);
|
||||
@@ -1,10 +0,0 @@
|
||||
-- 客户主档:通用个人档案字段
|
||||
-- Spec: docs/superpowers/specs/2026-10-08-customer-profile-expand-sync-design.md
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `biz_customer`
|
||||
ADD COLUMN `birth_ym` char(6) DEFAULT NULL COMMENT '出生年月YYYYMM' AFTER `age`,
|
||||
ADD COLUMN `ethnic` varchar(32) DEFAULT NULL COMMENT '民族' AFTER `birth_ym`,
|
||||
ADD COLUMN `email` varchar(128) DEFAULT NULL COMMENT '邮箱' AFTER `ethnic`,
|
||||
ADD COLUMN `address` varchar(512) DEFAULT NULL COMMENT '家庭住址' AFTER `email`;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- 采集协议 / 科研捐赠:地区经理用户ID
|
||||
-- Spec: docs/superpowers/specs/2026-10-08-protocol-market-specialist-region-manager-design.md
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `biz_protocol_collection_info`
|
||||
ADD COLUMN `region_manager_user_id` varchar(64) DEFAULT NULL COMMENT '地区经理用户ID' AFTER `sales_user_id`;
|
||||
|
||||
ALTER TABLE `biz_research_donation_collection`
|
||||
ADD COLUMN `region_manager_user_id` varchar(64) DEFAULT NULL COMMENT '地区经理用户ID' AFTER `sales_user_id`;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 侧栏二级菜单「细胞存储」更名为「存储服务」
|
||||
-- id: CEL00000000000000000000000000001
|
||||
-- 原因:该目录下统一承载细胞存储与科研捐赠业务入口
|
||||
--
|
||||
-- 【前端固定菜单】同步修改:
|
||||
-- - web-admin/src/router/modules/sqxp.ts
|
||||
-- - backend/app/api/menu.py
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE sys_menu
|
||||
SET menu_name = '存储服务',
|
||||
remark = CASE
|
||||
WHEN remark IS NULL OR TRIM(remark) = '' THEN '存储服务:含细胞存储与科研捐赠统一业务入口'
|
||||
WHEN remark LIKE '%细胞存储%' THEN REPLACE(remark, '细胞存储', '存储服务')
|
||||
ELSE remark
|
||||
END,
|
||||
update_time = NOW()
|
||||
WHERE del_flag = 0
|
||||
AND id = 'CEL00000000000000000000000000001'
|
||||
AND menu_name = '细胞存储';
|
||||
@@ -1,72 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 运费信息菜单:插入 sys_menu,并授权给已有「样本信息管理」的角色
|
||||
-- 菜单位置:样本信息管理 之下、缴费财务确认及开票 之上
|
||||
-- id: 50000000000000000000000000000021
|
||||
-- perms: cell-storage:sample-freight:list
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
'50000000000000000000000000000021',
|
||||
'运费信息',
|
||||
'CEL00000000000000000000000000001',
|
||||
'C',
|
||||
'sample-freight',
|
||||
'/cell-storage/sample-freight/index',
|
||||
'cell-storage:sample-freight:list',
|
||||
1,
|
||||
'1',
|
||||
'1',
|
||||
'zkxw',
|
||||
(SELECT order_num + 1 FROM sys_menu WHERE id = '50000000000000000000000000000013' AND del_flag = 0 LIMIT 1),
|
||||
'细胞存储:运费信息',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu m
|
||||
WHERE m.del_flag = 0
|
||||
AND (
|
||||
m.id = '50000000000000000000000000000021'
|
||||
OR m.perms = 'cell-storage:sample-freight:list'
|
||||
)
|
||||
);
|
||||
|
||||
-- 已有样本信息管理权限的角色补授权(幂等)
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(r.id, '|50000000000000000000000000000021')),
|
||||
r.id,
|
||||
'50000000000000000000000000000021',
|
||||
IFNULL(NULLIF(r.tenant_no, ''), 'zkxw'),
|
||||
0,
|
||||
'grant sample-freight to sample-info roles',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role r
|
||||
INNER JOIN sys_role_menu rm
|
||||
ON rm.role_id = r.id
|
||||
AND rm.menu_id = '50000000000000000000000000000013'
|
||||
AND rm.del_flag = 0
|
||||
WHERE r.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu x
|
||||
WHERE x.role_id = r.id
|
||||
AND x.menu_id = '50000000000000000000000000000021'
|
||||
AND x.del_flag = 0
|
||||
);
|
||||
@@ -1,30 +0,0 @@
|
||||
-- 若已按旧规则 YF+YYYY-MM-DD+序号 回填,纠正为 YF+YYYYMMDD+001~999
|
||||
|
||||
SET @grp := '';
|
||||
SET @seq := 0;
|
||||
|
||||
UPDATE biz_sample_freight f
|
||||
INNER JOIN (
|
||||
SELECT
|
||||
id,
|
||||
CONCAT(
|
||||
'YF',
|
||||
DATE_FORMAT(create_time, '%Y%m%d'),
|
||||
LPAD(
|
||||
@seq := IF(
|
||||
@grp = CONCAT(tenant_no, '|', DATE_FORMAT(create_time, '%Y%m%d')),
|
||||
@seq + 1,
|
||||
1
|
||||
),
|
||||
3,
|
||||
'0'
|
||||
)
|
||||
) AS new_no,
|
||||
@grp := CONCAT(tenant_no, '|', DATE_FORMAT(create_time, '%Y%m%d')) AS _grp
|
||||
FROM biz_sample_freight
|
||||
ORDER BY tenant_no ASC, create_time ASC, id ASC
|
||||
) t ON f.id = t.id
|
||||
SET f.freight_no = t.new_no;
|
||||
|
||||
ALTER TABLE biz_sample_freight
|
||||
MODIFY COLUMN freight_no VARCHAR(32) NOT NULL DEFAULT '' COMMENT '运费批次号 YF+YYYYMMDD+序号';
|
||||
@@ -1,35 +0,0 @@
|
||||
-- 运费批次号:YF + YYYYMMDD + 001~999(按租户、按日递增)
|
||||
|
||||
ALTER TABLE biz_sample_freight
|
||||
ADD COLUMN freight_no VARCHAR(32) NOT NULL DEFAULT '' COMMENT '运费批次号 YF+YYYYMMDD+序号' AFTER id;
|
||||
|
||||
-- 按租户 + 创建日回填历史数据
|
||||
SET @grp := '';
|
||||
SET @seq := 0;
|
||||
|
||||
UPDATE biz_sample_freight f
|
||||
INNER JOIN (
|
||||
SELECT
|
||||
id,
|
||||
CONCAT(
|
||||
'YF',
|
||||
DATE_FORMAT(create_time, '%Y%m%d'),
|
||||
LPAD(
|
||||
@seq := IF(
|
||||
@grp = CONCAT(tenant_no, '|', DATE_FORMAT(create_time, '%Y%m%d')),
|
||||
@seq + 1,
|
||||
1
|
||||
),
|
||||
3,
|
||||
'0'
|
||||
)
|
||||
) AS new_no,
|
||||
@grp := CONCAT(tenant_no, '|', DATE_FORMAT(create_time, '%Y%m%d')) AS _grp
|
||||
FROM biz_sample_freight
|
||||
ORDER BY tenant_no ASC, create_time ASC, id ASC
|
||||
) t ON f.id = t.id
|
||||
SET f.freight_no = t.new_no
|
||||
WHERE f.freight_no = '' OR f.freight_no IS NULL;
|
||||
|
||||
ALTER TABLE biz_sample_freight
|
||||
ADD UNIQUE KEY uk_sample_freight_tenant_no (tenant_no, freight_no);
|
||||
@@ -1,74 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 催缴信息菜单:插在缴费信息之下、样本信息管理之上
|
||||
-- id: 50000000000000000000000000000022
|
||||
-- perms / menuCode: cellStorage:paymentChase:list
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
'50000000000000000000000000000022',
|
||||
'催缴信息',
|
||||
'CEL00000000000000000000000000001',
|
||||
'C',
|
||||
'payment-chase',
|
||||
'/cell-storage/payment-chase/index',
|
||||
'cellStorage:paymentChase:list',
|
||||
1,
|
||||
'1',
|
||||
'1',
|
||||
'zkxw',
|
||||
5,
|
||||
'细胞存储:催缴信息(逾期∪近30天)',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu m
|
||||
WHERE m.del_flag = 0
|
||||
AND (
|
||||
m.id = '50000000000000000000000000000022'
|
||||
OR m.perms = 'cellStorage:paymentChase:list'
|
||||
)
|
||||
);
|
||||
|
||||
-- 已有缴费信息权限的角色补授权(幂等)
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(r.id, '|50000000000000000000000000000022')),
|
||||
r.id,
|
||||
'50000000000000000000000000000022',
|
||||
IFNULL(NULLIF(r.tenant_no, ''), 'zkxw'),
|
||||
0,
|
||||
'grant payment-chase to payment-info roles',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role r
|
||||
INNER JOIN sys_role_menu rm
|
||||
ON rm.role_id = r.id
|
||||
AND rm.del_flag = 0
|
||||
INNER JOIN sys_menu m
|
||||
ON m.id = rm.menu_id
|
||||
AND m.del_flag = 0
|
||||
AND m.perms = 'cellStorage:paymentInfo:list'
|
||||
WHERE r.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu x
|
||||
WHERE x.del_flag = 0
|
||||
AND x.role_id = r.id
|
||||
AND x.menu_id = '50000000000000000000000000000022'
|
||||
);
|
||||
@@ -1,24 +0,0 @@
|
||||
-- 样本级储存证质检信息(2026-10-09)
|
||||
-- 对齐 docs/superpowers/specs/2026-10-09-storage-cert-qc-info-design.md
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_sample_storage_cert_qc (
|
||||
id VARCHAR(64) NOT NULL COMMENT '主键ID',
|
||||
sample_code VARCHAR(32) NOT NULL COMMENT '样本编码',
|
||||
collection_protocol_id VARCHAR(64) DEFAULT NULL COMMENT '采集协议单ID冗余',
|
||||
qc_work_order_id VARCHAR(64) DEFAULT NULL COMMENT '最近填写来源工单ID',
|
||||
sample_type_key VARCHAR(32) NOT NULL COMMENT 'perinatal_hsc|uc_msc',
|
||||
payload TEXT NOT NULL COMMENT '业务字段 JSON',
|
||||
filled_flag CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否已保存过 0否1是',
|
||||
tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
order_num INT DEFAULT NULL COMMENT '排序号',
|
||||
remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
|
||||
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
|
||||
create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间',
|
||||
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间',
|
||||
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_storage_cert_qc_tenant_sample (tenant_no, sample_code),
|
||||
KEY idx_storage_cert_qc_protocol (tenant_no, collection_protocol_id, del_flag)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本储存证质检信息';
|
||||
@@ -1,125 +0,0 @@
|
||||
-- 移动市场现场 scope 查询复合索引
|
||||
-- MySQL 5.7;通过 information_schema + PREPARE 保证重复执行安全。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_collection_region_staff'
|
||||
AND INDEX_NAME = 'idx_crs_market_leader_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_collection_region_staff` ADD INDEX `idx_crs_market_leader_scope` (`tenant_no`,`user_id`,`is_leader`,`del_flag`,`region_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_presign_order'
|
||||
AND INDEX_NAME = 'idx_po_market_sales_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_presign_order` ADD INDEX `idx_po_market_sales_scope` (`tenant_no`,`del_flag`,`sales_user_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_presign_order'
|
||||
AND INDEX_NAME = 'idx_po_market_region_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_presign_order` ADD INDEX `idx_po_market_region_scope` (`tenant_no`,`del_flag`,`region_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_presign_order'
|
||||
AND INDEX_NAME = 'idx_po_market_manager_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_presign_order` ADD INDEX `idx_po_market_manager_scope` (`tenant_no`,`del_flag`,`region_manager_user_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_protocol_collection_info'
|
||||
AND INDEX_NAME = 'idx_pci_market_parent_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_protocol_collection_info` ADD INDEX `idx_pci_market_parent_scope` (`tenant_no`,`del_flag`,`collection_protocol_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_protocol_collection_info'
|
||||
AND INDEX_NAME = 'idx_pci_market_sales_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_protocol_collection_info` ADD INDEX `idx_pci_market_sales_scope` (`tenant_no`,`del_flag`,`sales_user_id`,`collection_protocol_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_protocol_collection_info'
|
||||
AND INDEX_NAME = 'idx_pci_market_region_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_protocol_collection_info` ADD INDEX `idx_pci_market_region_scope` (`tenant_no`,`del_flag`,`region_id`,`collection_protocol_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_protocol_collection_info'
|
||||
AND INDEX_NAME = 'idx_pci_market_manager_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_protocol_collection_info` ADD INDEX `idx_pci_market_manager_scope` (`tenant_no`,`del_flag`,`region_manager_user_id`,`collection_protocol_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_research_donation_collection'
|
||||
AND INDEX_NAME = 'idx_rdc_market_parent_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_research_donation_collection` ADD INDEX `idx_rdc_market_parent_scope` (`tenant_no`,`del_flag`,`research_donation_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_research_donation_collection'
|
||||
AND INDEX_NAME = 'idx_rdc_market_sales_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_research_donation_collection` ADD INDEX `idx_rdc_market_sales_scope` (`tenant_no`,`del_flag`,`sales_user_id`,`research_donation_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_research_donation_collection'
|
||||
AND INDEX_NAME = 'idx_rdc_market_region_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_research_donation_collection` ADD INDEX `idx_rdc_market_region_scope` (`tenant_no`,`del_flag`,`region_id`,`research_donation_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_research_donation_collection'
|
||||
AND INDEX_NAME = 'idx_rdc_market_manager_scope'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE `biz_research_donation_collection` ADD INDEX `idx_rdc_market_manager_scope` (`tenant_no`,`del_flag`,`region_manager_user_id`,`research_donation_id`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@@ -1,235 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 小程序市场现场菜单:市场专员 / 地区经理两套目录与十二个业务叶子
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. 面向每个租户已有的「移动端菜单」根生成菜单,不写死租户编号。
|
||||
-- 2. 目录仅用于角色授权分组,path 为空;首页只展示叶子。
|
||||
-- 3. 不在此处写入 sys_role_menu;由管理员在 Web 角色管理中手工勾选。
|
||||
-- 4. MySQL 5.7 可执行;所有 INSERT 均以 NOT EXISTS 保证幂等。
|
||||
-- =============================================================================
|
||||
|
||||
-- 市场专员目录
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(
|
||||
'mobile-market-directory|', root.tenant_no, '|', root.id, '|M||市场专员'
|
||||
)),
|
||||
'市场专员',
|
||||
root.id,
|
||||
'M',
|
||||
'',
|
||||
NULL,
|
||||
NULL,
|
||||
1,
|
||||
'1',
|
||||
'1',
|
||||
root.tenant_no,
|
||||
10,
|
||||
'小程序市场现场:市场专员菜单目录',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_menu root
|
||||
WHERE root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_menu existing
|
||||
WHERE existing.tenant_no = root.tenant_no
|
||||
AND existing.parent_id = root.id
|
||||
AND existing.menu_name = '市场专员'
|
||||
AND existing.menu_type = 'M'
|
||||
AND existing.path = ''
|
||||
AND existing.del_flag = 0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
tenant_no = VALUES(tenant_no),
|
||||
parent_id = VALUES(parent_id),
|
||||
menu_name = VALUES(menu_name),
|
||||
menu_type = VALUES(menu_type),
|
||||
path = VALUES(path),
|
||||
component = VALUES(component),
|
||||
perms = VALUES(perms),
|
||||
is_frame = VALUES(is_frame),
|
||||
visible = VALUES(visible),
|
||||
status = VALUES(status),
|
||||
order_num = VALUES(order_num),
|
||||
remark = VALUES(remark),
|
||||
update_time = VALUES(update_time),
|
||||
del_flag = VALUES(del_flag);
|
||||
|
||||
-- 地区经理目录
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(
|
||||
'mobile-market-directory|', root.tenant_no, '|', root.id, '|M||地区经理'
|
||||
)),
|
||||
'地区经理',
|
||||
root.id,
|
||||
'M',
|
||||
'',
|
||||
NULL,
|
||||
NULL,
|
||||
1,
|
||||
'1',
|
||||
'1',
|
||||
root.tenant_no,
|
||||
20,
|
||||
'小程序市场现场:地区经理菜单目录',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_menu root
|
||||
WHERE root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_menu existing
|
||||
WHERE existing.tenant_no = root.tenant_no
|
||||
AND existing.parent_id = root.id
|
||||
AND existing.menu_name = '地区经理'
|
||||
AND existing.menu_type = 'M'
|
||||
AND existing.path = ''
|
||||
AND existing.del_flag = 0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
tenant_no = VALUES(tenant_no),
|
||||
parent_id = VALUES(parent_id),
|
||||
menu_name = VALUES(menu_name),
|
||||
menu_type = VALUES(menu_type),
|
||||
path = VALUES(path),
|
||||
component = VALUES(component),
|
||||
perms = VALUES(perms),
|
||||
is_frame = VALUES(is_frame),
|
||||
visible = VALUES(visible),
|
||||
status = VALUES(status),
|
||||
order_num = VALUES(order_num),
|
||||
remark = VALUES(remark),
|
||||
update_time = VALUES(update_time),
|
||||
del_flag = VALUES(del_flag);
|
||||
|
||||
-- 十二个业务叶子:每个角色目录各六个,均进入小程序受控 web-view。
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(
|
||||
'mobile-market-leaf|', root.tenant_no, '|', root.id, '|', directory.id,
|
||||
'|', entry.path, '|', entry.perms
|
||||
)),
|
||||
entry.menu_name,
|
||||
directory.id,
|
||||
'C',
|
||||
entry.path,
|
||||
NULL,
|
||||
entry.perms,
|
||||
1,
|
||||
'1',
|
||||
'1',
|
||||
root.tenant_no,
|
||||
entry.order_num,
|
||||
CONCAT('小程序市场现场:', entry.role_name, ' / ', entry.menu_name),
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_menu root
|
||||
INNER JOIN (
|
||||
SELECT 'specialist' AS role_code, '市场专员' AS role_name,
|
||||
'presign' AS module_code, '预签约订单' AS menu_name,
|
||||
'/pages/market/webview/index?entry=specialist-presign' AS path,
|
||||
'mini:market:specialist:presign' AS perms, 10 AS order_num
|
||||
UNION ALL
|
||||
SELECT 'specialist', '市场专员', 'protocol', '协议单',
|
||||
'/pages/market/webview/index?entry=specialist-protocol',
|
||||
'mini:market:specialist:protocol', 20
|
||||
UNION ALL
|
||||
SELECT 'specialist', '市场专员', 'payment-chase', '催缴信息',
|
||||
'/pages/market/webview/index?entry=specialist-payment-chase',
|
||||
'mini:market:specialist:payment-chase', 30
|
||||
UNION ALL
|
||||
SELECT 'specialist', '市场专员', 'payment', '缴费信息',
|
||||
'/pages/market/webview/index?entry=specialist-payment',
|
||||
'mini:market:specialist:payment', 40
|
||||
UNION ALL
|
||||
SELECT 'specialist', '市场专员', 'freight', '运费信息',
|
||||
'/pages/market/webview/index?entry=specialist-freight',
|
||||
'mini:market:specialist:freight', 50
|
||||
UNION ALL
|
||||
SELECT 'manager', '地区经理', 'presign', '预签约订单',
|
||||
'/pages/market/webview/index?entry=manager-presign',
|
||||
'mini:market:manager:presign', 10
|
||||
UNION ALL
|
||||
SELECT 'manager', '地区经理', 'protocol', '协议单',
|
||||
'/pages/market/webview/index?entry=manager-protocol',
|
||||
'mini:market:manager:protocol', 20
|
||||
UNION ALL
|
||||
SELECT 'manager', '地区经理', 'payment-chase', '催缴信息',
|
||||
'/pages/market/webview/index?entry=manager-payment-chase',
|
||||
'mini:market:manager:payment-chase', 30
|
||||
UNION ALL
|
||||
SELECT 'manager', '地区经理', 'payment', '缴费信息',
|
||||
'/pages/market/webview/index?entry=manager-payment',
|
||||
'mini:market:manager:payment', 40
|
||||
UNION ALL
|
||||
SELECT 'manager', '地区经理', 'freight', '运费信息',
|
||||
'/pages/market/webview/index?entry=manager-freight',
|
||||
'mini:market:manager:freight', 50
|
||||
) entry
|
||||
ON 1 = 1
|
||||
INNER JOIN sys_menu directory
|
||||
ON directory.tenant_no = root.tenant_no
|
||||
AND directory.parent_id = root.id
|
||||
AND directory.menu_name = entry.role_name
|
||||
AND directory.menu_type = 'M'
|
||||
AND directory.path = ''
|
||||
AND directory.del_flag = 0
|
||||
WHERE root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_menu existing
|
||||
WHERE existing.tenant_no = root.tenant_no
|
||||
AND existing.parent_id = directory.id
|
||||
AND existing.menu_name = entry.menu_name
|
||||
AND existing.perms = entry.perms
|
||||
AND existing.path = entry.path
|
||||
AND existing.menu_type = 'C'
|
||||
AND existing.del_flag = 0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
tenant_no = VALUES(tenant_no),
|
||||
parent_id = VALUES(parent_id),
|
||||
menu_name = VALUES(menu_name),
|
||||
menu_type = VALUES(menu_type),
|
||||
path = VALUES(path),
|
||||
component = VALUES(component),
|
||||
perms = VALUES(perms),
|
||||
is_frame = VALUES(is_frame),
|
||||
visible = VALUES(visible),
|
||||
status = VALUES(status),
|
||||
order_num = VALUES(order_num),
|
||||
remark = VALUES(remark),
|
||||
update_time = VALUES(update_time),
|
||||
del_flag = VALUES(del_flag);
|
||||
@@ -1,56 +0,0 @@
|
||||
-- 预签约订单转正式协议唯一约束
|
||||
-- MySQL 5.7;重复执行安全。历史重复或错误同名索引会给出明确修复提示。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS `ensure_presign_convert_unique`$$
|
||||
CREATE PROCEDURE `ensure_presign_convert_unique`()
|
||||
BEGIN
|
||||
DECLARE duplicate_sources INT DEFAULT 0;
|
||||
DECLARE idx_entries INT DEFAULT 0;
|
||||
DECLARE idx_non_unique INT DEFAULT 1;
|
||||
DECLARE idx_columns VARCHAR(512) DEFAULT '';
|
||||
|
||||
SELECT COUNT(*) INTO duplicate_sources
|
||||
FROM (
|
||||
SELECT `tenant_no`, `source_presign_order_id`
|
||||
FROM `biz_collection_protocol`
|
||||
WHERE `source_presign_order_id` IS NOT NULL
|
||||
AND `source_presign_order_id` <> ''
|
||||
GROUP BY `tenant_no`, `source_presign_order_id`
|
||||
HAVING COUNT(*) > 1
|
||||
) duplicate_rows;
|
||||
|
||||
IF duplicate_sources > 0 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'duplicate presign conversions found; remediation: reconcile duplicate formal protocols before rerunning migration';
|
||||
END IF;
|
||||
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COALESCE(MIN(NON_UNIQUE), 1),
|
||||
COALESCE(GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','), '')
|
||||
INTO idx_entries, idx_non_unique, idx_columns
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_collection_protocol'
|
||||
AND INDEX_NAME = 'uk_collection_protocol_presign_source';
|
||||
|
||||
IF idx_entries = 0 THEN
|
||||
ALTER TABLE `biz_collection_protocol`
|
||||
ADD UNIQUE INDEX `uk_collection_protocol_presign_source`
|
||||
(`tenant_no`, `source_presign_order_id`);
|
||||
ELSEIF idx_entries <> 2
|
||||
OR idx_non_unique <> 0
|
||||
OR idx_columns <> 'tenant_no,source_presign_order_id' THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'wrong index uk_collection_protocol_presign_source; remediation: drop or rename it, then rerun migration';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
CALL `ensure_presign_convert_unique`()$$
|
||||
DROP PROCEDURE `ensure_presign_convert_unique`$$
|
||||
|
||||
DELIMITER ;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- 意向金不落协议侧快照表:正式协议通过 biz_collection_protocol.source_presign_order_id
|
||||
-- 关联预签订单,读写同一份 biz_presign_transfer,保证财务审批/调整两边一致。
|
||||
--
|
||||
-- 若此前已执行过「创建 biz_protocol_deposit_transfer」的旧脚本,执行下方 DROP 清理。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP TABLE IF EXISTS `biz_protocol_deposit_transfer`;
|
||||
@@ -1,192 +0,0 @@
|
||||
-- 市场移动端运费批次查询与并发关联约束(MySQL 5.7,幂等)。
|
||||
-- 若存量数据冲突或同名对象定义错误则明确中止,不静默移动/删除数据。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS `ensure_market_freight_indexes`$$
|
||||
CREATE PROCEDURE `ensure_market_freight_indexes`()
|
||||
BEGIN
|
||||
DECLARE duplicate_rows INT DEFAULT 0;
|
||||
DECLARE object_count INT DEFAULT 0;
|
||||
DECLARE index_non_unique INT DEFAULT 1;
|
||||
DECLARE index_columns VARCHAR(512) DEFAULT '';
|
||||
DECLARE index_prefix_count INT DEFAULT 0;
|
||||
DECLARE index_type_count INT DEFAULT 0;
|
||||
DECLARE index_type_name VARCHAR(32) DEFAULT '';
|
||||
DECLARE generated_extra VARCHAR(255) DEFAULT '';
|
||||
DECLARE generated_expression TEXT;
|
||||
DECLARE normalized_generation_expression TEXT;
|
||||
DECLARE generated_data_type VARCHAR(64) DEFAULT '';
|
||||
DECLARE generated_length BIGINT DEFAULT 0;
|
||||
DECLARE generated_nullable VARCHAR(3) DEFAULT '';
|
||||
DECLARE exact_freight_unique_indexes INT DEFAULT 0;
|
||||
|
||||
SELECT COUNT(*) INTO duplicate_rows
|
||||
FROM (
|
||||
SELECT tenant_no, freight_no
|
||||
FROM biz_sample_freight
|
||||
GROUP BY tenant_no, freight_no
|
||||
HAVING COUNT(*) > 1
|
||||
) duplicate_freights;
|
||||
IF duplicate_rows > 0 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'duplicate freight numbers found; reconcile them before rerunning migration';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO duplicate_rows
|
||||
FROM (
|
||||
SELECT tenant_no, sample_source, sample_id
|
||||
FROM biz_sample_freight_item
|
||||
WHERE del_flag = 0
|
||||
GROUP BY tenant_no, sample_source, sample_id
|
||||
HAVING COUNT(*) > 1
|
||||
) duplicate_active_links;
|
||||
IF duplicate_rows > 0 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'duplicate active sample freight links found; reconcile them before rerunning migration';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO object_count
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_sample_freight_item'
|
||||
AND COLUMN_NAME = 'active_sample_key';
|
||||
IF object_count = 0 THEN
|
||||
ALTER TABLE biz_sample_freight_item
|
||||
ADD COLUMN active_sample_key VARCHAR(170)
|
||||
GENERATED ALWAYS AS (
|
||||
IF(del_flag = 0, CONCAT(tenant_no, '#', sample_source, '#', sample_id), NULL)
|
||||
) STORED;
|
||||
ELSE
|
||||
SELECT EXTRA, GENERATION_EXPRESSION, DATA_TYPE,
|
||||
CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE
|
||||
INTO generated_extra, generated_expression, generated_data_type,
|
||||
generated_length, generated_nullable
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_sample_freight_item'
|
||||
AND COLUMN_NAME = 'active_sample_key';
|
||||
SET normalized_generation_expression = LOWER(
|
||||
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
|
||||
COALESCE(generated_expression, ''),
|
||||
'`', ''), ' ', ''), CHAR(9), ''), CHAR(10), ''), '(', ''), ')', '')
|
||||
);
|
||||
IF UPPER(COALESCE(generated_extra, '')) NOT LIKE '%STORED GENERATED%'
|
||||
OR LOWER(COALESCE(generated_data_type, '')) <> 'varchar'
|
||||
OR COALESCE(generated_length, 0) <> 170
|
||||
OR UPPER(COALESCE(generated_nullable, '')) <> 'YES'
|
||||
OR normalized_generation_expression <>
|
||||
'ifdel_flag=0,concattenant_no,''#'',sample_source,''#'',sample_id,null' THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'wrong active_sample_key type/expression; drop column and dependent index, then rerun migration';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*), COALESCE(MIN(NON_UNIQUE), 1),
|
||||
COALESCE(GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','), ''),
|
||||
SUM(CASE WHEN SUB_PART IS NOT NULL THEN 1 ELSE 0 END),
|
||||
COUNT(DISTINCT INDEX_TYPE), COALESCE(MIN(INDEX_TYPE), '')
|
||||
INTO object_count, index_non_unique, index_columns,
|
||||
index_prefix_count, index_type_count, index_type_name
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_sample_freight_item'
|
||||
AND INDEX_NAME = 'uk_bsfi_active_sample';
|
||||
IF object_count = 0 THEN
|
||||
ALTER TABLE biz_sample_freight_item
|
||||
ADD UNIQUE INDEX uk_bsfi_active_sample (active_sample_key);
|
||||
ELSEIF object_count <> 1 OR index_non_unique <> 0
|
||||
OR index_columns <> 'active_sample_key' OR index_prefix_count <> 0
|
||||
OR index_type_count <> 1 OR index_type_name <> 'BTREE' THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'wrong index uk_bsfi_active_sample; drop it and rerun migration';
|
||||
END IF;
|
||||
|
||||
-- 旧迁移已创建 uk_sample_freight_tenant_no;任何名称的等价精确唯一索引均复用。
|
||||
-- 但若本迁移预留名称已存在且定义错误,必须先修复,不能静默绕过。
|
||||
SELECT COUNT(*), COALESCE(MIN(NON_UNIQUE), 1),
|
||||
COALESCE(GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','), ''),
|
||||
SUM(CASE WHEN SUB_PART IS NOT NULL THEN 1 ELSE 0 END),
|
||||
COUNT(DISTINCT INDEX_TYPE), COALESCE(MIN(INDEX_TYPE), '')
|
||||
INTO object_count, index_non_unique, index_columns,
|
||||
index_prefix_count, index_type_count, index_type_name
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_sample_freight'
|
||||
AND INDEX_NAME = 'uk_bsf_tenant_freight_no';
|
||||
IF object_count > 0 AND (object_count <> 2 OR index_non_unique <> 0
|
||||
OR index_columns <> 'tenant_no,freight_no' OR index_prefix_count <> 0
|
||||
OR index_type_count <> 1 OR index_type_name <> 'BTREE') THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'wrong index uk_bsf_tenant_freight_no; drop it and rerun migration';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO exact_freight_unique_indexes
|
||||
FROM (
|
||||
SELECT INDEX_NAME
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_sample_freight'
|
||||
GROUP BY INDEX_NAME
|
||||
HAVING MIN(NON_UNIQUE) = 0
|
||||
AND MAX(NON_UNIQUE) = 0
|
||||
AND GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') =
|
||||
'tenant_no,freight_no'
|
||||
AND SUM(CASE WHEN SUB_PART IS NOT NULL THEN 1 ELSE 0 END) = 0
|
||||
AND COUNT(DISTINCT INDEX_TYPE) = 1
|
||||
AND MIN(INDEX_TYPE) = 'BTREE'
|
||||
) exact_freight_unique_index_definitions;
|
||||
IF exact_freight_unique_indexes = 0 THEN
|
||||
ALTER TABLE biz_sample_freight
|
||||
ADD UNIQUE INDEX uk_bsf_tenant_freight_no (tenant_no, freight_no);
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*), COALESCE(MIN(NON_UNIQUE), 1),
|
||||
COALESCE(GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','), ''),
|
||||
SUM(CASE WHEN SUB_PART IS NOT NULL THEN 1 ELSE 0 END),
|
||||
COUNT(DISTINCT INDEX_TYPE), COALESCE(MIN(INDEX_TYPE), '')
|
||||
INTO object_count, index_non_unique, index_columns,
|
||||
index_prefix_count, index_type_count, index_type_name
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_sample_freight'
|
||||
AND INDEX_NAME = 'idx_bsf_market_list';
|
||||
IF object_count = 0 THEN
|
||||
ALTER TABLE biz_sample_freight
|
||||
ADD INDEX idx_bsf_market_list (tenant_no, del_flag, create_time, id);
|
||||
ELSEIF object_count <> 4 OR index_non_unique <> 1
|
||||
OR index_columns <> 'tenant_no,del_flag,create_time,id'
|
||||
OR index_prefix_count <> 0 OR index_type_count <> 1
|
||||
OR index_type_name <> 'BTREE' THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'wrong index idx_bsf_market_list; drop it and rerun migration';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*), COALESCE(MIN(NON_UNIQUE), 1),
|
||||
COALESCE(GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','), ''),
|
||||
SUM(CASE WHEN SUB_PART IS NOT NULL THEN 1 ELSE 0 END),
|
||||
COUNT(DISTINCT INDEX_TYPE), COALESCE(MIN(INDEX_TYPE), '')
|
||||
INTO object_count, index_non_unique, index_columns,
|
||||
index_prefix_count, index_type_count, index_type_name
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_sample_freight_item'
|
||||
AND INDEX_NAME = 'idx_bsfi_market_batch';
|
||||
IF object_count = 0 THEN
|
||||
ALTER TABLE biz_sample_freight_item
|
||||
ADD INDEX idx_bsfi_market_batch (tenant_no, freight_id, del_flag, create_time, id);
|
||||
ELSEIF object_count <> 5 OR index_non_unique <> 1
|
||||
OR index_columns <> 'tenant_no,freight_id,del_flag,create_time,id'
|
||||
OR index_prefix_count <> 0 OR index_type_count <> 1
|
||||
OR index_type_name <> 'BTREE' THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'wrong index idx_bsfi_market_batch; drop it and rerun migration';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
CALL `ensure_market_freight_indexes`()$$
|
||||
DROP PROCEDURE `ensure_market_freight_indexes`$$
|
||||
|
||||
DELIMITER ;
|
||||
@@ -1,70 +0,0 @@
|
||||
-- 市场移动端催缴/缴费查询索引(MySQL 5.7)。
|
||||
-- 同名索引定义不一致时重建,重复执行保持幂等。
|
||||
|
||||
SET @schema_name = DATABASE();
|
||||
|
||||
SET @index_name = 'idx_biz_protocol_payment_schedule_market_scope';
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','),
|
||||
MIN(non_unique),
|
||||
MAX(non_unique),
|
||||
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END),
|
||||
COUNT(DISTINCT index_type),
|
||||
MIN(index_type)
|
||||
INTO @index_columns,
|
||||
@index_non_unique_min,
|
||||
@index_non_unique_max,
|
||||
@index_prefix_columns,
|
||||
@index_type_count,
|
||||
@index_type
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'biz_protocol_payment_schedule'
|
||||
AND index_name = @index_name;
|
||||
SET @ddl = CASE
|
||||
WHEN @index_columns = 'tenant_no,sample_payment_id,del_flag'
|
||||
AND @index_non_unique_min = 1
|
||||
AND @index_non_unique_max = 1
|
||||
AND @index_prefix_columns = 0
|
||||
AND @index_type_count = 1
|
||||
AND @index_type = 'BTREE' THEN 'SELECT 1'
|
||||
WHEN @index_columns IS NULL THEN
|
||||
'ALTER TABLE biz_protocol_payment_schedule ADD INDEX idx_biz_protocol_payment_schedule_market_scope (tenant_no, sample_payment_id, del_flag)'
|
||||
ELSE
|
||||
'ALTER TABLE biz_protocol_payment_schedule DROP INDEX idx_biz_protocol_payment_schedule_market_scope, ADD INDEX idx_biz_protocol_payment_schedule_market_scope (tenant_no, sample_payment_id, del_flag)'
|
||||
END;
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @index_name = 'idx_biz_protocol_payment_transfer_market_scope';
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','),
|
||||
MIN(non_unique),
|
||||
MAX(non_unique),
|
||||
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END),
|
||||
COUNT(DISTINCT index_type),
|
||||
MIN(index_type)
|
||||
INTO @index_columns,
|
||||
@index_non_unique_min,
|
||||
@index_non_unique_max,
|
||||
@index_prefix_columns,
|
||||
@index_type_count,
|
||||
@index_type
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'biz_protocol_payment_transfer'
|
||||
AND index_name = @index_name;
|
||||
SET @ddl = CASE
|
||||
WHEN @index_columns = 'tenant_no,schedule_id,del_flag'
|
||||
AND @index_non_unique_min = 1
|
||||
AND @index_non_unique_max = 1
|
||||
AND @index_prefix_columns = 0
|
||||
AND @index_type_count = 1
|
||||
AND @index_type = 'BTREE' THEN 'SELECT 1'
|
||||
WHEN @index_columns IS NULL THEN
|
||||
'ALTER TABLE biz_protocol_payment_transfer ADD INDEX idx_biz_protocol_payment_transfer_market_scope (tenant_no, schedule_id, del_flag)'
|
||||
ELSE
|
||||
'ALTER TABLE biz_protocol_payment_transfer DROP INDEX idx_biz_protocol_payment_transfer_market_scope, ADD INDEX idx_biz_protocol_payment_transfer_market_scope (tenant_no, schedule_id, del_flag)'
|
||||
END;
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -1,175 +0,0 @@
|
||||
-- 市场移动端样本统一列表、进度与轻量办理索引(MySQL 5.7)。
|
||||
-- 同名索引的列顺序、唯一性、前缀或类型不一致时重建;重复执行保持幂等。
|
||||
|
||||
SET @schema_name = DATABASE();
|
||||
|
||||
SET @table_name = 'biz_protocol_sample';
|
||||
SET @index_name = 'idx_bps_market_sample_code';
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
|
||||
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
|
||||
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
|
||||
@index_prefix_columns, @index_type_count, @index_type
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
|
||||
SET @ddl = CASE
|
||||
WHEN @index_columns = 'tenant_no,del_flag,sample_code' AND @index_non_unique_min = 1
|
||||
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
|
||||
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
|
||||
WHEN @index_columns IS NULL THEN
|
||||
'ALTER TABLE biz_protocol_sample ADD INDEX idx_bps_market_sample_code (tenant_no, del_flag, sample_code)'
|
||||
ELSE
|
||||
'ALTER TABLE biz_protocol_sample DROP INDEX idx_bps_market_sample_code, ADD INDEX idx_bps_market_sample_code (tenant_no, del_flag, sample_code)'
|
||||
END;
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @table_name = 'biz_protocol_sample';
|
||||
SET @index_name = 'idx_bps_market_parent';
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
|
||||
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
|
||||
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
|
||||
@index_prefix_columns, @index_type_count, @index_type
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
|
||||
SET @ddl = CASE
|
||||
WHEN @index_columns = 'tenant_no,del_flag,collection_protocol_id' AND @index_non_unique_min = 1
|
||||
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
|
||||
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
|
||||
WHEN @index_columns IS NULL THEN
|
||||
'ALTER TABLE biz_protocol_sample ADD INDEX idx_bps_market_parent (tenant_no, del_flag, collection_protocol_id)'
|
||||
ELSE
|
||||
'ALTER TABLE biz_protocol_sample DROP INDEX idx_bps_market_parent, ADD INDEX idx_bps_market_parent (tenant_no, del_flag, collection_protocol_id)'
|
||||
END;
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @table_name = 'biz_research_donation_sample';
|
||||
SET @index_name = 'idx_brds_market_sample_code';
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
|
||||
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
|
||||
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
|
||||
@index_prefix_columns, @index_type_count, @index_type
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
|
||||
SET @ddl = CASE
|
||||
WHEN @index_columns = 'tenant_no,del_flag,sample_code' AND @index_non_unique_min = 1
|
||||
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
|
||||
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
|
||||
WHEN @index_columns IS NULL THEN
|
||||
'ALTER TABLE biz_research_donation_sample ADD INDEX idx_brds_market_sample_code (tenant_no, del_flag, sample_code)'
|
||||
ELSE
|
||||
'ALTER TABLE biz_research_donation_sample DROP INDEX idx_brds_market_sample_code, ADD INDEX idx_brds_market_sample_code (tenant_no, del_flag, sample_code)'
|
||||
END;
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @table_name = 'biz_research_donation_sample';
|
||||
SET @index_name = 'idx_brds_market_parent';
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
|
||||
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
|
||||
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
|
||||
@index_prefix_columns, @index_type_count, @index_type
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
|
||||
SET @ddl = CASE
|
||||
WHEN @index_columns = 'tenant_no,del_flag,research_donation_id' AND @index_non_unique_min = 1
|
||||
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
|
||||
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
|
||||
WHEN @index_columns IS NULL THEN
|
||||
'ALTER TABLE biz_research_donation_sample ADD INDEX idx_brds_market_parent (tenant_no, del_flag, research_donation_id)'
|
||||
ELSE
|
||||
'ALTER TABLE biz_research_donation_sample DROP INDEX idx_brds_market_parent, ADD INDEX idx_brds_market_parent (tenant_no, del_flag, research_donation_id)'
|
||||
END;
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @table_name = 'biz_sample_reception';
|
||||
SET @index_name = 'idx_bsr_market_sample_progress';
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
|
||||
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
|
||||
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
|
||||
@index_prefix_columns, @index_type_count, @index_type
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
|
||||
SET @ddl = CASE
|
||||
WHEN @index_columns = 'tenant_no,reception_source,sample_code,del_flag,reception_time' AND @index_non_unique_min = 1
|
||||
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
|
||||
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
|
||||
WHEN @index_columns IS NULL THEN
|
||||
'ALTER TABLE biz_sample_reception ADD INDEX idx_bsr_market_sample_progress (tenant_no, reception_source, sample_code, del_flag, reception_time)'
|
||||
ELSE
|
||||
'ALTER TABLE biz_sample_reception DROP INDEX idx_bsr_market_sample_progress, ADD INDEX idx_bsr_market_sample_progress (tenant_no, reception_source, sample_code, del_flag, reception_time)'
|
||||
END;
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @table_name = 'biz_qc_work_order';
|
||||
SET @index_name = 'idx_bqwo_market_sample_progress';
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
|
||||
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
|
||||
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
|
||||
@index_prefix_columns, @index_type_count, @index_type
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
|
||||
SET @ddl = CASE
|
||||
WHEN @index_columns = 'tenant_no,reception_source,trigger_source,sample_code,del_flag' AND @index_non_unique_min = 1
|
||||
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
|
||||
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
|
||||
WHEN @index_columns IS NULL THEN
|
||||
'ALTER TABLE biz_qc_work_order ADD INDEX idx_bqwo_market_sample_progress (tenant_no, reception_source, trigger_source, sample_code, del_flag)'
|
||||
ELSE
|
||||
'ALTER TABLE biz_qc_work_order DROP INDEX idx_bqwo_market_sample_progress, ADD INDEX idx_bqwo_market_sample_progress (tenant_no, reception_source, trigger_source, sample_code, del_flag)'
|
||||
END;
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @table_name = 'biz_storage_release_review';
|
||||
SET @index_name = 'idx_bsrr_market_sample_progress';
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
|
||||
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
|
||||
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
|
||||
@index_prefix_columns, @index_type_count, @index_type
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
|
||||
SET @ddl = CASE
|
||||
WHEN @index_columns = 'tenant_no,reception_source,sample_code,del_flag,storage_time' AND @index_non_unique_min = 1
|
||||
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
|
||||
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
|
||||
WHEN @index_columns IS NULL THEN
|
||||
'ALTER TABLE biz_storage_release_review ADD INDEX idx_bsrr_market_sample_progress (tenant_no, reception_source, sample_code, del_flag, storage_time)'
|
||||
ELSE
|
||||
'ALTER TABLE biz_storage_release_review DROP INDEX idx_bsrr_market_sample_progress, ADD INDEX idx_bsrr_market_sample_progress (tenant_no, reception_source, sample_code, del_flag, storage_time)'
|
||||
END;
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @table_name = 'biz_protocol_storage_cert';
|
||||
SET @index_name = 'idx_bpsc_market_parent';
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
|
||||
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
|
||||
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
|
||||
@index_prefix_columns, @index_type_count, @index_type
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
|
||||
SET @ddl = CASE
|
||||
WHEN @index_columns = 'tenant_no,collection_protocol_id,del_flag' AND @index_non_unique_min = 1
|
||||
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
|
||||
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
|
||||
WHEN @index_columns IS NULL THEN
|
||||
'ALTER TABLE biz_protocol_storage_cert ADD INDEX idx_bpsc_market_parent (tenant_no, collection_protocol_id, del_flag)'
|
||||
ELSE
|
||||
'ALTER TABLE biz_protocol_storage_cert DROP INDEX idx_bpsc_market_parent, ADD INDEX idx_bpsc_market_parent (tenant_no, collection_protocol_id, del_flag)'
|
||||
END;
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @table_name = 'biz_protocol_fullmoon_todo';
|
||||
SET @index_name = 'idx_bpft_market_parent';
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
|
||||
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
|
||||
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
|
||||
@index_prefix_columns, @index_type_count, @index_type
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
|
||||
SET @ddl = CASE
|
||||
WHEN @index_columns = 'tenant_no,collection_protocol_id,del_flag' AND @index_non_unique_min = 1
|
||||
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
|
||||
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
|
||||
WHEN @index_columns IS NULL THEN
|
||||
'ALTER TABLE biz_protocol_fullmoon_todo ADD INDEX idx_bpft_market_parent (tenant_no, collection_protocol_id, del_flag)'
|
||||
ELSE
|
||||
'ALTER TABLE biz_protocol_fullmoon_todo DROP INDEX idx_bpft_market_parent, ADD INDEX idx_bpft_market_parent (tenant_no, collection_protocol_id, del_flag)'
|
||||
END;
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@@ -1,235 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 移动端菜单重组:市场部 / OA工作流两大目录
|
||||
--
|
||||
-- 目标结构:
|
||||
-- 移动端菜单
|
||||
-- ├─ 市场部
|
||||
-- │ ├─ 预签约订单(专员)…运费信息(专员)
|
||||
-- │ └─ 预签约订单(经理)…运费信息(经理)
|
||||
-- └─ OA工作流
|
||||
-- └─ 发起审批…开启微信通知
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. 复用原「市场专员」目录 id,改名为「市场部」;原「地区经理」目录软删。
|
||||
-- 2. 叶子 id / path / perms 不变,仅调整 parent_id、menu_name、order_num。
|
||||
-- 3. 已授权 OA 叶子的角色自动补授「OA工作流」目录(幂等)。
|
||||
-- 4. MySQL 5.7 可执行。
|
||||
-- =============================================================================
|
||||
|
||||
-- 1) 「市场专员」→「市场部」
|
||||
UPDATE sys_menu market_dir
|
||||
INNER JOIN sys_menu root
|
||||
ON root.id = market_dir.parent_id
|
||||
AND root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
SET
|
||||
market_dir.menu_name = '市场部',
|
||||
market_dir.order_num = 10,
|
||||
market_dir.remark = '小程序:市场部菜单目录',
|
||||
market_dir.update_time = NOW()
|
||||
WHERE market_dir.menu_type = 'M'
|
||||
AND market_dir.path = ''
|
||||
AND market_dir.del_flag = 0
|
||||
AND market_dir.menu_name IN ('市场专员', '市场部');
|
||||
|
||||
-- 2) 新建「OA工作流」目录(每个租户移动端根下一条)
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(
|
||||
'mobile-oa-directory|', root.tenant_no, '|', root.id, '|M||OA工作流'
|
||||
)),
|
||||
'OA工作流',
|
||||
root.id,
|
||||
'M',
|
||||
'',
|
||||
NULL,
|
||||
NULL,
|
||||
1,
|
||||
'1',
|
||||
'1',
|
||||
root.tenant_no,
|
||||
20,
|
||||
'小程序:OA工作流菜单目录',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_menu root
|
||||
WHERE root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_menu existing
|
||||
WHERE existing.tenant_no = root.tenant_no
|
||||
AND existing.parent_id = root.id
|
||||
AND existing.menu_name = 'OA工作流'
|
||||
AND existing.menu_type = 'M'
|
||||
AND existing.path = ''
|
||||
AND existing.del_flag = 0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
tenant_no = VALUES(tenant_no),
|
||||
parent_id = VALUES(parent_id),
|
||||
menu_name = VALUES(menu_name),
|
||||
menu_type = VALUES(menu_type),
|
||||
path = VALUES(path),
|
||||
order_num = VALUES(order_num),
|
||||
remark = VALUES(remark),
|
||||
update_time = VALUES(update_time),
|
||||
del_flag = VALUES(del_flag);
|
||||
|
||||
-- 3) 市场叶子:挂到「市场部」,名称加(专员)/(经理),调整排序
|
||||
UPDATE sys_menu leaf
|
||||
INNER JOIN sys_menu root
|
||||
ON root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
AND root.tenant_no = leaf.tenant_no
|
||||
INNER JOIN sys_menu market_dir
|
||||
ON market_dir.tenant_no = root.tenant_no
|
||||
AND market_dir.parent_id = root.id
|
||||
AND market_dir.menu_name = '市场部'
|
||||
AND market_dir.menu_type = 'M'
|
||||
AND market_dir.path = ''
|
||||
AND market_dir.del_flag = 0
|
||||
INNER JOIN (
|
||||
SELECT 'mini:market:specialist:presign' AS perms, '预签约订单(专员)' AS menu_name, 10 AS order_num
|
||||
UNION ALL SELECT 'mini:market:specialist:protocol', '协议单(专员)', 20
|
||||
UNION ALL SELECT 'mini:market:specialist:payment-chase', '催缴信息(专员)', 30
|
||||
UNION ALL SELECT 'mini:market:specialist:payment', '缴费信息(专员)', 40
|
||||
UNION ALL SELECT 'mini:market:specialist:freight', '运费信息(专员)', 50
|
||||
UNION ALL SELECT 'mini:market:manager:presign', '预签约订单(经理)', 60
|
||||
UNION ALL SELECT 'mini:market:manager:protocol', '协议单(经理)', 70
|
||||
UNION ALL SELECT 'mini:market:manager:payment-chase', '催缴信息(经理)', 80
|
||||
UNION ALL SELECT 'mini:market:manager:payment', '缴费信息(经理)', 90
|
||||
UNION ALL SELECT 'mini:market:manager:freight', '运费信息(经理)', 100
|
||||
) map ON map.perms = leaf.perms
|
||||
SET
|
||||
leaf.parent_id = market_dir.id,
|
||||
leaf.menu_name = map.menu_name,
|
||||
leaf.order_num = map.order_num,
|
||||
leaf.remark = CONCAT('小程序市场现场:', map.menu_name),
|
||||
leaf.update_time = NOW()
|
||||
WHERE leaf.menu_type = 'C'
|
||||
AND leaf.del_flag = 0
|
||||
AND leaf.perms LIKE 'mini:market:%';
|
||||
|
||||
-- 4) OA 叶子:挂到「OA工作流」
|
||||
UPDATE sys_menu leaf
|
||||
INNER JOIN sys_menu root
|
||||
ON root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
AND root.tenant_no = leaf.tenant_no
|
||||
INNER JOIN sys_menu oa_dir
|
||||
ON oa_dir.tenant_no = root.tenant_no
|
||||
AND oa_dir.parent_id = root.id
|
||||
AND oa_dir.menu_name = 'OA工作流'
|
||||
AND oa_dir.menu_type = 'M'
|
||||
AND oa_dir.path = ''
|
||||
AND oa_dir.del_flag = 0
|
||||
SET
|
||||
leaf.parent_id = oa_dir.id,
|
||||
leaf.update_time = NOW()
|
||||
WHERE leaf.del_flag = 0
|
||||
AND leaf.menu_type = 'C'
|
||||
AND (
|
||||
leaf.perms IN (
|
||||
'mini:oa:start',
|
||||
'mini:oa:drafts',
|
||||
'mini:oa:pending',
|
||||
'mini:oa:done',
|
||||
'mini:oa:mine',
|
||||
'mini:oa:cc',
|
||||
'oa:mention',
|
||||
'mini:oa:wechat-notify'
|
||||
)
|
||||
OR leaf.path IN (
|
||||
'/pages/oa/start/index',
|
||||
'/pages/oa/drafts/index',
|
||||
'/pages/oa/pending/index',
|
||||
'/pages/oa/done/index',
|
||||
'/pages/oa/mine/index',
|
||||
'/pages/oa/cc/index',
|
||||
'/pages/oa/mentions/index',
|
||||
'/pages/oa/wechat-notify/index'
|
||||
)
|
||||
);
|
||||
|
||||
-- 5) 软删「地区经理」目录(步骤 3 已把子节点迁到市场部;避免同表子查询触发 1093)
|
||||
UPDATE sys_menu manager_dir
|
||||
INNER JOIN sys_menu root
|
||||
ON root.id = manager_dir.parent_id
|
||||
AND root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
SET
|
||||
manager_dir.del_flag = 1,
|
||||
manager_dir.update_time = NOW(),
|
||||
manager_dir.remark = '已重组至市场部,软删'
|
||||
WHERE manager_dir.menu_type = 'M'
|
||||
AND manager_dir.path = ''
|
||||
AND manager_dir.menu_name = '地区经理'
|
||||
AND manager_dir.del_flag = 0;
|
||||
|
||||
-- 6) 已有 OA 叶子授权的角色补授「OA工作流」目录
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(r.id, '|', oa_dir.id)),
|
||||
r.id,
|
||||
oa_dir.id,
|
||||
IFNULL(NULLIF(r.tenant_no, ''), oa_dir.tenant_no),
|
||||
0,
|
||||
'grant OA工作流 directory to roles with OA leaf menus',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role r
|
||||
INNER JOIN sys_role_menu rm
|
||||
ON rm.role_id = r.id
|
||||
AND rm.del_flag = 0
|
||||
INNER JOIN sys_menu leaf
|
||||
ON leaf.id = rm.menu_id
|
||||
AND leaf.del_flag = 0
|
||||
AND leaf.menu_type = 'C'
|
||||
AND (
|
||||
leaf.perms IN (
|
||||
'mini:oa:start',
|
||||
'mini:oa:drafts',
|
||||
'mini:oa:pending',
|
||||
'mini:oa:done',
|
||||
'mini:oa:mine',
|
||||
'mini:oa:cc',
|
||||
'oa:mention',
|
||||
'mini:oa:wechat-notify'
|
||||
)
|
||||
OR leaf.path LIKE '/pages/oa/%'
|
||||
)
|
||||
INNER JOIN sys_menu oa_dir
|
||||
ON oa_dir.id = leaf.parent_id
|
||||
AND oa_dir.menu_name = 'OA工作流'
|
||||
AND oa_dir.menu_type = 'M'
|
||||
AND oa_dir.del_flag = 0
|
||||
WHERE r.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_role_menu x
|
||||
WHERE x.del_flag = 0
|
||||
AND x.role_id = r.id
|
||||
AND x.menu_id = oa_dir.id
|
||||
)
|
||||
GROUP BY r.id, oa_dir.id, r.tenant_no, oa_dir.tenant_no;
|
||||
@@ -1,15 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 菜单改名:缴费财务确认及开票 → 缴费财务确认
|
||||
-- id: 50000000000000000000000000000004
|
||||
-- perms: biz:cellStoragePaymentConfirm:list
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE sys_menu
|
||||
SET menu_name = '缴费财务确认',
|
||||
update_time = NOW()
|
||||
WHERE del_flag = 0
|
||||
AND (
|
||||
id = '50000000000000000000000000000004'
|
||||
OR perms = 'biz:cellStoragePaymentConfirm:list'
|
||||
)
|
||||
AND menu_name <> '缴费财务确认';
|
||||
@@ -1,219 +0,0 @@
|
||||
-- 样本客户服务扩展表 + 租户字典(2026-10-13)
|
||||
-- 对齐 docs/superpowers/specs/2026-10-13-customer-service-management-design.md
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_sample_customer_service (
|
||||
id VARCHAR(64) NOT NULL COMMENT '主键ID',
|
||||
sample_code VARCHAR(32) NOT NULL COMMENT '样本编码',
|
||||
collection_protocol_id VARCHAR(64) DEFAULT NULL COMMENT '采集协议单ID冗余',
|
||||
sms_send_date DATE DEFAULT NULL COMMENT '短信发送日期',
|
||||
sms_send_status VARCHAR(64) DEFAULT NULL COMMENT '短信发送情况',
|
||||
microbe_followup_date DATE DEFAULT NULL COMMENT '微生物回访日期',
|
||||
microbe_followup_status VARCHAR(64) DEFAULT NULL COMMENT '微生物回访情况',
|
||||
fullmoon_followup_date DATE DEFAULT NULL COMMENT '满月回访时间',
|
||||
fullmoon_followup_status VARCHAR(64) DEFAULT NULL COMMENT '满月回访情况',
|
||||
household_registered VARCHAR(64) DEFAULT NULL COMMENT '是否上户口',
|
||||
einvoice_send_time DATETIME DEFAULT NULL COMMENT '电子发票发送时间',
|
||||
effective_address VARCHAR(512) DEFAULT NULL COMMENT '有效地址',
|
||||
tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
order_num INT DEFAULT NULL COMMENT '排序号',
|
||||
remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
|
||||
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
|
||||
create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间',
|
||||
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间',
|
||||
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_bscs_tenant_sample (tenant_no, sample_code),
|
||||
KEY idx_bscs_protocol (tenant_no, collection_protocol_id, del_flag)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本客户服务扩展';
|
||||
-- 注:uk_bscs_tenant_sample 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_customer_service_dict (
|
||||
id VARCHAR(64) NOT NULL COMMENT '主键ID',
|
||||
dict_type VARCHAR(64) NOT NULL COMMENT '字典类型',
|
||||
dict_value VARCHAR(128) NOT NULL COMMENT '选项值',
|
||||
is_system CHAR(1) NOT NULL DEFAULT '0' COMMENT '1预置 0手输',
|
||||
tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
order_num INT DEFAULT NULL COMMENT '排序号',
|
||||
remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
|
||||
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
|
||||
create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间',
|
||||
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间',
|
||||
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_bcsd_tenant_type_value (tenant_no, dict_type, dict_value)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户服务字典';
|
||||
-- 注:uk_bcsd_tenant_type_value 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。
|
||||
|
||||
-- 预置字典种子(tenant_no=zkxw,is_system=1;幂等 NOT EXISTS)
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'sms_send_status', '已发', '1', 'zkxw', 1, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'sms_send_status' AND dict_value = '已发' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'sms_send_status', '合并发', '1', 'zkxw', 2, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'sms_send_status' AND dict_value = '合并发' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'sms_send_status', '/', '1', 'zkxw', 3, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'sms_send_status' AND dict_value = '/' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'microbe_followup_status', '回访成功', '1', 'zkxw', 1, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'microbe_followup_status' AND dict_value = '回访成功' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'microbe_followup_status', '无人接听', '1', 'zkxw', 2, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'microbe_followup_status' AND dict_value = '无人接听' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'microbe_followup_status', '挂机', '1', 'zkxw', 3, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'microbe_followup_status' AND dict_value = '挂机' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'microbe_followup_status', '微信通知', '1', 'zkxw', 4, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'microbe_followup_status' AND dict_value = '微信通知' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'fullmoon_followup_status', '回访成功', '1', 'zkxw', 1, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'fullmoon_followup_status' AND dict_value = '回访成功' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'fullmoon_followup_status', '无人接听', '1', 'zkxw', 2, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'fullmoon_followup_status' AND dict_value = '无人接听' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'fullmoon_followup_status', '挂机', '1', 'zkxw', 3, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'fullmoon_followup_status' AND dict_value = '挂机' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'fullmoon_followup_status', '微信通知', '1', 'zkxw', 4, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'fullmoon_followup_status' AND dict_value = '微信通知' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'household_registered', '是', '1', 'zkxw', 1, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'household_registered' AND dict_value = '是' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'household_registered', '否', '1', 'zkxw', 2, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'household_registered' AND dict_value = '否' AND del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO biz_customer_service_dict (
|
||||
id, dict_type, dict_value, is_system, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT REPLACE(UUID(), '-', ''), 'household_registered', '未回复', '1', 'zkxw', 3, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM biz_customer_service_dict
|
||||
WHERE tenant_no = 'zkxw' AND dict_type = 'household_registered' AND dict_value = '未回复' AND del_flag = 0
|
||||
);
|
||||
@@ -1,42 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 下线小程序「市场专员 / 地区经理 → 样本信息」菜单(2026-10-13)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- 样本信息(客服管理)不面向市场专员、地区经理;移动端入口与 OA H5 路由已移除。
|
||||
-- 本脚本将已入库的叶子菜单设为不可见,并软删除已有角色菜单关联,
|
||||
-- 避免历史授权仍可绕过入口直接调用样本 API。
|
||||
--
|
||||
-- perms:
|
||||
-- mini:market:specialist:sample
|
||||
-- mini:market:manager:sample
|
||||
-- visible: '0'=隐藏
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE sys_menu
|
||||
SET visible = '0',
|
||||
update_time = NOW()
|
||||
WHERE del_flag = 0
|
||||
AND (
|
||||
perms IN (
|
||||
'mini:market:specialist:sample',
|
||||
'mini:market:manager:sample'
|
||||
)
|
||||
OR path IN (
|
||||
'/pages/market/webview/index?entry=specialist-sample',
|
||||
'/pages/market/webview/index?entry=manager-sample'
|
||||
)
|
||||
);
|
||||
|
||||
UPDATE sys_role_menu
|
||||
SET del_flag = 1,
|
||||
update_time = NOW()
|
||||
WHERE sys_role_menu.del_flag = 0
|
||||
AND menu_id IN (
|
||||
SELECT id
|
||||
FROM sys_menu
|
||||
WHERE del_flag = 0
|
||||
AND perms IN (
|
||||
'mini:market:specialist:sample',
|
||||
'mini:market:manager:sample'
|
||||
)
|
||||
);
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user